Belajar Neovim - Autocompletion Engine dengan nvim-cmp & Snippets
Episode 16 of 28

Belajar Neovim - Autocompletion Engine dengan nvim-cmp & Snippets

Di episode ini kita akan membangun engine autocompletion modern dengan nvim-cmp, menghubungkan sumber saran dari LSP, buffer, dan path, plus menguasai snippet engine LuaSnip dengan friendly-snippets siap pakai.

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

Pendahuluan

Setelah di episode 15 sebelumnya kita menghubungkan Neovim dengan language server lewat LSP — sekarang Neovim memahami kode kalian secara semantik, tapi ada satu hal yang membuat editor terasa hidup setiap kali kalian mengetik: popup autocompletion. Di episode ini kita akan membangun engine autocompletion modern dengan nvim-cmp dan menguasai snippet engine LuaSnip.

Pertimbangkan skenario nyata: kalian menulis handler baru di kode Go dan perlu memanggil fungsi GetUserByID yang ada di package lain. Tanpa completion, kalian harus ingat nama persisnya, mengetik manual, lalu berharap tidak salah eja. Dengan nvim-cmp, begitu kalian mengetik Get, muncul popup berisi saran dari basis data simbol LSP — lengkap dengan signature, dokumentasi, dan kemampuan Tab untuk melompat antar argumen. Rata-rata developer mengetik puluhan ribu karakter per hari; completion yang baik menghemat sebagian besar typo dan pencarian API, sehingga kecepatan berpikir kembali sejajar dengan kecepatan mengetik — filosofi yang kita bahas sejak episode 1.

Pembahasan Utama

Mengenal nvim-cmp: Completion Engine yang Modular

nvim-cmp adalah completion engine yang popup-nya hanya satu dari banyak bagian. Kekuatan sesungguhnya ada pada arsitekturnya yang berbasis sources: setiap sumber saran adalah plugin terpisah, dan kalian bebas menggabungkannya.

Source PluginSumber SaranKapan Terasa Berguna
cmp-nvim-lspSimbol dari language server (LSP)Mengetik nama fungsi/variabel dari project & dependency
cmp-bufferKata dari buffer yang sedang/kalau terbukaMengetik string atau istilah yang sama dengan kode sekitarnya
cmp-pathPath file di sistemMengetik ./ atau ../ untuk import module
cmp_luasnipSnippet dari LuaSnipMengetik trigger snippet seperti fn atau fori
cmp-cmdlineCommand-line completion (:)Mengetik perintah Ex seperti :bdelete

Analogi yang tepat: nvim-cmp adalah kereta logistik, dan masing-masing source adalah pemasok barang. LSP pemasok utama (barang selalu segar dan akurat), buffer pemasok lokal (barang sisa yang kalian pernah pakai), path pemasok alamat, dan snippet pemasok "kotak paket jadi" yang tinggal diisi. Semua saran dijadikan satu antrian, di-ranking, lalu ditampilkan di popup yang sama.

Important

Kesalahan paling fatal bagi pemula: menginstall nvim-cmp tanpa cmp-nvim-lsp. Akibatnya popup completion tetap muncul, tapi tidak pernah berisi saran dari language server — persis seperti IDE yang kehilangan database simbolnya. Jika kalian sudah setting LSP di episode 15 tapi completion kosong, penyebab nomor satu adalah source ini tidak ada atau capabilities tidak di-passing.

Menyiapkan Capabilities LSP untuk Completion

Di episode 15 kita menyiapkan capabilities manual. Dengan nvim-cmp, ada helper yang lebih rapi:

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()

Fungsi default_capabilities() mengembalikan objek make_client_capabilities() yang sudah diisi semua kemampuan yang dibutuhkan nvim-cmp, termasuk completionItem.snippetSupport dan completionItem.resolveSupport. Ini menghilangkan kerumitan menyusun capabilities manual yang kita lakukan di episode 15 — satu baris, semua kebutuhan completion terpenuhi.

Tip

Karena capabilities didefinisikan sebelum handlers dijalankan dan dikirim ke semua server lewat handler, menukar blok ini sudah cukup untuk mengaktifkan completion LSP di semua bahasa yang terinstall — termasuk tsserver, pyright, dan gopls yang kita set di episode 15.

Konfigurasi Lengkap nvim-cmp + LuaSnip + friendly-snippets

Ini jantung episode ini. Berikut spec lengkap yang production-grade:

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,
  },
}

