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 know node_modules exists, 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>sr opens it.
  • better-escapejk to leave insert mode without the timeout lag.
  • peekup — preview all the registers, because I can never remember what is in "a versus "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 nvim with 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-host and only sets node_host_prog if it finds it. Works on any machine without hardcoding a path.
  • Macro recording disabled. I mapped q and Q to nothing. I almost never use macros on purpose, but I used to start recording one by accident constantly. qq quits 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).