the keyboard that broke my workflow
I have a thing for mechanical keyboards. Not the expensive ones, I’m a broke CS student, but I was casually browsing Amazon looking for something cheap with a satisfying click. Found this Amazon Basics mechanical keyboard for like 900 rupees. Ordered it immediately.
It arrived, felt great. Good switches, decent build. But there was one problem I hadn’t checked for: the arrow keys were not distinct. They shared keys with other characters, and specifically – the right arrow key was on the same key as forward slash /.
Now if you’re a developer, you use / literally dozens of times every single line. Path separators, regex, URL strings, comments, division operators. And I also needed arrow keys for navigation. There was no good way to use both comfortably.
I had two options: return it or adapt. Returning it felt like giving up. Adapting meant learning Neovim, something I had been putting off for months because it looked intimidating. The keyboard basically forced my hand.
I started with the goal of just being functional. Survive in the editor, navigate files, edit code. But here’s the thing about me – I cannot do anything halfway. I started watching YouTube videos about Neovim configs. Then I found GitHub repos with insane setups. Then I started reading plugin documentation. Then I got obsessed with making keymaps feel natural. Then I spent a weekend going through Treesitter’s textobjects plugin docs.
Three months later I had built something I’m genuinely proud of. A config that’s not copied from one source but assembled from maybe fifteen different places, tons of trial and error, some AI help for the Lua parts I didn’t understand, pattern recognition from other people’s setups, and actual use. Every plugin in there has a reason. Every keymap was thought about.
This blog is that config, explained. And everything I learned using it over three months – from the absolute basics of modes and motions to LSP, Telescope, git integration, custom snippets. You’re getting the full picture, the way I wish someone had written it for me when I started.
setting up Neovim
Before anything else you need Neovim itself. The important thing here: the config uses features from Neovim 0.11+. Older versions won’t work correctly. Check what you have first:
If the output shows NVIM v0.11.0 or higher you’re fine. If it shows something older, or if the command doesn’t exist, install it now.
install Neovim 0.11+
# verify $ nvim –version
# verify $ nvim –version
sudo apt install fuse libfuse2 first for the AppImage to run. if you get a "fuse: device not found" error, that's why.# if the version is older than 0.11, use the AppImage method: $ curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.appimage $ chmod u+x nvim-linux-x86_64.appimage $ sudo mv nvim-linux-x86_64.appimage /usr/local/bin/nvim
# verify $ nvim –version
You also need git (almost certainly already installed), node (for some LSP servers and live-server), and a Nerd Font set as your terminal font so the icons render correctly. If you see small squares where there should be icons, that’s the font. Grab one from nerdfonts.com – I use JetBrainsMono Nerd Font.
installing the config
After three months of tweaking, this is the config I’m sharing. It has a good LSP setup for Python, JavaScript, TypeScript, C/C++, Lua, Rust, and more. Telescope for fuzzy finding, Neotree for file explorer, gitsigns for inline git diffs, Harpoon for quick file switching, nvim-surround, autoformatting with prettier/stylua/ruff, treesitter-based highlighting and text objects, a working snippet engine. It’s a solid base that won’t embarrass you.
↓ download nvim config (nvim.zip)Once you have it:
# extract the zip and move it into place $ unzip nvim.zip $ mv nvim_new ~/.config/nvim
# open neovim – lazy.nvim (the plugin manager) will bootstrap itself # and start downloading all plugins automatically $ nvim
The first time you open Neovim with this config it will take a minute or two. You’ll see lazy.nvim installing everything. Let it finish. If any errors appear, press q to dismiss and then quit with :q and reopen. Mason (the LSP package manager) also runs in the background installing language servers – watch the fidget spinner in the top-right corner to know when things are loading.
After the initial setup, opening Neovim will be instant.
verify the install
# see all installed plugins and their status :Lazy
# see all installed LSP servers and tools :Mason
If :checkhealth shows mostly green with a few yellow warnings, you’re fine. Red errors for things like node or python3 just mean those runtimes aren’t on your system yet and the relevant LSP servers won’t work until you install them. Everything else should be fine.
what makes Neovim different: modes
You’ve got Neovim installed. Now the actual learning starts, and the very first concept is the one that trips everyone up.
Every editor you’ve used before works the same way: you open a file, your cursor is somewhere, and whatever you type inserts text at that position. The keyboard always types text. That’s the only mode.
Neovim has modes. The keyboard does completely different things depending on which mode you’re in.
Normal mode is the default and it’s where you should be most of the time. In normal mode, the keyboard is entirely for commands – j moves down, d deletes, w jumps forward a word, gg goes to the top of the file. Nothing you type inserts text into the file.
Insert mode is what you’re used to. Keys type text. You enter it from normal mode and leave it back to normal mode when you’re done typing.
Visual mode is for selections. v selects character by character, V selects whole lines, Ctrl+v does block/column selection.
Command mode is for running ex commands. You enter it with :. This is where :w (save), :q (quit), :s/old/new/g (find and replace) live.
The current mode is always shown in the lualine statusbar at the bottom of the screen. You always know where you are.
Why does this design exist? Think about what you actually do when you’re coding. You write new text for maybe 30% of the time. The other 70% you’re navigating, selecting things, deleting, rearranging, searching. A regular editor gives you the mouse and some Ctrl+key combinations for all of that. Vim gives the entire keyboard, all the home row keys, no modifier needed, just for those operations. Once that keyboard real estate is yours, you use it constantly and efficiently.
getting out of insert mode
In this config, jk and kj are both mapped to Escape in insert mode. Use this instead of reaching for the physical Escape key, which is too far up on the keyboard. jk is a quick two-finger roll on the home row and after a week it becomes completely unconscious. You’ll accidentally do it in browser text boxes.
The single most important habit to build first: stop living in insert mode. In VSCode the mental model is “cursor is always ready to type text.” In Neovim the mental model is: enter insert mode, type a sentence or a block of code, exit with jk, navigate, enter insert mode again, type, exit. Normal mode is home. Insert mode is a visit. The rhythm is burst-of-typing, exit, move, burst-of-typing, exit.
jk first, navigate in normal mode, then enter insert mode again at the right position. this feels unnatural for a while, but it builds the right muscle memory.
moving around without arrow keys
The core navigation keys in normal mode are h, j, k, l for left, down, up, right. They’re on the home row of your right hand. Your hand doesn’t need to move. This sounds minor but over a full day of coding it genuinely adds up.
That said, h/j/k/l are only for small adjustments. Moving through a file one line at a time would be slow. These are the motions that actually cover distance:
| Key | What it does |
|---|---|
| w | Jump forward to the start of the next word |
| W | Same, but treats anything-not-whitespace as one word (skips punctuation boundaries) |
| b | Jump backward to the start of the current or previous word |
| B | Backward WORD version |
| e | Forward to the end of the current or next word |
| E | End of WORD |
| 0 | Start of line (column 0, absolute) |
| ^ | First non-blank character of the line |
| $ | End of line |
| gg | First line of the file |
| G | Last line of the file |
| 50G or :50 | Jump to line 50 |
| { } | Jump up or down to the next empty line -- paragraph boundary |
| H M L | Move cursor to top, middle, or bottom of the visible screen |
| Ctrl+d | Scroll down half page, cursor re-centers on screen |
| Ctrl+u | Scroll up half page, cursor re-centers |
The small/WORD distinction matters in practice. In foo.bar, w stops at the dot. W skips the whole thing as one unit. In a URL like https://example.com/path, w stops at every slash and colon. W skips the whole URL. Use W, B, E when you want to jump at the level of tokens rather than individual word-characters.
{ and } are underused by beginners. In real code, blank lines separate functions, classes, logical blocks. You can navigate through an entire file visiting every function boundary with { and }, no searching needed. I use these more than Ctrl+d/u for most files.
This config has relativenumber = true set, which shows the distance to every line from your cursor position rather than the absolute line number. So instead of counting in your head, you look at the number next to the line you want: it says 7, you press 7j to jump there, 7k to come back. Count-prefixed jumps become very natural once relative numbers are on.
finding characters on the current line
| Key | What it does |
|---|---|
| f{char} | Find next occurrence of char on this line, land on it |
| F{char} | Find previous occurrence, land on it |
| t{char} | Jump to just before the next char (think: "until") |
| T{char} | Jump to just after the previous char |
| ; | Repeat last f/F/t/T in the same direction |
| , | Repeat in the opposite direction |
These become very useful combined with operators. f( on a function call jumps straight to the opening paren. t, in an argument list lands just before a comma. dt, means delete from cursor up to but not including the comma. cf( means change from cursor to the opening paren. Once f and t are reflex, editing individual lines gets noticeably faster.
entering insert mode in the right position
There are six ways to enter insert mode and each puts the cursor in a different place. Using only i and then navigating with arrow keys is the slowest approach.
| Key | Where it puts you |
|---|---|
| i | Insert before cursor |
| a | Append after cursor |
| I | Insert at first non-blank character of the line |
| A | Append at end of line |
| o | Open new line below, enter insert mode there |
| O | Open new line above, enter insert mode there |
| s | Delete character under cursor, enter insert mode |
| S or cc | Delete entire line content, enter insert mode at start |
Adding something at the end of a line? A, not $ then a. New line below and start typing? o, not j then O. Clear the whole line and retype it? S, not 0d$i. Each one saves a few keystrokes and they happen constantly. After a week these become instinctive.
the jump list, your undo for navigation
Every time you make a big jump – G, /search, gd to go to a definition, * on a word – Vim records your position. Ctrl+o walks backward through that list. Ctrl+i goes forward.
This becomes extremely useful once you’re using LSP. You press gd to jump to a function definition somewhere else in the file (or another file entirely), read it, then Ctrl+o and you’re right back where you were. I use this pair probably fifty times a day. It’s essentially a browser back/forward button for code navigation.
the grammar: operators, motions, text objects
This is the section where Neovim stops feeling like a weird editor and starts making sense as a system. I remember the exact moment it clicked for me – I was trying to delete the contents of a string literal and I thought “delete, inside, quotes” and typed di" and it just worked. That’s the grammar.
You know d (delete), 3 (three times), w (word). You didn’t memorize d3w as a shortcut. You constructed it from vocabulary you already have. This is the fundamental difference between Vim and every other editor’s keyboard shortcuts. VSCode: memorize isolated facts. Vim: learn a grammar, generate thousands of combinations from a small set of primitives.
operators, the verbs
| Operator | Action |
|---|---|
| d | Delete (cuts to register, can be pasted) |
| c | Change -- delete then immediately enter insert mode |
| y | Yank (copy to register) |
| > | Indent right |
| < | Indent left |
| = | Auto-indent |
| gc | Comment or uncomment (native Neovim 0.10+, no plugin) |
| gU | Make uppercase |
| gu | Make lowercase |
Doubling any operator applies it to the whole line: dd deletes the line, yy yanks it, cc clears it and enters insert mode, >> indents it, gcc comments it.
text objects, the nouns that actually matter
A motion describes a direction – “three words forward.” A text object describes a shape – “the thing inside these quotes,” “the whole function body,” “this paragraph.” They always need a prefix:
imeans inner – the contents, without the surrounding delimitersameans around – the contents plus the delimiters themselves
| Text Object | What it selects |
|---|---|
| iw / aw | inner word / a word including trailing whitespace |
| iW / aW | inner WORD / a WORD (whitespace-bounded) |
| i" / a" | inside double quotes / including the quote characters |
| i' / a' | inside single quotes / including them |
| i` / a` | inside backticks / including them (great for template literals) |
| i( / a( or ib / ab | inside parentheses / including the parens |
| i{ / a{ or iB / aB | inside curly braces / including them |
| i[ / a[ | inside square brackets / including them |
| it / at | inside HTML/XML tag / including the tags |
| is / as | inner sentence / around sentence |
| ip / ap | inner paragraph / around paragraph |
| if / af | inner function body / around function (treesitter-aware) |
| ia / aa | inner argument / around argument (treesitter-aware) |
| ic / ac | inner class body / around class (treesitter-aware) |
The last three – if, ia, ic – come from the nvim-treesitter-textobjects plugin included in this config. They’re AST-aware, meaning they understand actual code structure rather than just matching brackets. dif on a Python function deletes the body correctly regardless of indentation complexity. dia on an argument in a function call removes exactly that argument and adjusts the commas. These sound like minor things but they’re genuinely impressive when you first feel them work.
the combos – read them as sentences
I learned these by saying the sentence in my head while typing the keys. It sounds silly but it works.
| Keys | Read as / what happens |
|---|---|
| ciw | "change inner word" -- deletes word under cursor, drops into insert mode ready to type replacement |
| ci" | "change inside quotes" -- clears string contents, cursor is inside empty quotes in insert mode |
| ca( | "change around parens" -- removes everything including the parens, insert mode |
| di{ | "delete inside braces" -- clears a block body, useful for emptying a function |
| yi( | "yank inside parens" -- copies the arguments of a function call |
| yif | "yank inner function" -- copies the entire function body |
| dif | "delete inner function" -- removes the function body, keeps the signature |
| cit | "change inside tag" -- clears HTML tag content, enter insert mode inside |
| vip | "visual inner paragraph" -- visually selects the current code block |
| gcip | "comment inner paragraph" -- comments the whole current block |
| gUiw | "uppercase inner word" -- makes word under cursor ALL CAPS |
| =ip | "auto-indent inner paragraph" -- re-indents the current block |
| dia | "delete inner argument" -- removes a function argument cleanly |
| 3dd | delete three lines |
| d$ | delete from cursor to end of line (same as D) |
| >ib | indent everything inside the current parens |
Once you have ten or fifteen of these internalized, you stop needing to look things up. You think “I want to change what’s inside these brackets” and your hands just type ci[. You didn’t memorize ci[ specifically – you constructed it.
the dot key, repeat anything
. repeats your last change. All of it – the operator, the text object, and whatever you typed in insert mode. If you did ciw and typed newName, pressing . on another word deletes it and types newName. If you pressed A;jk to add a semicolon at the end of a line, j. does the same on the next line.
The practical principle: design edits to be repeatable. Instead of selecting twenty lines and changing everything at once, make the change on one, move to the next with n or j, press .. Instead of manually finding every instance of something, use the search-and-dot pattern described in the config keymaps section. The dot key turns any edit into a batch operation.
count prefixes
Any operator or motion can be preceded by a number. 3w jumps three words forward. d3w deletes three words. 5j moves five lines down. 3dd deletes three lines. 2yy yanks two lines. With relative numbers on (which this config sets), you see the distance to every line on screen. You see 7 next to the function you want, you type 7j and you’re there.
visual mode
v enters character visual mode, V selects whole lines, Ctrl+v enters block/column visual mode. You expand the selection with any motion, then apply an operator. viw selects inner word. vi{ selects inside braces. vip selects the paragraph.
In this config, < and > (indent/dedent) stay in visual mode after applying, so you can keep pressing > to keep indenting without re-selecting. And p in visual mode pastes without overwriting your yank register – normally pasting over a selection kills what you had copied, this config routes the deleted selection to a blackhole so your clipboard stays intact.
Block visual (Ctrl+v) does something other editors can’t do natively. Select a column of text across multiple lines, press I, type something, press jk, and that text gets prepended to every selected line simultaneously. Select a column and d to delete that column across every line. This is the “multiple cursors” operation without needing a plugin.
registers, multiple clipboards
When you yy or dd, where does it go? Into the default unnamed register ". When you p, it pastes from there. But Vim has multiple registers:
| Register | What it holds |
|---|---|
| " | Default -- last delete or yank (whichever was more recent) |
| 0 | Yank-only register -- only yanks, never deletes. Always reliable. |
| _ | Blackhole -- things sent here disappear, don't overwrite anything |
| + | System clipboard -- your OS copy/paste |
| a through z | Named registers you control manually |
| / | Last search pattern |
The most useful thing to know: "0p pastes from the yank register specifically. This matters because dd (delete) overwrites the default register ". Say you yy something you want, then dd a line you don’t need – now p gives you the deleted line, not what you yanked. "0p always gives you what you last yanked, no matter how many deletes happened since. This saves real frustration.
In this config, x is mapped to "_x – deletes the character to the blackhole register. So deleting single characters never pollutes your clipboard. Small thing, genuinely nice quality of life. Also, Space+y and Space+Y explicitly yank to the system clipboard ("+y). Use these when you need to paste something outside of Neovim.
To use named registers: "ayiw yanks the current word into register a. "ap pastes it later. You can hold multiple completely independent things in memory this way – very useful for complex refactors.
searching
| Key | What it does |
|---|---|
| /pattern | Search forward for pattern, Enter to confirm |
| ?pattern | Search backward for pattern |
| n | Jump to next match |
| N | Jump to previous match |
| * | Search for exact word under cursor (forward) |
| # | Search for exact word under cursor (backward) |
| Esc | Clear search highlights (mapped to :noh in this config) |
In this config, n and N are both mapped to auto-center the screen after jumping – nzzzv and Nzzzv. This means every match you jump to appears in the middle of the screen. You never lose context cycling through results.
The config has ignorecase = true and smartcase = true together. This combination means: lowercase searches are case-insensitive (so /foo matches Foo, FOO, foo). But if you include any uppercase character in the search, it becomes case-sensitive (so /Foo only matches Foo). This is almost always the right behavior and you stop thinking about it quickly.
The * key deserves emphasis. Put the cursor on any identifier, press *, and every occurrence of that exact word in the file gets highlighted. Then n/N to cycle through them. This is your quick-find for the symbol under cursor, and it works instantly without typing a search pattern.
marks, navigation bookmarks
Marks save your position so you can come back to it.
| Key | What it does |
|---|---|
| ma | Set mark 'a' at current position (line + column) |
| `a | Jump to exact position of mark 'a' |
| 'a | Jump to line of mark 'a' (first non-blank char) |
| `` | Jump back to position before last big jump |
| '0 | Jump to where you were when you last exited Neovim |
Lowercase marks a-z are local to the file. Uppercase A-Z are global and persist across files – set mark M in one file, open another, 'M to go back to the exact position in the first file.
The pattern I use most: I’m deep in some implementation and need to check something in another part of the file. ma where I am, jump there with gg/pattern or gd, read what I need, 'a to come back instantly. No scrolling, no searching, just back to exactly where I was.
macros, automating repetitive edits
Macros record any sequence of normal mode actions and replay them. They’re stored in registers, same as text.
qa starts recording into register a. Do your operation. q stops. @a replays. @@ replays the last macro again. 50@a replays it fifty times.
The discipline that makes macros actually reliable: use text objects and motions, not character counts. If your macro does 3l (move three characters right), it will break on lines with slightly different structure. If it does f( (jump to next open paren), it works everywhere there’s a paren. Macros should describe structure, not raw physical keystroke positions.
Good workflow: position at the consistent starting point of the first item, record, end at the consistent starting point for the next item (usually j to next line or } to next block), test with @a on one more item, then 98@@ for the rest.
Macros live in registers, so "ap in insert mode literally pastes the macro as text. This means you can edit a macro after recording: paste it, fix the mistake, yank it back into register a with "ayy. Much cleaner than re-recording from scratch when you made one small error halfway through.
the config keymaps, the complete reference
Your leader key is Space. Everything Space+... below means: press Space, then the rest. The config sets timeoutlen = 300, meaning you have 300ms between keys in a sequence. It feels fast but comfortable.
basics
| Key | What it does |
|---|---|
| Ctrl+s | Save file |
| Ctrl+q | Quit |
| Space+sn | Save without triggering autoformat (when prettier is mangling something specific) |
| Esc | Clear search highlights |
| Space+lw | Toggle line wrap |
| Space+ss | Save session to .session.vim in current directory |
| Space+sl | Load session from .session.vim |
editing helpers
| Key | What it does |
|---|---|
| Alt+j / Alt+k | Move current line down / up (works in normal and visual) |
| Alt+d | Duplicate current line below |
| Space+j | Interactive word replace -- type new name, then . to replace each next occurrence, n to skip |
| Space+y / Space+Y | Yank selection or line to system clipboard |
| Space++ / Space+- | Increment / decrement number under cursor |
| x | Delete character to blackhole (won't kill your yank register) |
Space+j maps to *``cgn. Here is exactly what that does: * searches for the word under cursor. `` (two backticks) jumps back to where you were before the * moved you. cgn changes the next search match. You’re now in insert mode – type the replacement, press jk. From here, pressing . replaces the next occurrence. n skips one. This is surgical find-and-replace where you control every individual instance.
buffers and windows
| Key | What it does |
|---|---|
| Tab / Shift+Tab | Next / previous buffer |
| Space+x | Close current buffer without closing the window split |
| Space+b | New empty buffer |
| Space+v | Split window vertically (new pane to the right) |
| Space+hs | Split window horizontally (new pane below) |
| Space+se | Make all splits equal size |
| Space+xs | Close current split |
| Ctrl+h/j/k/l | Move focus between splits (also crosses tmux pane boundaries) |
| Arrow keys | Resize the current split |
A buffer is a file loaded in memory. A window is a viewport (split) that shows a buffer. A tab is a whole layout of windows. Most of the time you’ll use buffers (Tab/Shift+Tab to cycle). Splits are for keeping a reference visible while you edit in another. Tabs are rare in practice.
The vim-tmux-navigator plugin in this config means Ctrl+h/j/k/l works seamlessly across both Neovim splits and tmux panes with the same keys. If you use tmux (my other blog covers this), you’ll find the navigation becomes completely unified.
tabs
| Key | What it does |
|---|---|
| Space+to | Open new tab |
| Space+tx | Close current tab |
| Space+tn / Space+tp | Next / previous tab |
telescope, the fuzzy finder for everything
Telescope is probably what you’ll use more than any other plugin. It’s a fuzzy finder covering files, text search across projects, buffers, git history, LSP symbols, diagnostics, help tags – everything in one. Think VSCode’s command palette but significantly more capable.
| Key | What it does |
|---|---|
| Space+sf | Find files by name in the project |
| Space+sg | Live grep -- search file contents across the whole project |
| Space+sw | Search the word currently under cursor across entire project |
| Space+sb or Space+Space | Search open buffers |
| Space+sm | Search marks |
| Space+s. or Space+? | Recently opened files |
| Space+sh | Search Neovim help tags -- this is very useful once you know it exists |
| Space+sd | Search all current diagnostics (errors and warnings) |
| Space+sr | Resume -- reopen whatever Telescope was last showing |
| Space+/ | Fuzzy search inside the current buffer only |
| Space+s/ | Live grep across only your currently open files |
| Space+sds | Document symbols -- searchable list of all functions, classes, methods in current file |
Inside any Telescope picker, Ctrl+j/k navigate the list, Ctrl+l or Enter opens the selection, Esc or q (in normal mode) closes it.
The three I use constantly: Space+sf when I know the filename, Space+sg when I know some text inside the file, Space+sw on a symbol to see every place it’s used across the project. The last one replaces most of what I used “Find All References” for, before LSP’s gr handles it even better.
Space+sds deserves a mention too. In any large file, opening it and typing a function name lets you jump directly to any function or class in the file. Way faster than scrolling.
The git pickers: Space+gs opens a diff view of all changed files. Space+gc lets you browse commit history and jump into any commit. Space+gb for branches.
neotree, the file explorer
| Key | What it does |
|---|---|
| Space+e | Toggle sidebar file explorer on the left |
| Space+w | Toggle floating file explorer |
| \ | Reveal current file in the tree (opens neotree focused on the current file) |
| Space+ngs | Open git status in a floating neotree window |
Inside neotree, the standard file operations:
| Key | What it does |
|---|---|
| a | Add file (supports bash brace expansion: src/{a,b,c}.js creates three files) |
| A | Add directory |
| d | Delete |
| r | Rename |
| y / x / p | Copy / cut / paste |
| Enter or l | Open file |
| s | Open in vertical split |
| S | Open in horizontal split |
| t | Open in new tab |
| H | Toggle hidden files (dotfiles etc.) |
| / | Fuzzy find within the tree |
| z | Close all expanded nodes |
| R | Refresh the tree |
| i | Show file details -- size, modified date |
| [g / ]g | Jump to previous / next git-modified file in the tree |
| q | Close neotree |
LSP, the intelligence layer
LSP is Language Server Protocol. The idea: instead of every editor reimplementing autocomplete, go-to-definition, rename, etc. for every language separately, the editor and the language tool talk over a standardized protocol. The editor handles the UI. The language tool handles the understanding. Neovim is the editor. Language servers (separate programs that run in the background) are the tools. There are language servers for essentially every language.
When you open a Python or JavaScript file, a language server starts in the background and connects to Neovim. You’ll see this in the fidget.nvim spinner in the top right corner of the screen. Once connected, Neovim gets real code intelligence for that file.
mason, the package manager for language servers
Before Mason, you’d install each language server yourself – npm packages, pip packages, random binaries – and then manually configure each one. Very painful.
Mason lives inside Neovim and manages all of this. It downloads language servers, formatters, and linters to a single place (~/.local/share/nvim/mason/). You can see its UI with :Mason to browse and install tools. In this config, ensure_installed = vim.tbl_keys(servers) tells mason to auto-install every server defined in the config the first time Neovim opens. You never need to touch :Mason manually unless you want to add something new.
The full stack:
Formatters like prettier and stylua are not LSP servers – they’re standalone CLI tools that don’t speak the LSP protocol at all. none-ls wraps them and presents them to Neovim as if they were an LSP. This is how Ctrl+s saves and auto-formats – a BufWritePre autocmd calls the formatter before every write.
LSP keymaps
These only work inside a file where a language server is active. They’re registered on LspAttach – an event that fires when a server connects. If gd does nothing in a file, run :LspInfo to see what’s active.
| Key | What it does |
|---|---|
| gd | Go to definition -- jump to where this symbol is defined |
| gr | Go to references -- list every place this symbol is used (opens in Telescope) |
| gI | Go to implementation |
| gD | Go to declaration |
| K | Hover docs -- shows type, signature, docstring in a popup |
| Space+D | Type definition |
| Space+rn | Rename symbol across entire project -- every file, every reference, instantly |
| Space+ca | Code action -- import suggestions, fix options, extract variable, refactor choices |
| Space+ds | Document symbols -- searchable list of all functions and classes in this file |
| Space+ws | Workspace symbols -- search symbols across the whole project |
| [d / ]d | Jump to previous / next diagnostic (error or warning) |
| Space+d | Open floating window showing the full diagnostic message |
| Space+q | Send all diagnostics to the quickfix list |
| Space+do | Toggle diagnostics on/off for current buffer |
The workflow that replaced most of my VSCode usage: gd to jump to a definition, read it, Ctrl+o to come back. K on any symbol to see its type without leaving the file. ]d to cycle through errors instead of clicking red squiggles. Space+ca on an underlined error to get fix suggestions from the server. Space+rn to rename a variable – it finds every reference in every file and renames them all simultaneously.
When gr opens in Telescope you get a searchable list of every usage across the project. You can jump to any one, see the context, come back. Space+ds in a large file gives you a searchable function/class index so you can jump to any one directly.
nvim-cmp, the completion popup
In insert mode, a completion popup appears with suggestions from LSP, snippets, buffer words, and file paths. Navigation in the popup:
| Key | What it does |
|---|---|
| Ctrl+j / Ctrl+k | Navigate down / up in suggestions |
| Tab / Shift+Tab | Same navigation, or jump between snippet placeholders |
| Enter | Confirm and insert the selected suggestion |
| Ctrl+l / Ctrl+h | Jump forward / backward through snippet placeholders |
| Ctrl+c | Manually trigger completion if popup closed |
LuaSnip, the snippet engine
LuaSnip handles code snippets – short trigger words that expand into templates with cursor stops you jump between. It’s separate from LSP autocomplete. LSP suggests real symbols from your actual codebase. Snippets expand predefined templates you trigger intentionally.
This config loads friendly-snippets, a massive pre-written collection for every major language (React hooks, Python class structures, JS imports, and hundreds more). Plus these custom ones defined specifically in this config:
C++ – type cppm and Tab in a .cpp file. Expands to a full competitive programming template with #include <bits/stdc++.h>, using namespace std;, and a main() with return 0;. Cursor lands inside main ready to type.
Lua – type func and Tab. Expands to function name() with cursor on the name placeholder.
HTML – type ! and Tab. Expands to a complete HTML5 boilerplate. Cursor lands on the title field first, Tab again jumps into the body.
Snippets have insert nodes – named cursor stops where you fill in the variable parts. After expanding, each Tab press jumps to the next placeholder. Ctrl+l also jumps forward, Ctrl+h backward. When you’ve filled in the last placeholder, you’re done and the snippet is complete.
To add your own: open lua/plugins/autocompletion.lua, find the “Custom Snippets” comment, and add:
luasnip.add_snippets("javascript", {
s("cl", { -- trigger: "cl"
t("console.log("),
i(1, "value"), -- cursor stop 1, default text "value"
t(");"),
}),
})
s creates the snippet, t is static text, i is a cursor stop with optional default. A table inside t() is multi-line: t({"line one", "line two"}). i(0) is always the final cursor position.
gitsigns, inline git diffs
Before this plugin I was running git diff constantly in the terminal to check what I changed. Now it’s all inline. Gitsigns adds colored bars in the sign column (the thin strip left of line numbers):
- green bar – lines added since last commit
- yellow bar – lines modified
- red symbol – lines deleted
| Key | What it does |
|---|---|
| ]h / [h | Jump to next / previous changed hunk |
| Space+hp | Preview the diff of the hunk under cursor in a popup |
| Space+hs | Stage the hunk under cursor |
| Space+hr | Reset the hunk under cursor back to HEAD |
| Space+hS / Space+hR | Stage or reset the entire buffer |
| Space+hu | Undo the last stage operation |
| Space+hb | Show full git blame for current line in a popup |
| Space+tb | Toggle inline blame annotation on every line |
| Space+hd | Diff current file against HEAD |
| Space+td | Toggle showing deleted lines preview inline |
]h / [h for jumping between hunks is particularly useful – you can cycle through every change you made in a file without scrolling. For actual commits and push, Space+lg opens lazygit in a floating terminal. The full git TUI is easier for writing commit messages and resolving merge conflicts.
harpoon, instant file switching
Harpoon solves a specific problem: you’re always working with 3 or 4 files at any given moment in a task. Telescope is great for finding files cold. But once you know which files you need, opening Telescope every time adds friction. Harpoon lets you pin those files and jump to them with one keystroke.
| Key | What it does |
|---|---|
| Space+Ha | Add current file to the harpoon list |
| Space+Hh | Open harpoon menu -- navigate with j/k, Enter to jump, reorder files |
| Space+H1 through Space+H4 | Jump directly to harpooned file 1, 2, 3, or 4 |
| Space+Hn / Space+Hp | Cycle to next / previous harpooned file |
Typical workflow: start a task, open your main files, Space+Ha on each one. Main implementation on slot 1, test file on slot 2, related module on slot 3. Jumping between them is now a single chord. This is one of those plugins where after a week you can’t imagine not having it.
nvim-surround, wrapping pairs
This handles surrounding pairs – quotes, brackets, parens, HTML tags – without manually navigating to both ends of a selection.
| Key | What it does |
|---|---|
| ysiw" | Wrap word under cursor in double quotes |
| ysiw( | Wrap word in parentheses (with spaces: ( word )) |
| ysiw) | Wrap word in parentheses (tight: (word)) |
| ysip<div> | Wrap current paragraph in div tags |
| yss" | Wrap entire line in quotes |
| cs"' | Change surrounding double quotes to single quotes |
| cs'` | Change single quotes to backticks |
| cst<p> | Change surrounding HTML tag to p tag |
| ds" | Delete surrounding double quotes |
| ds( | Delete surrounding parentheses |
| dst | Delete surrounding HTML tag |
| S" (visual mode) | Surround the visual selection in double quotes |
ys means “you surround” – add surroundings. cs means “change surround.” ds means “delete surround.” Once those three prefixes are in muscle memory you stop thinking about individual commands and just describe what you want. “Change the surrounding tag to a div” is cst<div>. This comes up constantly in HTML and JSX work.
treesitter navigation
Beyond syntax highlighting, the treesitter setup in this config adds navigation motions that understand code structure:
| Key | What it does |
|---|---|
| ]m / [m | Jump to start of next / previous function |
| ]M / [M | Jump to end of next / previous function |
| ]] / [[ | Jump to start of next / previous class |
| ][ / [] | Jump to end of next / previous class |
| Space+a | Swap current parameter with the next one |
| Space+A | Swap current parameter with the previous one |
These use the actual syntax tree, not pattern matching. ]m finds the next real function declaration in whatever language you’re in, not just a line that looks like one. Combined with {/} for block-level navigation and f/t for line-level, you have navigation at every useful granularity.
search and replace, bulk editing
The substitute command replaces text with a pattern. The full form: :%s/pattern/replacement/flags.
| Command | What it does |
|---|---|
| :s/old/new/ | Replace first match on current line |
| :s/old/new/g | Replace all matches on current line |
| :%s/old/new/g | Replace all matches in entire file |
| :%s/old/new/gc | Replace all, confirm each one |
| :%s/old/new/gi | Replace all, case insensitive |
| :'<,'>s/old/new/g | Replace in visual selection (auto-fills when you type : from visual) |
| :5,20s/old/new/g | Replace between lines 5 and 20 |
| :%s/\<word\>/new/g | Replace whole word only (won't touch "wordpart") |
This config has inccommand = "split" set. As you type a :s command, a preview split appears at the bottom of the screen showing exactly what will change before you confirm. You see the old and new text highlighted in real-time. Once you use this, going back to blind substitution feels wrong.
the global command
:g/pattern/command runs any normal mode command on every line matching a pattern. It’s a force multiplier.
:g/console.log/d " delete every line containing console.log
:g/^$/d " delete all blank lines in the file
:g/TODO/normal! >> " indent every TODO line by one level
:g/import/y A " append every import line to register A
You use it maybe once a week but when you need it nothing else comes close.
the quickfix list and project-wide replace
When Telescope’s live grep (Space+sg) is open and you press Ctrl+q inside it, all the current matches get sent to the quickfix list – a persistent list of file locations. :copen to see it, :cclose to close it, :cnext/:cprev to navigate.
The power move: Space+sg to search something across the project, Ctrl+q to send all matches to quickfix, then :cdo s/old/new/g to run the substitution on every matched file. Project-wide refactor in four keystrokes. This replaced my usage of VSCode’s “Replace in Files” entirely.
the options this config sets
Reading through the config is how you build intuition for what’s possible. Here’s what every setting in core/options.lua actually does and why it’s there:
relativenumber = true – line distances instead of absolute numbers. Makes count-prefixed jumps natural. Without this, 5j means counting in your head. With it, you just look.
scrolloff = 8 – always keep 8 lines visible above and below the cursor. The cursor never gets stranded at the very edge of the screen where you lose context.
inccommand = "split" – live preview for substitute commands. See changes before confirming. One of the best options in Neovim and off by default.
undofile = true – undo history is written to disk and persists across sessions. You can close a file, come back tomorrow, and still undo yesterday’s changes. Just works, no action needed.
smartcase with ignorecase together – searches are case-insensitive until you type a capital letter, then case-sensitive. Almost always the right behavior.
swapfile = false – no .swapfile created. You have persistent undo and git. Swap files are unnecessary and create the “already open, recover?” popup annoyance.
foldmethod = "expr" with foldexpr = "nvim_treesitter#foldexpr()" – treesitter-based code folding. za toggles a fold, zM closes all, zR opens all. The config sets foldenable = false so files open unfolded, but you can fold manually any time.
list = true with listchars = { trail = "·", tab = "» " } – shows trailing spaces as visible dots. You’ll notice them and clean them up. Stops accidentally committing whitespace issues.
updatetime = 250 – how long Neovim waits idle before triggering CursorHold events. Lower value means diagnostics and hover docs respond faster. Default is 4000ms which feels slow.
timeoutlen = 300 – window for completing a multi-key leader sequence. If you accidentally trigger sequences too easily, bump this to 400.
expandtab = true with tabstop = 4 and shiftwidth = 4 – Tab key inserts 4 spaces. Consistent indentation. vim-sleuth (also in this config) overrides this per-file based on what the file already uses, so you always match existing code style automatically.
things I wish I had known earlier
:help is actually good. Use Space+sh to open Telescope’s help search and type anything. The built-in documentation covers every option, every key, every function. When something doesn’t work as expected, :help is usually faster than googling.
Ctrl+o and Ctrl+i are your back/forward buttons. Navigate code like web pages. gd to jump somewhere, Ctrl+o to come back, Ctrl+i to go forward. I use this all day. The jump list is automatic – you don’t manage it, just use it.
:LspInfo is for debugging LSP issues. If gd or K don’t work in a file, run :LspInfo. It shows which servers are attached, whether they started correctly, what root directory they resolved. Most LSP problems are either the server not being installed or the wrong root directory, and :LspInfo tells you which.
:checkhealth is the system diagnostic. Run it and get a full health report of every component. If something feels broken, this is the first command.
:messages recovers error output. When an error flashes at the bottom and disappears before you read it, :messages has the full text.
The first week is genuinely slower. Things that took one second in VSCode take five seconds in Neovim while you’re still thinking about which key to press. This is completely normal. The learning curve is real. The payoff comes in week two when muscle memory starts forming and you stop consciously thinking about keys. By week three, going back to VSCode for anything feels like regression. Commit to two actual weeks of daily use before making any judgments.