Neovim from scratch / part 6
Rebuilding my Neovim config from scratch (Part 6: Completion, and when the docs run ahead of the release)
Adding autocomplete to my from-scratch Neovim config with blink.cmp, the one place I broke from "no plugins," plus the version-pinning bug from docs that described an unreleased version.
(Part 6 of rebuilding my Neovim config from zero on 0.12, no plugin manager, understanding every line. Earlier parts: options, keymaps, vim.pack, treesitter, native LSP.)
Completion, the little popup menu that suggests as you type, was the first time in this whole project I deliberately reached for a plugin instead of doing it natively. And I want to explain why, because the purist in me put up a fight.
The fork I actually faced
Neovim 0.12 can do completion natively. The LSP servers from Part 5 already feed Neovim’s built-in completion engine (vim.lsp.completion), and built-in vim.snippet even expands snippets. A year ago native completion was too bare to live in. Now it’s usable. So I could have stayed 100% pure.
But completion is the one feature where the UX gap is wide. A good completion plugin gives you fuzzy matching (type fwfn and still match formatWithFallbackName), a ranked menu, ghost text, doc popups, multiple sources. Tuning all that by hand on top of the native engine is a lot of work to reinvent something that already exists and is fast. So here I made the trade consciously: one plugin, for the feature where a plugin clearly earns it. I went with blink.cmp.
(If you’d rather stay fully native, it’s doable: vim.lsp.completion.enable() with autotrigger = true, plus a couple of keymaps. I just decided it wasn’t worth reinventing here.)
Installing it with vim.pack
blink ships a Rust fuzzy matcher. Normally “Rust” means “install cargo and compile,” but blink publishes prebuilt binaries on each release, and downloads the right one automatically as long as you’re on a release tag. So the trick with vim.pack is to pin a version range:
vim.pack.add({
{ src = "https://github.com/rafamadriz/friendly-snippets" }, -- snippet library
{ src = "https://github.com/saghen/blink.cmp", version = vim.version.range("1") },
}, { confirm = false })
vim.version.range("1") resolves to the latest v1.x tag, which has a prebuilt binary attached. No cargo, no compiler.
The bug: the docs described a version that doesn’t exist yet
Here’s the part that ate twenty minutes. The official install docs (read fresh, current) told me to:
- add a dependency,
saghen/blink.lib, - pin to version 2.x,
- and run
require('blink.cmp').build():pwait()as a build step.
So I did. And vim.pack stopped cold:
No versions fit constraint. Relax it or switch to branch. Available:
v1.10.2, v1.10.1, ... v1.0.0, v0.14.2, ...
Branches: main, v1, v2
Read that carefully. The newest tag is v1.10.2. There is no v2.0.0 release: v2 is only a branch. The docs on the main branch were describing the unreleased 2.0, which is where blink.lib and the new build() step come from. I’d been reading documentation from the future.
The fix was to drop back to what’s actually shipped: the stable v1 line.
version = vim.version.range("1")→ resolves to v1.10.2 ✅- no
blink.lib(that’s a v2 thing), so I removed it - no
build()either. v1.10.2 doesn’t expose it, so the binary just auto-downloads on first use. I guarded the call so it works on both lines:
local blink = require("blink.cmp")
if type(blink.build) == "function" then -- v2 has it; v1.10 doesn't
blink.build():pwait()
end
Lesson filed away: when a plugin has a fast-moving main, its docs can describe a release that isn’t out yet. Always check the actual tags. (vim.pack helpfully prints them on a bad constraint, so that error message is a feature.)
The config
blink.setup({
keymap = { preset = "default" }, -- <C-y> accept, <C-n>/<C-p> select, <C-space> menu
completion = {
menu = { auto_show = true },
documentation = { auto_show = true, auto_show_delay_ms = 250 },
},
sources = { default = { "lsp", "path", "snippets", "buffer" } },
fuzzy = { implementation = "prefer_rust" }, -- Rust binary, Lua fallback if absent
signature = { enabled = true },
})
sources is the nice part: completion comes from my LSP servers, plus file paths, snippets, and words already in the buffer, all ranked together. And prefer_rust means if the prebuilt binary ever fails to download, it silently falls back to a pure-Lua matcher instead of breaking. A safety net, on by default.
Wiring it to native LSP
One integration detail. blink wants to tell each language server “hey, I support snippets and rich completion items,” which servers use to send better suggestions. With the old nvim-lspconfig you’d pass capabilities per server. With the native API it’s one line that merges into the shared config:
vim.lsp.config("*", { capabilities = require("blink.cmp").get_lsp_capabilities() })
I kept this in blink.lua rather than my LSP module, so the LSP layer stays plugin-free. Pull blink out someday and the LSP setup doesn’t even notice. I verified it worked by checking that gopls, once attached, reports snippetSupport = true. It does.
Worth it?
Yes. And the line I drew feels right. Five layers of “no plugins, understand everything,” then one deliberate, well-understood plugin exactly where native falls short. That’s not a compromise of the philosophy. It is the philosophy. Know what the native baseline is, know precisely what the plugin buys you, and choose on purpose.
Next, the finale: quality-of-life plugins. Fuzzy file finding, a file tree, git signs in the gutter, a statusline, the stuff that makes daily driving pleasant. After six layers of foundations, it’s time to make it comfortable.
Cheers!
Comments
Comments require a GitHub account. No GitHub? Reply on Substack or email me.