My Vim Configuration
A tour of my _vimrc and the small set of plugins I actually use.
Hello! A few years ago I migrated from Vim to Neovim with a full Lua config, and I was really excited about it. And then last year I quietly switched back to plain Vim, and I've been a lot happier. So I figured I'd write up what my setup looks like now.
# why I went back to Vim
The short version: I wanted one editor that behaves the same way everywhere, with one config file I can drop on a new machine and forget about. The Neovim plugin ecosystem moves really fast, which is great in some ways and exhausting in others — things break, plugins get rewritten, and I'd open my editor in the morning and find that something I relied on had been deprecated. (The thing that finally tipped me over was an LSP plugin reorganizing how it loaded servers and breaking my Python setup three Mondays in a row.)
Vim 9 already has the pieces I missed when I first switched: popups, a real terminal feature, vim9script for fast plugins, and coc.nvim does LSP and completion really well. The result is one _vimrc that works on Windows, macOS, and any random Linux box I SSH into. I never think about startup time.
There's also a Windows-specific reason. I mostly use Vim through GVim on Windows, and GVim has been rock-solid for as long as I can remember — proper font picker, real GUI tabs, ligature support, sensible resize behavior, a menu bar I can poke around in when I forget a command. When I double-click gvim.exe, it just opens. That's most of what I want from a GUI editor. Neovim's various GUI frontends on Windows (nvim-qt, Goneovim) never quite felt as polished to me — small things would be off in ways I couldn't always articulate. Neovide (opens new window) is the one I keep hearing good things about, and I'd genuinely like to try it on Windows at some point, but I haven't actually done that yet, so I can't tell you how it stacks up.
I want to be honest: a lot of people will read the above and think "skill issue, just pin your plugin versions". That's fair! For me though, the friction of constant adjustment was the actual cost — not the breakage itself.
# installing it
I use Scoop (opens new window) on Windows:
scoop install vim
On macOS I use Homebrew (brew install vim), and on Linux whatever the distro provides. The same _vimrc works on all of them.
# one big _vimrc
The whole configuration lives in one file. On Windows that's C:\Users\YOURUSERNAME\_vimrc, and on macOS / Linux it's ~/.vimrc. Plugins are managed by vim-plug (opens new window) and live in ~/vimfiles/plugged (Windows) or ~/.vim/plugged (everywhere else).
I organize the file with fold markers ({{{ and }}}) so it collapses into sections when I open it:
""" Plugins {{{ ... }}}
""" Options {{{ ... }}}
""" Autocommand {{{ ... }}}
""" UI {{{ ... }}}
""" Mappings {{{ ... }}}
""" User functions {{{ ... }}}
""" LSP - Coc {{{ ... }}}
""" Plugin Configs {{{ ... }}}
""" MISC {{{ ... }}}
There's a modeline at the bottom of the file that sets foldmethod=marker, so the folds just work as soon as the file opens.
# plugins
I manage plugins with vim-plug. The list is small on purpose — most of these I've been using for years. (Roughly a third of my plugins are by Tim Pope. If you're starting a Vim config from scratch, just install everything tpope has written and you'll be fine for a while.)
For editing ergonomics:
jiangmiao/auto-pairstpope/vim-surround,tpope/vim-commentary,tpope/vim-repeat,tpope/vim-rsi(readline bindings in command and insert mode)junegunn/vim-easy-alignrhysd/clever-f.vimmonkoose/vim9-stargate— fast 2-character jumps, I use this constantly
For navigation:
tpope/vim-vinegarlambdalisue/vim-fernandvim-fern-hijackairblade/vim-rooterjunegunn/fzfandjunegunn/fzf.vim(more on this below!)dstein64/vim-win
For git: tpope/vim-fugitive and tommcdo/vim-fubitive (so :Gbrowse works on Bitbucket).
For a popup terminal: voldikss/vim-floaterm.
For syntax, colors, and look: sheerun/vim-polyglot, lilydjwg/colorizer, nordtheme/vim, kamil-stachowski/flatwhite-vim, and my own little colorscheme tweaks (cellsummer/vim-colors and cellsummer/oscura-vim).
For writing: dhruvasagar/vim-table-mode and google/vim-searchindex.
For LSP and completion: neoclide/coc.nvim. I install it from the master branch with npm ci.
And one fun one: eggbean/resize-font.gvim, which gives me Ctrl-+ / Ctrl-- to resize the font in GVim.
# options I actually use every day
There are a lot of set lines in the file, but only a handful that I'd really miss if they went away:
set clipboard=unnamed— yank and put go through the system clipboard. I forget this isn't the default until I'm on a fresh machine.set ignorecase+set smartcase— search is case-insensitive unless I type a capital letter.set colorcolumn=88— a soft reminder for line length.set so=20— keep 20 lines of context above and below the cursor. (The default of 0 always feels claustrophobic to me.)set foldmethod=indentwithset foldlevel=99— folds are available, but everything starts open.set splitbelow/set splitright— splits open where I expect them to, instead of pushing my existing window around.set nobackup/set nowritebackup/set noswapfile— I rely on git, not on Vim's safety net.set termguicolors— true colors in the terminal.set grepprg=rg --vimgrep --no-heading --smart-casewhenrgis onPATH.
There's also a Windows-friendly block that sets fileformats=dos and adds .git\*, .hg\*, .svn\* to wildignore. On Unix it does the reverse.
# autocommands
A few that I'd call out:
" Return to the last edit position when re-opening a file
au BufReadPost * if line("'\"") > 1 && line("'\"") <= line("$")
\ | exe "normal! g'\"" | endif
" Disable expensive features on large files
augroup LargeFile
autocmd!
autocmd BufReadPre * if getfsize(expand('%')) > 500000
\ | setlocal syntax=off foldmethod=manual norelativenumber nocursorline
\ | endif
augroup END
" Open the quickfix window after :grep / :make automatically
augroup quickfix
autocmd!
autocmd QuickFixCmdPost [^l]* cwindow
autocmd QuickFixCmdPost l* lwindow
autocmd FileType qf wincmd J
augroup END
I also wire up :make per language so <space>r runs the current file with the right tool — uv run % for Python, pwsh % for PowerShell.
# the look
My colorscheme is predawn (dark), with flatwhite as a light counterpart. I bound <leader>ct to toggle between them, because I switch a few times a day depending on light:
function! ToggleColorScheme()
if exists("g:colors_name") && g:colors_name == "predawn"
colorscheme flatwhite
else
colorscheme predawn
endif
endfunction
I don't use vim-airline. I rolled my own statusline because I wanted something small, and I really like that the background color flips when I enter insert mode:
au InsertEnter * hi statusline guifg=black guibg=#d7afff
au InsertLeave * hi statusline guifg=black guibg=#8fbfdc
The statusline shows buffer number, file path, filetype, fileformat, cursor position, encoding, and the current mode (with friendly names — "Normal", "Visual", "Insert", etc., instead of n, v, i).
In GVim I use a Nerd Font (currently Hurmit_Nerd_Font_Mono) and start the window maximized. I have a small PickFont command that pops an fzf picker over my installed monospace fonts, so I can audition them without editing the rc file. (More on fzf below!)
# mappings
<leader> is <space> and <localleader> is ,. The ones I reach for most:
jkin insert mode escapes to normal mode. (This is the single best keymap I have.)<BS>clears the search highlight.<C-h/j/k/l>move between splits. The arrow keys resize them.<S-l>/<S-h>cycle buffers.<leader>etoggles a Fern file drawer at the project root;<leader>efopens it at the current file's directory.<C-\>toggles a floaterm popup terminal.<F2>starts a project-wide replace of the word under the cursor, with confirmation.<F4>inserts today's date. (I write a lot of dated notes.)sis remapped tovim9-stargatefor 2-character jump motions.<C-W>mtoggles maximizing the current split — it's a tiny function that remembers the previous layout so I can pop back to it.
I also redirect the c family into the black hole register so that changing text doesn't clobber what I just yanked:
nnoremap c "_c
nnoremap C "_C
vnoremap c "_c
vnoremap C "_C
[a downside:] this trips up anyone who borrows my Vim, and it also breaks the symmetry with d — sometimes I do actually want the changed text to go into the unnamed register, and then I have to remember to use "+c or yank first. I keep meaning to undo this mapping and I never do.
# why coc.nvim
I use coc.nvim for LSP and completion, and I want to be a little loud about this, because the conventional wisdom has drifted toward "just use the built-in Neovim LSP" and I don't actually agree. There's a really good post by fann.im — "Thoughts on coc.nvim" (opens new window) — that captures most of my reasons better than I could. The short version of why I still pick it:
- It's the most complete LSP client in the Vim world. The fann.im post puts it at 95%+ API parity with VS Code's language client. In practice that means inlay hints, semantic tokens, code lens, snippets, and code actions all just work — not "work if you also install plugin X and Y".
- Some language servers aren't pure LSP. TypeScript and Vue in particular need a wrapping layer to be usable, and that layer exists as
coc-tsserver,coc-volar, and friends. Rolling these yourself on top of bare LSP is a real project. - The extensions are the killer feature.
coc-pyrightfinds Python venvs automatically.coc-prettierjust works.coc-yankgives me a fuzzy-searchable yank history across the whole session.coc-marksmanunderstands Markdown links across my notes folder. Each of these would be a separate plugin (and a separate config decision) in a built-in LSP setup. - The completion UX is tuned. Things like
suggest.triggerCompletionWaitand source prioritization smooth over the "wait, did I just accept the wrong completion in the middle of a keystroke" problem that bites me in pretty much every other completion plugin I've tried. - It lets plugins live in Node, which is sometimes the right tool. The fann.im article makes this point well — dismissing Node as a bad stack for editor plugins is more aesthetic than technical. Some of the best things in coc are best precisely because they're JS modules with a real package ecosystem behind them.
My extension list:
let g:coc_global_extensions = [
\'coc-powershell',
\'coc-yaml',
\'coc-json',
\'coc-sql',
\'coc-prettier',
\'coc-pydocstring',
\'coc-markdown-preview-enhanced',
\'coc-yank',
\'coc-word',
\'coc-emoji',
\'coc-webview',
\'@yaegassy/coc-ruff',
\'@yaegassy/coc-marksman'
\]
The bindings are pretty standard for coc: gd / gy / gr for navigation, K for hover docs, [g / ]g for diagnostics, <leader>lr to rename, <leader>lf to format, <leader>a for code actions, <leader>qf to apply the most likely fix on the current line. <Tab> confirms the completion popup, <C-n> / <C-p> navigate it, and <CR> triggers coc's on-enter handler so format-on-save works where the language server supports it.
[a small downside:] coc is a Node app that lives in node_modules, which is a bit weird. The first install can be slow and occasionally fails on Windows if the Node version drifts. It's a real cost, but in three years it's never cost me more than 15 minutes to fix — the day-to-day experience is worth it.
# fzf is my picker
I went through a phase of trying heavier "everything pickers" (LeaderF, Telescope), but I kept coming back to fzf + fzf.vim. The two plugins together are tiny, fast, and they work the same way on every platform that has the fzf binary on PATH. That's basically what I want from a picker.
So, two plugins:
Plug 'junegunn/fzf'
Plug 'junegunn/fzf.vim'
I want the picker to feel like a panel and not take over the whole screen, so I float it in a centered popup. I also turn on jump-to-buffer behavior (otherwise picking an already-open file opens it again in the current window, which I never want):
let g:fzf_vim = {}
let g:fzf_layout = { 'window': { 'width': 0.7, 'height': 0.7 } }
let g:fzf_vim.buffers_jump = 1
The popup borrows its colors from the active colorscheme by mapping fzf's UI elements onto Vim highlight groups. This is a small thing but I love it — the picker recolors automatically when I toggle between predawn and flatwhite:
let g:fzf_colors =
\ { 'fg': ['fg', 'Normal'],
\ 'bg': ['bg', 'Normal'],
\ 'hl': ['fg', 'Comment'],
\ 'fg+': ['fg', 'Visual', 'CursorLine', 'Normal'],
\ 'bg+': ['bg', 'Visual', 'CursorLine', 'Normal'],
\ 'hl+': ['fg', 'Keyword'],
\ 'info': ['fg', 'PreProc'],
\ 'border': ['fg', 'Ignore'],
\ 'prompt': ['fg', 'Conditional'],
\ 'pointer': ['fg', 'Exception'],
\ 'marker': ['fg', 'Keyword'],
\ 'spinner': ['fg', 'Label'],
\ 'header': ['fg', 'Comment'] }
All the bindings live on <leader>f… so they share a mental shelf. <leader><leader> is the one I hit the most, so it should be the cheapest keystroke in the editor:
nnoremap <silent><leader><leader> :<c-u>Files<cr>
nnoremap <silent><leader>ff :<c-u>GFiles<cr>
nnoremap <silent><leader>fg :<c-u>GFiles?<cr>
nnoremap <silent><leader>fb :<c-u>Buffers<cr>
nnoremap <silent><leader>fj :<c-u>Jumps<cr>
nnoremap <silent><leader>fc :<c-u>Changes<cr>
nnoremap <silent><leader>f; :<c-u>History:<cr>
nnoremap <silent><leader>fo :<c-u>History<cr>
nnoremap <silent><leader>ft :<c-u>Colors<cr>
nnoremap <silent><leader>fq :<c-u>FzfQuickfix<cr>
nnoremap <silent><C-p> :<c-u>Buffers<cr>
nnoremap <silent><leader>pf :<c-u>PickFont<cr>
nnoremap <silent><leader>sn :<c-u>Files ~/Notes<cr>
<leader>sn is a deliberate shortcut into my notes folder, so I never have to :cd first.
I also overrode the built-in :History so a single command can pick between three historical views, depending on the argument: bare :History picks files from v:oldfiles, :History : opens fzf over command history, and :History / opens fzf over search history.
function! s:HistoryFiles(bang) abort
call fzf#run(fzf#wrap('history-files', {
\ 'source': v:oldfiles,
\ 'options': ['-m', '--prompt', 'Hist> '],
\ }, a:bang))
endfunction
function! s:HistoryDispatch(arg, bang) abort
if a:arg ==# ':'
call fzf#vim#command_history(a:bang)
elseif a:arg ==# '/'
call fzf#vim#search_history(a:bang)
else
call s:HistoryFiles(a:bang)
endif
endfunction
command! -bang -nargs=* History call <SID>HistoryDispatch(<q-args>, <bang>0)
FzfQuickfix is the other custom one I lean on. When :grep returns more hits than I want to step through with :cnext, this opens an fzf picker over the quickfix list, so I can fuzzy-match a path or message and jump straight to that entry:
function! s:FzfQfSource() abort
let l:qf = getqflist()
let l:lines = []
for l:i in range(len(l:qf))
let l:e = l:qf[l:i]
let l:name = l:e.bufnr > 0 ? bufname(l:e.bufnr) : ''
call add(l:lines, printf("%d\t%s:%d:%d: %s",
\ l:i + 1, l:name, l:e.lnum, l:e.col, l:e.text))
endfor
return l:lines
endfunction
function! s:FzfQfSink(line) abort
let l:idx = str2nr(matchstr(a:line, '^\d\+'))
if l:idx > 0
execute 'cc' l:idx
endif
endfunction
command! -bar FzfQuickfix call fzf#run(fzf#wrap({
\ 'source': s:FzfQfSource(),
\ 'sink': function('s:FzfQfSink'),
\ 'options': ['--prompt', 'QF> ', '-d', "\t", '--with-nth', '2..'],
\ }))
The little trick here is -d "\t" plus --with-nth 2..: the leading index column is in each line (so the sink can read it), but it's hidden from the UI. The sink pulls the number back out and jumps with :cc N, so selection accuracy doesn't depend on the visible text matching exactly.
PickFont, later in the post, uses the same pattern for a totally different thing.
# fzf's bindings are the killer feature
The reason I keep coming back to fzf isn't really that it's fast (though it is) — it's that fzf's --bind system lets me teach the picker custom actions. Each bind can run a shell command on the current selection, toggle a UI element, or terminate fzf with the selection in hand. That tiny mechanism is what turns fzf from "a fuzzy file picker" into something more like a tiny operating system for picking from arbitrary lists.
My terminal-side options live in ~/.fzfrc, which I load with FZF_DEFAULT_OPTS_FILE:
--height=100%
--info=inline
--layout=reverse
--border=none
--margin=5%
--padding=1
--scheme=path
--nth=1,2
--preview='bat --style=numbers --color=always {}'
--preview-window=bottom:60%:wrap:border-bold
--multi
--marker=✓
--pointer=▶
--header='| CTRL-y: COPY | CTRL-o: OPEN | CTRL-e: EDIT |'
--prompt='❯ '
--bind 'ctrl-y:execute-silent(echo {} | clip)+abort'
--bind 'ctrl-o:execute(start "" {})+abort'
--bind 'ctrl-e:execute(gvim {})+abort'
--bind=ctrl-b:toggle-preview
--bind=ctrl-space:toggle-all
The bindings are the part I want to call out:
Ctrl-ycopies the selected line to the Windows clipboard viaclip. I use this dozens of times a day, mostly to grab a path or a buffer name without leaving the picker.Ctrl-oopens the selected file in its default application viastart "". PDF? It opens in my PDF viewer. PNG? Image viewer. (On macOS this would beopen, on Linuxxdg-open.)Ctrl-eopens the selection in GVim. Useful when I'm in a terminal fzf and I want the file in a separate editor window instead of the current one.Ctrl-btoggles the preview pane, for when thebat-rendered preview is in the way.Ctrl-spacetoggles selection of every visible item, for multi-select operations.
The --header line at the top of the picker is a literal cheat-sheet so I don't forget the bindings exist. That's the whole "documentation" my fzf has, and it works.
These all flow through to fzf.vim too, because fzf.vim invokes the same fzf binary with the same default options. Which means when I'm inside Vim and hit <leader><leader> to fuzzy-find a file, Ctrl-y still copies the path to my clipboard, Ctrl-o still pops it open in its default app, and Ctrl-b still toggles the preview. The terminal config and the editor config are the same config, and that consistency is what makes the bindings actually stick in muscle memory.
If you've never written an fzf --bind, I'd encourage you to try one. The list of available actions (opens new window) is long and well-documented, and once you have one custom binding wired up you immediately start seeing places where another would help.
# some functions I wrote
A few small helpers I keep coming back to:
Bclose— delete the current buffer without closing the window. This is what I actually want 95% of the time, and it's surprising it isn't built in.CleanExtraSpaces— runs onBufWritePrefor the file types I write most often. Strips trailing whitespace without moving the cursor or losing the last search.SectionDivider— bound to<leader>d. Take the current line as a title and stretch it into a comment divider, padded with box-drawing characters out to column 79.PickFont— fzf picker over a cached list of installed monospace fonts, withF5inside the picker to refresh the cache. (This is honestly more useful than it sounds, I switch fonts a lot.)FzfQuickfix— the quickfix picker from the fzf section above.- A custom
:Historycommand that dispatches betweenv:oldfiles, command history, and search history depending on the argument — also covered above.
# a few other bits
Auto-clean trailing whitespace on save, for the file types I write most:
autocmd BufWritePre *.txt,*.js,*.py,*.sql,*.sh,*.md,*.vb :call CleanExtraSpaces()
Insert today's date with <F4>:
nnoremap <F4> "=strftime("%Y-%m-%d")<CR>P
inoremap <F4> <C-R>=strftime("%Y-%m-%d")<CR>
q closes any read-only buffer (help, quickfix, fugitive, coc preview), but stays as the macro recorder everywhere else:
nnoremap <expr> q (&readonly ? ':close!<CR>' : 'q')
Markdown is folded by header, with an initial fold level deep enough to actually see what's in the document:
let g:markdown_folding = 1
au BufEnter *.md setlocal foldlevel=3
# what's Vim vs what's me
If you're reading this thinking "I should set up my Vim exactly like this": please don't! A lot of what's above is just personal taste. So here's my honest split between "this is Vim being good at something Vim is genuinely good at" and "this is just how I happen to like it".
Things Vim is genuinely great at, regardless of how you configure it:
- The grammar. Verb + motion + text object —
d3w,ci",>ap,yi(. Once you internalize that operators, motions, and text objects compose, you stop thinking about keystrokes and start thinking about edits. I'm not aware of another editor where this much editing power comes from such a small vocabulary. .for repeating. Most editors have a "repeat last action" key, and it's almost never useful. In Vim,.is load-bearing — change a thing, jump to the next instance,., jump,.. Half of my refactors look like this.- Registers and macros.
qa…qthen@ais the kind of one-shot automation I reach for several times a day. Other editors push you to write a script for this; Vim makes the "script" ephemeral. - The ex command line.
:%s,:g/pattern/d,:norm, ranges — this is a whole language for batch operations on buffers, and it's been roughly the same language since the '70s. - Buffers, windows, and tabs as separate concepts. Most editors collapse all three into "tabs". Vim's split makes window layouts disposable and buffers persistent, which is the right way around for me — and it's the concept that takes longest to explain to people coming from VS Code.
- Quickfix. Underrated.
:make,:grep,:vimgrep, your LSP — they all populate the same list, and:cn/:cpwalks it. The fact that this has been the standard since forever is why every plugin can hook into it. :help. The docs are genuinely good.:help :substitutewill tell you everything you ever wanted to know about:s. Not many tools where this is true.- It's just there. SSH into any random box and Vim's almost certainly installed. Your config travels in a single file. Things you learned a decade ago still work.
Things that are just my flavor (where you could do the opposite and still have a great editor):
- The colorscheme, statusline, and font.
- The plugin manager (
vim-plugvsPackervslazy.nvimvs Vim 8's built-in:packadd). - Which fuzzy finder (fzf, LeaderF, Telescope, or just
:findwith a cleverpath). - Which file explorer (Fern, nerdtree, netrw, or none —
:e .is fine). - Which LSP plugin (coc.nvim, the built-in LSP via ALE / vim-lsp, or none if you mostly write Markdown).
- The escape mapping (
jk,kj,<C-c>, sticking with<Esc>). - The leader key (space,
,,\, anything). - Whether
cgoes to the black hole register. (Big personal opinion, not a Vim feature.) - Floating popup terminal vs
:terminalin a split. - Window-management plugins. Vim's built-in
<C-W>family is fine for most people.
The short version, if you're new-ish to Vim: learn the grammar, ., registers, macros, and the ex command line first. Everything else in this post is decoration.
# that's it!
That's the whole setup — one file, vendor-neutral, the same everywhere. Big thanks to Tim Pope for writing roughly half of my plugin list, to junegunn for vim-plug and fzf.vim, and to whoever it was on Hacker News years ago who told me to map jk to escape. If you've found a nicer way to do any of this, I'd love to hear about it.