Mari kita bedah blok pentingnya:

  • snippet.expandcritical piece yang menyatukan nvim-cmp dengan LuaSnip. Ketika sebuah snippet terpilih, fungsi ini memanggil luasnip.lsp_expand untuk mengeksekusinya. Jika fungsi ini hilang atau salah engine, snippet tidak akan pernah bisa di-expand — persis masalah klasik "snippet muncul di popup tapi tidak bekerja".
  • mapping dengan cmp.mapping.preset.insert(...) — preset yang menyediakan pemetaan default yang sehat, lalu kita tambahkan yang kustom.
  • Mapping <Tab> — prioritas berantai yang elegan: jika popup visible, Tab memilih item berikutnya; jika tidak dan ada snippet yang bisa di-jump, Tab melompat ke placeholder snippet berikutnya; jika tidak keduanya, jatuh ke fungsi fallback (Tab bawaan Vim).
  • sources — urutan menentukan prioritas default: LSP selalu paling atas, lalu snippet, buffer, dan path sebagai pelengkap.

Caution

Konflik Tab adalah musuh terbesar nvim-cmp. Jika kalian punya plugin lain yang memakai <Tab> (misal tmux navigasi, auto-pair, atau which-key), mereka akan berebut keybinding. Solusi: urutkan logika di dalam satu callback Tab seperti contoh di atas, atau pindahkan plugin lain ke kombinasi tombol berbeda. Jangan mendaftarkan dua keymap <Tab> terpisah untuk fitur berbeda — itu resep konflik yang membingungkan.

Mengatur Ranking Saran di Popup

Ketika banyak source mengirim saran sekaligus, pertanyaan berikutnya adalah: saran mana yang tampil paling atas? nvim-cmp memakai comparators yang dievaluasi berurutan seperti penyaring pada pipeline — saran yang menang di comparator pertama langsung dianggap lebih baik tanpa perlu mengecek sisanya:

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,
        },
      },

Alur kerjanya:

  1. offset — saran yang persis mengikuti kursor menang (posisi completion lebih relevan).
  2. exact — kecocokan persis dengan teks yang diketik menang atas kecocokan parsial.
  3. score — skor internal fuzzy matching dari masing-masing source.
  4. recently_used — saran yang pernah dipilih sebelumnya dinaikkan (pembelajaran dari kebiasaan).
  5. locality — saran yang lebih dekat dengan posisi kursor dalam file diunggulkan.
  6. length — saran lebih pendek menang untuk kasus yang hampir seri.

Tip

Jika kalian merasa popup terlalu sering menampilkan saran yang tidak relevan, paling sering penyebabnya adalah comparator locality dan length yang terlalu cepat dipakai. Tweak paling umum: hapus locality untuk source LSP (biarkan ia menang murni berdasarkan score), atau naikkan priority_weight untuk menekankan source tertentu. Nilai default sudah cukup untuk mayoritas developer — ubah hanya jika terasa mengganggu.

Workflow Completion Sehari-hari

Sekarang mari kita lihat bagaimana semua bagian bekerja sama dalam satu sesi menulis kode:

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.

Perhatikan bahwa seluruh interaksi dilakukan di home row, tanpa menyentuh mouse maupun angka — konsisten dengan filosofi modal editing yang kita bangun sejak episode 2.

Command-line Completion: Bonus dari nvim-cmp

Satu fitur yang sering luput dari perhatian: nvim-cmp juga bisa melengkapi command-line (:). Dengan cmp-cmdline, mengetik :bde akan menampilkan :bdelete, dan :set nos akan menampilkan semua opsi nos.... Ini mempercepat penggunaan perintah Ex yang sering diketik berulang:

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" },
        }),
      })

Karena command-line punya konteks berbeda (perintah vs path), source-nya diisolasi dari completion normal — kalian mendapat saran perintah ketika mengetik perintah, dan saran path ketika argumen perintah adalah file.

LuaSnip: Snippet Engine Modern

LuaSnip adalah engine snippet yang cepat, ditulis dalam Lua, dan terintegrasi langsung dengan nvim-cmp. Ia mendukung tiga gaya snippet sekaligus:

  1. VS Code style (via from_vscode) — format standar yang bisa diimpor dari repositori snippet komunitas seperti friendly-snippets, yang berisi puluhan ribu snippet siap pakai untuk semua bahasa.
  2. Lua style (via from_lua) — snippet didefinisikan langsung dengan tabel Lua, paling ekspresif.
  3. SnipMate style (via from_snipmate) — format lama Vim.

Dua baris pemuat di config kita sudah mencakup yang paling penting: from_vscode memuat semua snippet friendly-snippets, dan from_lua memuat snippet kustom yang kita tulis sendiri.

