Learn Neovim - Autocompletion Engine with nvim-cmp & Snippets
Series/Learn Neovim/Episode 16
Episode 16 of 28

Learn Neovim - Autocompletion Engine with nvim-cmp & Snippets

In this episode we build a modern autocompletion engine with nvim-cmp, connecting suggestion sources from LSP, buffer, and path, plus master the LuaSnip snippet engine with ready-to-use friendly-snippets.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After connecting Neovim to a language server via LSP in episode 15 — Neovim now understands your code semantically, but there is one thing that makes an editor feel alive every time you type: the autocompletion popup. In this episode we will build a modern autocompletion engine with nvim-cmp and master the LuaSnip snippet engine.

Consider a real scenario: you are writing a new handler in Go code and need to call the GetUserByID function that lives in another package. Without completion, you have to remember its exact name, type it manually, then hope you do not misspell it. With nvim-cmp, the moment you type Get, a popup appears with suggestions from the LSP symbol database — complete with signature, documentation, and the ability to Tab through arguments. The average developer types tens of thousands of characters a day; good completion saves most of the typos and API lookups, so thinking speed is once again aligned with typing speed — the philosophy we have discussed since episode 1.

Main Discussion

Getting to Know nvim-cmp: A Modular Completion Engine

nvim-cmp is a completion engine whose popup is only one of many parts. The real power lies in its source-based architecture: every suggestion source is a separate plugin, and you are free to combine them.

Source PluginSuggestion SourceWhen It Feels Useful
cmp-nvim-lspSymbols from the language server (LSP)Typing function/variable names from the project & dependencies
cmp-bufferWords from the current/open buffersTyping strings or terms identical to surrounding code
cmp-pathFile paths on the systemTyping ./ or ../ for module imports
cmp_luasnipSnippets from LuaSnipTyping snippet triggers like fn or fori
cmp-cmdlineCommand-line completion (:)Typing Ex commands like :bdelete

A fitting analogy: nvim-cmp is the logistics train, and each source is a goods supplier. LSP is the main supplier (goods always fresh and accurate), buffer is the local supplier (leftover goods you have used before), path is the address supplier, and snippets are the supplier of "ready-made package boxes" that just need filling. All suggestions are queued into one list, ranked, then displayed in the same popup.

Important

The most fatal mistake for beginners: installing nvim-cmp without cmp-nvim-lsp. As a result the completion popup still appears, but never contains suggestions from the language server — exactly like an IDE that has lost its symbol database. If you already configured LSP in episode 15 but completion is empty, the number one cause is this source being missing or capabilities not being passed.

Setting Up LSP Capabilities for Completion

In episode 15 we set up capabilities manually. With nvim-cmp, there is a cleaner helper:

lua/plugins/lsp.lua
-- Di dalam config nvim-lspconfig, GANTI blok capabilities ini:
local capabilities = vim.lsp.protocol.make_client_capabilities()
-- Ganti dengan:
local capabilities = require("cmp_nvim_lsp").default_capabilities()

The default_capabilities() function returns a make_client_capabilities() object already populated with all the capabilities nvim-cmp needs, including completionItem.snippetSupport and completionItem.resolveSupport. This removes the complexity of hand-crafting capabilities that we did in episode 15 — one line, all completion needs fulfilled.

Tip

Because capabilities is defined before handlers runs and is sent to all servers through the handler, swapping this block is enough to enable LSP completion in all installed languages — including tsserver, pyright, and gopls that we set up in episode 15.

Complete nvim-cmp + LuaSnip + friendly-snippets Configuration

This is the heart of this episode. Here is the complete production-grade spec:

lua/plugins/cmp.lua
return {
  {
    "hrsh7th/nvim-cmp",
    dependencies = {
      "hrsh7th/cmp-nvim-lsp",
      "hrsh7th/cmp-buffer",
      "hrsh7th/cmp-path",
      "hrsh7th/cmp-cmdline",
      "L3MON4D3/LuaSnip",
      "saadparwaiz1/cmp_luasnip",
      "rafamadriz/friendly-snippets",
    },
    config = function()
      local cmp = require("cmp")
      local luasnip = require("luasnip")
 
      require("luasnip.loaders.from_vscode").lazy_load()
      require("luasnip.loaders.from_lua").lazy_load({ paths = { vim.fn.stdpath("config") .. "/lua/snippets" } })
 
      cmp.setup({
        snippet = {
          expand = function(args)
            luasnip.lsp_expand(args.body)
          end,
        },
        window = {
          completion = cmp.config.window.bordered(),
          documentation = cmp.config.window.bordered(),
        },
        mapping = cmp.mapping.preset.insert({
          ["<Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_next_item()
            elseif luasnip.expand_or_jumpable() then
              luasnip.expand_or_jump()
            else
              fallback()
            end
          end, { "i", "s" }),
          ["<S-Tab>"] = cmp.mapping(function(fallback)
            if cmp.visible() then
              cmp.select_prev_item()
            elseif luasnip.jumpable(-1) then
              luasnip.jump(-1)
            else
              fallback()
            end
          end, { "i", "s" }),
          ["<CR>"] = cmp.mapping.confirm({ select = true }),
          ["<C-Space>"] = cmp.mapping.complete(),
          ["<C-e>"] = cmp.mapping.abort(),
          ["<C-n>"] = cmp.mapping.select_next_item(),
          ["<C-p>"] = cmp.mapping.select_prev_item(),
          ["<C-f>"] = cmp.mapping.scroll_docs(4),
          ["<C-b>"] = cmp.mapping.scroll_docs(-4),
        }),
        sources = cmp.config.sources({
          { name = "nvim_lsp" },
          { name = "luasnip" },
          { name = "buffer" },
          { name = "path" },
        }),
      })
    end,
  },
}

