My Neovim Setup
I already published a Neovim Cheatsheet that lists every keymap I use, but a list of keys does not explain why the config looks the way it does. This post is the other half: the architecture, the plugin choices, and the two or three problems that cost me real time to solve. If the cheatsheet is the what, this is the why.
My whole config is Lua, lives in ~/.config/nvim, and is small enough that I
can hold the entire thing in my head. That is on purpose. I have been burned
by 3,000-line “distros” that break on update and leave you debugging someone
else’s abstractions at 11pm. I would rather own every line.
I write it in Lua rather than Vimscript, and not out of fashion. Vimscript is a
language that exists only inside Vim — I write it once a year and have to
relearn the quoting rules every time. Lua is a real, general-purpose language
with proper tables, functions, modules, and require, so I can structure the
config like actual code: split it across files, share helpers, loop over a list
to disable built-in plugins instead of pasting twenty near-identical lines. It
is also the language the modern plugin ecosystem is written in, so the config
and the plugins I am configuring finally speak the same dialect — no more
context-switching between two languages in the same file. And it is fast:
Neovim compiles Lua to bytecode, which keeps startup snappy even as the config
grows.
How it is organized
init.lua does almost nothing. It just pulls in three files, in order:
require('plugins');
require('options');
require('config');
plugins.lua— the plugin list and per-plugin setup, all driven by lazy.nvim.options.lua— pure editor settings, no plugins. Indentation, search, splits.config.lua— everything that depends on plugins being loaded: keymaps, LSP, Telescope, the small autocommands.
That split means when something breaks I usually know which file to open before
I have even read the error. Options never depend on plugins, so options.lua is
the one file that is basically immortal.
Plugins with lazy.nvim
I use lazy.nvim and bootstrap it from
inside the config, so a fresh machine only needs Neovim installed — clone the
dotfiles, open nvim, and it clones lazy and installs everything on first run:
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not (vim.uv or vim.loop).fs_stat(lazypath) then
vim.fn.system({
'git', 'clone', '--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable', lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
The lazy-lock.json file is committed, so every machine resolves to the exact
same plugin commits. No “works on my laptop” surprises.
The look
gruvbox on contrast = 'hard',
and that is the whole theme decision. I have tried the fashionable ones and I
always come back. It is warm, it is easy on the eyes for long sessions, and
the hard contrast keeps the background properly dark.
lualine handles the status line,
themed to match gruvbox so nothing clashes. termguicolors is on, which you
need for any modern colorscheme to look right.
Moving around
This is where most of my day actually happens, so it gets the most attention.
Telescope is the fuzzy
finder, and I have it wired to ripgrep under the hood with --hidden so it
searches dotfiles too. Every picker opens in the ivy theme pinned to half the
window height — I find the bottom-anchored layout much calmer than a floating
box that covers my code:
map('n', '<Leader>ff', '<cmd>Telescope find_files theme=get_ivy layout_config={height=0.5}<CR>')
map('n', '<Leader>fg', '<cmd>Telescope live_grep theme=get_ivy layout_config={height=0.5}<CR>')
map('n', '<Leader>fv', '<cmd>Telescope git_files theme=get_ivy layout_config={height=0.5}<CR>')
<leader>ff for files, <leader>fg to grep the project, <leader>fv for
git-tracked files only. After years of this, my hands reach for them without me
thinking.
A few companions round it out:
- nvim-tree as the file explorer,
toggled with
<leader>e. I configured it to show git-ignored files but dim them — I want to knownode_modulesexists, I just do not want it shouting. - hlslens shows a little “3 of 12” counter next to search matches so I know where I am in the results.
- nvim-bqf turns the quickfix list into something with a live preview pane, which makes LSP references and grep results genuinely browsable instead of a wall of file paths.
One thing that probably looks weird to other vimmers: I remapped window
navigation to Shift + hjkl instead of the usual <C-w> dance.
map('n', '<S-h>', '<C-w>h')
map('n', '<S-j>', '<C-w>j')
map('n', '<S-k>', '<C-w>k')
map('n', '<S-l>', '<C-w>l')
Jumping between splits is something I do hundreds of times a day, and <C-w>h
is three keys for a thing that should be one motion. Shift+h just feels right.
LSP without the boilerplate
This is the part of the config I have rewritten the most, because Neovim’s LSP story keeps getting better. On 0.11 it is finally clean.
Mason installs the servers and
mason-lspconfig bridges them to Neovim. I keep a list of the servers I
actually use and let it install whatever is missing:
ensure_installed = {
'lua_ls', 'ts_ls', 'terraformls',
'bashls', 'jsonls', 'yamlls',
'ruby_lsp', 'rust_analyzer', 'kotlin_language_server',
},
The keymaps are not set globally — they attach per-buffer, only when a language
server actually connects, via an LspAttach autocommand. That way gd, gr,
K and friends only exist where they mean something:
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufopts = { noremap = true, silent = true, buffer = args.buf }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, bufopts)
vim.keymap.set('n', 'gr', vim.lsp.buf.references, bufopts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, bufopts)
-- ...rename, code action, diagnostics
end,
})
Completion capabilities are advertised once, for every server, with the new
vim.lsp.config('*', { ... }) API. No more copy-pasting a capabilities table
into every server block. And because Xcode ships its own language server that
Mason does not manage, I enable sourcekit only if the binary is on the path:
if vim.fn.executable('sourcekit-lsp') == 1 then
vim.lsp.enable('sourcekit')
end
The two LSP gotchas that cost me an afternoon
Config posts always show the happy path. Here are the two things that did not just work, because someone searching for these will thank me.
terraform-ls freezing the whole UI. Open a decent-sized .tf file and the
editor would lock up for seconds at a time. The culprit: terraform-ls sends
semantic tokens for the entire file, and Neovim processes every token through
str_utfindex, which on a large file blocks the UI thread. The fix is to drop
the semantic-tokens capability for that one server when it attaches:
vim.api.nvim_create_autocmd('LspAttach', {
pattern = { '*.tf', '*.tfvars', '*.hcl' },
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client and client.name == 'terraformls' then
client.server_capabilities.semanticTokensProvider = nil
end
end,
})
terraform-ls also spews a ton of stderr during module discovery, so I just
turn the LSP log off entirely with vim.lsp.log.set_level('OFF').
lua_ls warning about the vim global. When you edit your own Neovim config,
the Lua language server does not know vim is a thing and underlines it
everywhere. You tell it that global is fine:
vim.lsp.config('lua_ls', {
settings = {
Lua = {
diagnostics = { globals = { 'vim' } },
workspace = { checkThirdParty = false },
},
},
})
Neither of these is exotic, but both are the kind of thing that quietly ruins your editor until you find the one line that fixes it.
Completion
nvim-cmp with four sources, in priority
order: LSP, snippets (LuaSnip), the current buffer, and file paths. <Tab>
cycles the menu and also jumps through snippet placeholders, <CR> confirms,
and <C-Space> forces the menu open when I want it. nvim-autopairs
is hooked into cmp’s confirm event, so accepting a function completion also
closes its parentheses. Small thing, constant payoff.
Editing niceties
A cluster of small, sharp tools I would miss immediately if they were gone:
- nvim-surround — add, change, or
delete the thing around text. Wrap a word in quotes, swap
()for[]. - kommentary — comment toggling that understands the file type.
- nvim-spectre — project-wide
search and replace with a preview of every change before you commit to it.
<leader>sropens it. - better-escape —
jkto leave insert mode without the timeout lag. - peekup — preview all the
registers, because I can never remember what is in
"aversus"b.
I also have two autocommands that just quietly keep files tidy: one highlights text for a moment when I yank it (so I can see exactly what I grabbed), and one strips trailing whitespace as I leave insert mode:
cmd [[au TextYankPost * silent! lua vim.highlight.on_yank {timeout=200}]]
cmd [[autocmd InsertLeavePre * :%s/\s\+$//e]]
Git without leaving the editor
gitsigns puts add/change/delete
markers in the sign column. git-blame
is installed but off by default — I toggle it with <leader>gb only when I am
playing detective, because inline blame on every line all the time is noise.
The piece I actually love is FTerm, which I use to pop dedicated floating terminals for two tools:
local lazygit = fterm:new({ cmd = 'lazygit' })
local tig = fterm:new({ cmd = 'tig %' })
vim.keymap.set('n', '<Leader>lg', function() lazygit:toggle() end)
vim.keymap.set('n', '<Leader>tg', function() tig:toggle() end)
<leader>lg throws up lazygit in a
float for staging, committing, and rebasing; <leader>tg opens tig on the
current file’s history. Toggle again and they vanish. I commit far more
carefully now that lazygit is one keystroke away.
Copilot, on my terms
I use Copilot, but I refuse to let it
hijack <Tab> — that key has a job already. So I disable its tab mapping and
drive the suggestions explicitly:
g.copilot_no_tab_map = true
vim.keymap.set('i', '<C-J>', 'copilot#Accept("\\<CR>")', { expr = true, replace_keycodes = false })
vim.keymap.set('i', '<C-K>', 'copilot#Cancel()', { expr = true })
vim.keymap.set('i', '<C-N>', 'copilot#Next()', { expr = true })
vim.keymap.set('i', '<C-P>', 'copilot#Previous()', { expr = true })
<C-J> to accept, <C-K> to dismiss, <C-N>/<C-P> to cycle alternatives.
The point is that a suggestion never appears as my keystroke — I always opt
in. Ghost text is a suggestion, not a decision.
The small stuff that adds up
- Startify as a launcher. My start
screen is a list of bookmarks to the configs I edit most — the Neovim files,
fish, git, tmux — each one key away. Opening
nvimwith no arguments becomes a little control panel. - Auto-detecting the Node host. I run Node through Volta, so the config asks
volta which neovim-node-hostand only setsnode_host_progif it finds it. Works on any machine without hardcoding a path. - Macro recording disabled. I mapped
qandQto nothing. I almost never use macros on purpose, but I used to start recording one by accident constantly.qqquits the window instead, which is what my fingers wanted anyway. - Half-page scrolling on Shift+u / Shift+d, because
<C-u>/<C-d>are one more reach than they need to be. - Sensible defaults throughout: two-space indent, no swapfile, system clipboard
via
unnamedplus, splits open below and to the right, case-insensitive search that gets case-sensitive the moment you type a capital.
That is it
None of this is a framework. It is a few hundred lines of Lua I understand completely, and that is exactly why I trust it. When Neovim ships a new LSP API, I get to adopt it on my terms instead of waiting for a distro maintainer.
For the full list of every keymap, see the companion
Neovim Cheatsheet. And if you
want to copy any of this, it all lives in my
dotfiles repo under nvim/.
This post was written with the help of AI (Claude by Anthropic).