Setelah snippet ter-expand, kalian akan melihat placeholder yang bisa diisi. Workflow-nya:

  1. Ketik fori lalu tekan <Tab> — nvim-cmp menampilkan snippet "for index" dari friendly-snippets.
  2. Tekan <CR> untuk memilih — snippet langsung ter-expand menjadi blok for lengkap dengan kursor di placeholder i = 1.
  3. Ketik nilai, tekan <Tab> — kursor melompat ke placeholder berikutnya (kondisi loop).
  4. Lanjutkan hingga semua placeholder terisi. <S-Tab> untuk kembali ke placeholder sebelumnya.

Ini adalah tabstop navigation — persis seperti feature yang membuat VS Code snippet begitu nyaman. Bedanya, di Neovim semua ini berjalan tanpa mouse dan tanpa meninggalkan keyboard home row.

friendly-snippets luar biasa, tapi tim production hampir selalu butuh snippet spesifik — misalnya pola service atau handler khusus yang sering ditulis. Mari kita buat file snippet Lua sendiri:

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')),
}

Struktur node LuaSnip:

  • s(trigger, nodes) — membuat snippet baru dengan trigger string.
  • t("teks") — teks statis (bisa berisi \t untuk tab, \n untuk baris baru).
  • i(n, "default") — insert node (placeholder) bernomor n. Nomor menentukan urutan navigasi Tab.
  • c(1, { ... }) — choice node (pilih di antara beberapa opsi) — fitur yang tidak dimiliki format VS Code sederhana.

Dengan satu file kecil ini, mengetik fn lalu <Tab> menghasilkan template fungsi lengkap dalam tiga ketukan. Kalikan dengan pola-pola yang sering kalian tulis di tim, dan hitung berapa banyak waktu yang dihemat setiap hari.

Kesalahan Umum Seputar nvim-cmp & Snippets

KesalahanGejalaSolusi
Popup kosong dari LSPCompletion tidak pernah berisi saran dari projectTambahkan cmp-nvim-lsp ke dependencies & sources; pastikan capabilities memakai cmp_nvim_lsp.default_capabilities()
Snippet tidak bisa expandMemilih snippet tapi tidak terjadi apa-apaPastikan snippet.expand memakai luasnip.lsp_expand; jangan memakai engine lain
Tab tidak berfungsiTab malah memberi indentasi atau tidak melakukan apa-apaCek konflik keymap Tab; pastikan { "i", "s" } sebagai mode
<CR> memilih item saat menulis kode normalEnter yang tidak diinginkanUbah <CR> mapping menjadi cmp.mapping.confirm({ select = false }) atau hapus mapping
Snippet terpilih dua kali / duplikatItem snippet muncul ganda di popupHapus cmp_luasnip atau LuaSnip jika terduplikat di sources
Snippet custom tidak munculFile snippet Lua tidak ter-loadPastikan path di from_lua benar dan file return { ... } punya tabel snippet
Performance menurun di file besarPopup terasa berat saat mengetikMatikan cmp-buffer untuk buffer raksasa atau set max_item_count
Saran dari LSP hilang setelah updateTernyata capabilities ditimpa di tempat lainCek handler lspconfig — capabilities harus dipassing ke setup() setiap server

Warning

Perangkap tersembunyi: friendly-snippets memuat snippet untuk semua bahasa secara default. Di project dengan banyak bahasa, ini menambah beban minor dan bisa memunculkan snippet yang tidak relevan di filetype tertentu. Solusi bersih: ganti require("luasnip.loaders.from_vscode").lazy_load() dengan pemuatan selektif per bahasa (lazy_load({ paths = { "~/some/snippets" } })), atau atur filetype di sources supaya luasnip hanya aktif untuk bahasa yang memang butuh.

Penutup

Pada episode 16 ini kita telah membangun engine autocompletion yang lengkap: nvim-cmp sebagai pusatnya, empat source utama (LSP, buffer, path, snippet) yang diatur prioritasnya, cmp_nvim_lsp.default_capabilities() untuk menghubungkan ke LSP episode 15, serta LuaSnip dengan friendly-snippets untuk expand-and-jump workflow yang mulus. Kalian juga sekarang bisa menulis snippet kustom — senjata rahasia untuk mempercepat pola-pola berulang di tim kalian.

Sekarang saat kalian mengetik, popup saran muncul, snippet ter-expand dengan tabstop navigation, dan Neovim terasa seperti IDE modern sesungguhnya. Namun ada satu sisi yang belum kita sentuh: konsistensi kode. Saran yang benar itu penting, tapi kode yang ditulis dalam format yang seragam dengan tim adalah standar profesional yang tak bisa ditawar.

Di episode 17 selanjutnya, kita akan membahas Code Formatting & Linting dengan conform.nvim dan nvim-lint — memformat kode secara otomatis saat menyimpan file dan memeriksa kualitas kode secara asinkron tanpa membekukan editor. Pastikan tetap semangat!