Let's dissect the important blocks:

  • snippet.expand — the critical piece that unites nvim-cmp with LuaSnip. When a snippet is selected, this function calls luasnip.lsp_expand to execute it. If this function is missing or uses the wrong engine, snippets will never expand — exactly the classic "snippet appears in the popup but does not work" problem.
  • mapping with cmp.mapping.preset.insert(...) — a preset providing healthy default mappings, then we add custom ones.
  • The <Tab> mapping — an elegant chain of priorities: if the popup is visible, Tab selects the next item; if not and there is a snippet that can be jumped, Tab jumps to the next snippet placeholder; if neither, it falls through to the fallback function (Vim's built-in Tab).
  • sources — the order determines default priority: LSP is always at the top, then snippets, buffer, and path as supplements.

Caution

Tab conflict is nvim-cmp's biggest enemy. If you have other plugins using <Tab> (e.g. tmux navigation, auto-pair, or which-key), they will fight over the keybinding. Solution: put the logic in order inside one Tab callback as in the example above, or move the other plugin to a different key combination. Do not register two separate <Tab> keymaps for different features — that is a recipe for confusing conflicts.

Controlling Suggestion Ranking in the Popup

When many sources send suggestions at once, the next question is: which suggestion appears on top? nvim-cmp uses comparators evaluated in sequence like filters in a pipeline — a suggestion that wins the first comparator is immediately considered better without needing to check the rest:

lua/plugins/cmp.lua
      sorting = {
        priority_weight = 2.0,
        comparators = {
          cmp.config.compare.offset,
          cmp.config.compare.exact,
          cmp.config.compare.score,
          cmp.config.compare.recently_used,
          cmp.config.compare.locality,
          cmp.config.compare.kind,
          cmp.config.compare.sort_text,
          cmp.config.compare.length,
          cmp.config.compare.order,
        },
      },

How it works:

  1. offset — suggestions exactly following the cursor win (the completion position is more relevant).
  2. exact — exact matches with the typed text win over partial matches.
  3. score — the internal fuzzy matching score of each source.
  4. recently_used — suggestions previously chosen are promoted (learning from habits).
  5. locality — suggestions closer to the cursor position in the file are favored.
  6. length — shorter suggestions win for near-tie cases.

Tip

If you feel the popup too often shows irrelevant suggestions, the most common cause is the locality and length comparators being used too early. The most common tweak: remove locality for the LSP source (let it win purely on score), or raise priority_weight to emphasize a certain source. The defaults are enough for most developers — only change them if it feels annoying.

The Daily Completion Workflow

Now let's see how all the parts work together in one coding session:

Alur kerja menulis fungsi dengan completion
1. Ketik "function getUser" → popup menampilkan: getUserByID (LSP),
   getUserByEmail (LSP), getUserFromCache (buffer), dst.
2. Tekan <C-n> / <C-p> untuk berpindah saran tanpa melepas Ctrl.
3. Tekan <C-b> untuk scroll dokumentasi saran yang sedang disorot.
4. Tekan <CR> untuk memilih → kode terisi + signature helper muncul.
5. Ketik trigger "fori" → pilih snippet → expand → Tab melalui placeholder.
6. Tekan <C-Space> kapan saja untuk memaksa popup muncul kembali.

Notice that the entire interaction happens on the home row, without touching the mouse or the number keys — consistent with the modal editing philosophy we have built since episode 2.

Command-line Completion: A Bonus from nvim-cmp

One feature often overlooked: nvim-cmp can also complete the command line (:). With cmp-cmdline, typing :bde shows :bdelete, and :set nos shows all the nos... options. This speeds up using Ex commands that are often retyped:

lua/plugins/cmp.lua
      -- Setup command-line completion terpisah dari buffer completion
      cmp.setup.cmdline(":", {
        mapping = cmp.mapping.preset.cmdline(),
        sources = cmp.config.sources({
          { name = "path" },
        }, {
          { name = "cmdline" },
        }),
      })

Because the command line has a different context (commands vs paths), its sources are isolated from normal completion — you get command suggestions when typing a command, and path suggestions when the command's argument is a file.

LuaSnip: A Modern Snippet Engine

LuaSnip is a fast snippet engine, written in Lua, and integrated directly with nvim-cmp. It supports three snippet styles at once:

  1. VS Code style (via from_vscode) — a standard format importable from community snippet repositories like friendly-snippets, which contains tens of thousands of ready-to-use snippets for all languages.
  2. Lua style (via from_lua) — snippets defined directly with Lua tables, the most expressive.
  3. SnipMate style (via from_snipmate) — the old Vim format.

The two loader lines in our config already cover the most important: from_vscode loads all of friendly-snippets, and from_lua loads the custom snippets we write ourselves.

Snippet Navigation: Tab and Shift-Tab

After a snippet expands, you will see placeholders to fill in. The workflow:

  1. Type fori then press <Tab> — nvim-cmp shows the "for index" snippet from friendly-snippets.
  2. Press <CR> to select — the snippet immediately expands into a complete for block with the cursor on the i = 1 placeholder.
  3. Type the value, press <Tab> — the cursor jumps to the next placeholder (the loop condition).
  4. Continue until all placeholders are filled. <S-Tab> to return to the previous placeholder.

This is tabstop navigation — exactly the feature that makes VS Code snippets so comfortable. The difference: in Neovim all of this runs without a mouse and without leaving the keyboard home row.

Writing Your Own Custom Snippets

friendly-snippets is great, but production teams almost always need specific snippets — for example a service pattern or a particular handler they write often. Let's create our own Lua snippet file:

lua/snippets/lua.lua
return {
  s("fn", {
    t({ "function ", "" }),
    i(1, "name"),
    t({ "(", "" }),
    i(2, "param"),
    t({ ")", "\t", "" }),
    i(3, "body"),
    t({ "", "end", "" }),
  }),
  s("fori", {
    t({ "for ", "" }),
    i(1, "i"),
    t({ " = ", "" }),
    i(2, "1"),
    t({ ", ", "" }),
    i(3, "n"),
    t({ " do", "\t", "" }),
    i(4, "body"),
    t({ "", "end", "" }),
  }),
  s("req", t('local M = require("$1")\nreturn M')),
}

The LuaSnip node structure:

  • s(trigger, nodes) — creates a new snippet with a trigger string.
  • t("text") — static text (can contain \t for tab, \n for newline).
  • i(n, "default") — an insert node (placeholder) numbered n. The number determines Tab navigation order.
  • c(1, { ... }) — a choice node (choose among several options) — a feature simple VS Code formats do not have.

With this one small file, typing fn then <Tab> produces a complete function template in three keystrokes. Multiply that by the patterns you often write in your team, and count how much time you save every day.

Common nvim-cmp & Snippets Pitfalls

MistakeSymptomSolution
Empty popup from LSPCompletion never contains suggestions from the projectAdd cmp-nvim-lsp to dependencies & sources; make sure capabilities uses cmp_nvim_lsp.default_capabilities()
Snippet cannot expandSelecting a snippet does nothingMake sure snippet.expand uses luasnip.lsp_expand; do not use another engine
Tab does not workTab instead indents or does nothingCheck for Tab keymap conflicts; make sure { "i", "s" } is the mode
<CR> selects an item while writing normal codeUnwanted EnterChange the <CR> mapping to cmp.mapping.confirm({ select = false }) or remove the mapping
Snippet selected twice / duplicatedSnippet items appear twice in the popupRemove cmp_luasnip or LuaSnip if duplicated in sources
Custom snippets do not appearLua snippet file not loadedMake sure the path in from_lua is correct and the file return { ... } has a snippet table
Performance drops on large filesThe popup feels heavy while typingDisable cmp-buffer for giant buffers or set max_item_count
LSP suggestions disappear after updateIt turns out capabilities is overwritten elsewhereCheck the lspconfig handler — capabilities must be passed to every server's setup()

Warning

A hidden trap: friendly-snippets loads snippets for all languages by default. In multi-language projects, this adds minor overhead and can surface irrelevant snippets in certain filetypes. A clean solution: replace require("luasnip.loaders.from_vscode").lazy_load() with selective per-language loading (lazy_load({ paths = { "~/some/snippets" } })), or set the filetype in sources so luasnip only activates for languages that actually need it.

Closing

In episode 16 we built a complete autocompletion engine: nvim-cmp as its center, four main sources (LSP, buffer, path, snippet) with managed priorities, cmp_nvim_lsp.default_capabilities() to connect to episode 15's LSP, and LuaSnip with friendly-snippets for a smooth expand-and-jump workflow. You can now also write custom snippets — a secret weapon for accelerating your team's recurring patterns.

Now when you type, the suggestion popup appears, snippets expand with tabstop navigation, and Neovim feels like a true modern IDE. But there is one side we have not touched yet: code consistency. Correct suggestions matter, but code written in a format uniform with your team is a professional standard that is non-negotiable.

In episode 17, we will cover Code Formatting & Linting with conform.nvim and nvim-lint — formatting code automatically on save and checking code quality asynchronously without freezing the editor. Stay motivated!

Learn Neovim - Autocompletion Engine with nvim-cmp & Snippets | Learn Neovim