Neovim: The Complete Guide to Modern Text Editing
A comprehensive guide to Neovim 0.11: installation, configuration, plugins, keybindings, and how to build a powerful development environment for Go and Flutter development. From beginner basics to advanced customization.
There is a moment in every developer’s journey when they realize their text editor is holding them back. The mouse becomes an interruption. Menus feel slow. The constant context-switching between keyboard and pointing device fragments concentration. Some developers accept this friction as inevitable. Others discover Neovim.
Neovim is not just another text editor. It is a philosophy of editing made executable—a system where every keystroke can be meaningful, where the editor adapts to the developer rather than the reverse, and where the distance between thought and code shrinks to nearly nothing. It is the modern evolution of Vim, carrying forward forty years of modal editing wisdom while embracing contemporary development practices.
This guide is for developers who want to understand Neovim deeply. Whether you are coming from VS Code seeking speed, from Vim seeking modern features, or from curiosity seeking a new way to interact with code, this article will take you from installation to a fully configured development environment optimized for real-world programming.
We will cover everything: the philosophy behind modal editing, installation across platforms, configuration from scratch using Lua, essential plugins, Language Server Protocol integration, and specific setups for Go and Flutter development. By the end, you will not just have a configured editor—you will understand why every piece exists and how to extend it further.
Why Neovim Exists
To understand Neovim, we must first understand why it was created. This is not just history—it is context that explains design decisions you will encounter throughout your Neovim journey.
The Vim Legacy
Vim, created by Bram Moolenaar in 1991, was itself an improvement on Vi, which dates back to 1976. For nearly three decades, Vim was the gold standard for modal text editing. Its influence is immeasurable—Vim keybindings exist in virtually every modern editor, from VS Code to JetBrains IDEs to web browsers.
But Vim’s architecture showed its age. The codebase was massive and difficult to contribute to. Async operations were bolted on rather than native. The scripting language, Vimscript, was powerful but idiosyncratic. Plugin authors worked around limitations rather than within a designed system.
The Fork That Changed Everything
In 2014, a group of developers led by Thiago de Arruda created Neovim as a fork of Vim. Their mission was not to replace Vim but to refactor it—to modernize the architecture while preserving the editing model that made Vim exceptional.
The key innovations were:
Lua as First-Class Language: While Vimscript remains supported, Lua became the recommended configuration and plugin language. Lua is fast, well-documented, and familiar to many developers.
Native Async Architecture: Neovim was rebuilt with asynchronous I/O at its core, enabling features like Language Server Protocol support without blocking the editor.
Built-in Terminal Emulator: A full terminal inside the editor, not as an afterthought but as a designed feature.
Remote Plugin Architecture: Plugins can run in separate processes, in any language, communicating via MessagePack RPC.
Embeddable: Neovim can serve as the editing engine for other applications, from GUI frontends to browser extensions.
Neovim 0.11: The Current State
As of 2025, Neovim 0.11 represents the most mature version yet. The recent releases have focused on making Neovim powerful out of the box:
Built-in LSP Configuration: The vim.lsp.config() and vim.lsp.enable() functions provide declarative LSP setup without plugins.
Native Auto-Completion: Basic completion works immediately with <C-x><C-o> and can be enhanced with minimal configuration.
Default LSP Mappings: Keybindings like grn (rename), grr (references), gri (implementation), gO (document symbols), and gra (code action) work automatically when an LSP is attached.
Virtual Lines for Diagnostics: Errors and warnings can display as virtual lines below the problematic code.
Improved Treesitter Integration: Syntax highlighting and code understanding are faster and more accurate.
These changes mean that Neovim 0.11 is usable for serious development with minimal configuration—a significant shift from earlier versions that required extensive plugin setup.
Understanding Modal Editing
Before installing anything, you need to understand the core concept that makes Vim and Neovim different from every other editor: modal editing.
The Problem with Modeless Editors
In traditional editors, every key you press either inserts a character or requires a modifier (Ctrl, Alt, Cmd). Want to delete a word? Ctrl+Backspace. Want to go to a line? Ctrl+G. Want to select a paragraph? Click, drag, or Shift+arrows.
This approach has fundamental limitations:
- Modifier Fatigue: Constantly pressing Ctrl and Alt strains hands and slows editing.
- Limited Vocabulary: There are only so many Ctrl+key combinations.
- No Composability: Each action is atomic. You cannot combine them fluidly.
The Modal Solution
Neovim solves this with modes. In Normal mode, keys are commands, not characters. In Insert mode, keys insert text. In Visual mode, keys define selections. This separation creates a rich vocabulary for editing.
The primary modes are:
Normal Mode: Your home base. Keys are commands. d deletes, y yanks (copies), c changes, w moves by word. Press Escape or <C-[> to return here from any mode.
Insert Mode: Text entry. Keys insert characters. Enter from Normal mode with i (insert before cursor), a (append after cursor), o (open line below), or many others.
Visual Mode: Selection. Keys extend selection. Enter with v (character-wise), V (line-wise), or <C-v> (block-wise).
Command-Line Mode: Ex commands. Enter with : for commands like :w (write), :q (quit), or :s (substitute).
The Grammar of Editing
Modal editing creates a grammar: verbs (actions) combine with nouns (motions and text objects) to form sentences (commands).
Verbs include:
d— deletec— change (delete and enter Insert mode)y— yank (copy)>— indent<— unindent
Nouns include:
w— wordb— back word$— end of line0— beginning of linegg— beginning of fileG— end of fileiw— inner wordaw— a word (including surrounding space)i"— inside quotesa{— a block including braces
Combining them:
dw— delete wordci"— change inside quotesyap— yank a paragraph>G— indent from here to end of filed$— delete to end of line
This composability is why experienced Neovim users can edit at the speed of thought. Once you internalize the grammar, new commands become intuitive combinations rather than memorized shortcuts.
Installing Neovim
With the philosophy understood, let us get Neovim running on your system. Installation varies by operating system, but all paths lead to the same powerful editor.
Linux Installation
Linux offers the most installation options. The best choice depends on your distribution and how current you need your Neovim version.
Arch Linux and Derivatives (Manjaro, EndeavourOS, CachyOS)
Arch-based distributions typically have the latest Neovim in their repositories:
sudo pacman -S neovim
This installs Neovim along with its runtime files. For the absolute latest version, you can use the neovim-git package from the AUR:
yay -S neovim-git
# or with paru
paru -S neovim-git
Debian and Ubuntu
The official repositories often lag behind. For recent versions, use the Neovim PPA:
sudo add-apt-repository ppa:neovim-ppa/unstable
sudo apt update
sudo apt install neovim
Alternatively, download the AppImage for a portable, self-contained installation:
curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage
chmod u+x nvim.appimage
sudo mv nvim.appimage /usr/local/bin/nvim
Fedora
Fedora maintains reasonably current packages:
sudo dnf install neovim
macOS Installation
Homebrew provides the simplest installation path:
brew install neovim
For the development version:
brew install --HEAD neovim
Windows Installation
Windows users have several options:
Scoop (Recommended)
scoop install neovim
Chocolatey
choco install neovim
Winget
winget install Neovim.Neovim
Manual Installation
Download the MSI installer or ZIP archive from the GitHub releases page and add the binary to your PATH.
Verifying Installation
After installation, verify everything works:
nvim --version
You should see output similar to:
NVIM v0.11.5
Build type: Release
LuaJIT 2.1.1713484068
The version should be 0.11.x or higher to follow this guide fully. If your package manager provides an older version, consider using an AppImage, building from source, or using a version manager like bob.
Bob: A Neovim Version Manager
For developers who need to switch between Neovim versions or always want the latest nightly build, bob is invaluable:
# Install bob (Rust required)
cargo install bob-nvim
# Or on Arch
yay -S bob
# Install latest stable
bob install stable
bob use stable
# Install nightly
bob install nightly
bob use nightly
# List installed versions
bob list
Bob manages multiple Neovim installations and makes switching between them trivial—useful when testing plugins or debugging version-specific issues.
The Configuration Directory
Neovim’s power comes from configuration. Understanding the directory structure is essential before writing any Lua.
Where Configuration Lives
Neovim looks for configuration in a platform-specific location:
| Platform | Path |
|---|---|
| Linux/macOS | ~/.config/nvim/ |
| Windows | ~/AppData/Local/nvim/ |
The $XDG_CONFIG_HOME environment variable can override this on Unix-like systems.
Directory Structure
A well-organized Neovim configuration typically follows this structure:
~/.config/nvim/
├── init.lua # Entry point
├── lua/
│ ├── config/
│ │ ├── options.lua # Editor settings
│ │ ├── keymaps.lua # Key bindings
│ │ └── autocmds.lua # Auto commands
│ └── plugins/
│ ├── lsp.lua # LSP configuration
│ ├── telescope.lua # Fuzzy finder
│ ├── treesitter.lua
│ └── ...
├── after/
│ └── ftplugin/ # Filetype-specific settings
│ ├── go.lua
│ ├── dart.lua
│ └── ...
└── snippets/ # Custom snippets
This structure separates concerns:
init.lua: The entry point that loads everything elselua/config/: Core editor behavior independent of pluginslua/plugins/: Plugin specifications and configurationsafter/ftplugin/: Settings that apply only to specific file typessnippets/: Custom code snippets
The Init File
Every Neovim configuration begins with init.lua (or init.vim for Vimscript, but we will use Lua throughout this guide). This file is executed when Neovim starts.
A minimal init.lua might look like:
-- Set leader key before loading plugins
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
-- Load configuration modules
require("config.options")
require("config.keymaps")
The require() function loads Lua modules from the lua/ directory. When you write require("config.options"), Neovim looks for lua/config/options.lua.
Understanding Vim Options in Lua
Neovim exposes Vim options through Lua tables:
vim.o— Global optionsvim.bo— Buffer-local optionsvim.wo— Window-local optionsvim.opt— Modern API that handles lists and maps elegantlyvim.g— Global variablesvim.b— Buffer variablesvim.env— Environment variables
For most configuration, vim.opt is preferred because it handles complex option types gracefully:
-- vim.o requires string manipulation for list options
vim.o.clipboard = "unnamedplus"
-- vim.opt handles lists naturally
vim.opt.clipboard = { "unnamedplus" }
-- Append to list options
vim.opt.shortmess:append("c")
-- Remove from list options
vim.opt.shortmess:remove("F")
Essential Editor Options
A well-configured options.lua transforms Neovim from a bare editor into a comfortable development environment. Here is a comprehensive configuration with explanations:
-- lua/config/options.lua
local opt = vim.opt
-- Line Numbers
opt.number = true -- Show absolute line number on current line
opt.relativenumber = true -- Show relative numbers on other lines
-- This combination gives you the best of both worlds:
-- You see your current line number and can use relative
-- numbers for quick jumps (5j, 12k, etc.)
-- Indentation
opt.tabstop = 4 -- Visual width of a tab character
opt.shiftwidth = 4 -- Width for autoindent
opt.softtabstop = 4 -- Spaces per tab when editing
opt.expandtab = true -- Convert tabs to spaces
opt.smartindent = true -- Smart autoindenting on new lines
opt.autoindent = true -- Copy indent from current line
-- For languages that prefer 2-space indentation (like Lua, Dart),
-- you can override these in after/ftplugin/
-- Search Behavior
opt.ignorecase = true -- Ignore case in search patterns
opt.smartcase = true -- Override ignorecase if pattern has uppercase
opt.hlsearch = true -- Highlight all search matches
opt.incsearch = true -- Show matches as you type
-- smartcase is particularly useful: searching for "function"
-- matches "Function", but searching for "Function" only
-- matches exact case
-- Visual Aids
opt.cursorline = true -- Highlight the current line
opt.signcolumn = "yes" -- Always show sign column (for git, diagnostics)
opt.colorcolumn = "80" -- Show column guide at 80 characters
opt.wrap = false -- Don't wrap long lines
opt.scrolloff = 8 -- Keep 8 lines above/below cursor
opt.sidescrolloff = 8 -- Keep 8 columns left/right of cursor
-- Window Behavior
opt.splitbelow = true -- Horizontal splits go below
opt.splitright = true -- Vertical splits go right
-- These match common expectations and make split navigation
-- more intuitive
-- System Integration
opt.clipboard = "unnamedplus" -- Use system clipboard
opt.mouse = "a" -- Enable mouse in all modes
-- Using unnamedplus means yanking in Neovim copies to your
-- system clipboard, and you can paste from outside Neovim
-- File Handling
opt.backup = false -- Don't create backup files
opt.writebackup = false -- Don't create backup before overwriting
opt.swapfile = false -- Don't create swap files
opt.undofile = true -- Persist undo history between sessions
opt.undodir = vim.fn.stdpath("data") .. "/undo"
-- Swap files can cause issues with file watchers and modern
-- workflows. Undo files are more useful—you can undo changes
-- even after closing and reopening a file.
-- Performance
opt.updatetime = 250 -- Faster completion and diagnostics
opt.timeoutlen = 300 -- Faster key sequence completion
opt.lazyredraw = false -- Don't redraw during macros (careful with this)
-- Appearance
opt.termguicolors = true -- Enable 24-bit RGB colors
opt.showmode = false -- Don't show mode (statusline handles it)
opt.pumheight = 10 -- Max items in popup menu
-- Completion
opt.completeopt = { "menu", "menuone", "noselect" }
-- This setting is important for LSP completion:
-- menu: show menu even for one item
-- menuone: show menu even for one match
-- noselect: don't auto-select first item
Global Variables
Some settings use global variables rather than options:
-- Disable built-in plugins you don't need
vim.g.loaded_netrw = 1 -- Disable netrw (file explorer)
vim.g.loaded_netrwPlugin = 1
vim.g.loaded_gzip = 1 -- Disable gzip
vim.g.loaded_zip = 1 -- Disable zip
vim.g.loaded_zipPlugin = 1
vim.g.loaded_tar = 1 -- Disable tar
vim.g.loaded_tarPlugin = 1
-- Leader keys (set before loading plugins)
vim.g.mapleader = " " -- Space as leader
vim.g.maplocalleader = "\\" -- Backslash as local leader
Disabling built-in plugins you do not use slightly improves startup time and avoids conflicts with modern alternatives like neo-tree or oil.nvim.
Key Mappings
Key mappings are the heart of a personalized Neovim experience. The vim.keymap.set() function provides a clean API for defining them.
The Keymap API
vim.keymap.set(mode, lhs, rhs, opts)
- mode: String or table of modes (
"n","i","v","x","t",{"n", "v"}) - lhs: The key sequence to map
- rhs: The action (string command or Lua function)
- opts: Table of options (desc, silent, noremap, buffer, etc.)
Essential Key Mappings
Here is a comprehensive keymaps.lua with explanations:
-- lua/config/keymaps.lua
local keymap = vim.keymap.set
local opts = { noremap = true, silent = true }
-- Better escape
keymap("i", "jk", "<Esc>", { desc = "Exit insert mode" })
keymap("i", "jj", "<Esc>", { desc = "Exit insert mode" })
-- Many Neovim users remap jk or jj to Escape for faster
-- mode switching. Choose one that feels natural.
-- Save and quit shortcuts
keymap("n", "<C-s>", "<cmd>w<CR>", { desc = "Save file" })
keymap("n", "<leader>q", "<cmd>q<CR>", { desc = "Quit" })
keymap("n", "<leader>Q", "<cmd>qa!<CR>", { desc = "Quit all without saving" })
-- Window navigation
keymap("n", "<C-h>", "<C-w>h", { desc = "Move to left window" })
keymap("n", "<C-j>", "<C-w>j", { desc = "Move to lower window" })
keymap("n", "<C-k>", "<C-w>k", { desc = "Move to upper window" })
keymap("n", "<C-l>", "<C-w>l", { desc = "Move to right window" })
-- These make navigating between splits much faster than
-- the default <C-w>h, <C-w>j, etc.
-- Window resizing
keymap("n", "<C-Up>", "<cmd>resize +2<CR>", { desc = "Increase window height" })
keymap("n", "<C-Down>", "<cmd>resize -2<CR>", { desc = "Decrease window height" })
keymap("n", "<C-Left>", "<cmd>vertical resize -2<CR>", { desc = "Decrease window width" })
keymap("n", "<C-Right>", "<cmd>vertical resize +2<CR>", { desc = "Increase window width" })
-- Buffer navigation
keymap("n", "<S-h>", "<cmd>bprevious<CR>", { desc = "Previous buffer" })
keymap("n", "<S-l>", "<cmd>bnext<CR>", { desc = "Next buffer" })
keymap("n", "<leader>bd", "<cmd>bdelete<CR>", { desc = "Delete buffer" })
-- Using Shift+h and Shift+l for buffer navigation is intuitive
-- since h and l are left/right movements
-- Better line movement
keymap("n", "j", "gj", opts) -- Move by visual line, not actual line
keymap("n", "k", "gk", opts)
-- When lines wrap, j and k normally skip entire wrapped lines.
-- gj and gk move by visual lines instead.
-- Keep cursor centered when scrolling
keymap("n", "<C-d>", "<C-d>zz", opts)
keymap("n", "<C-u>", "<C-u>zz", opts)
keymap("n", "n", "nzzzv", opts)
keymap("n", "N", "Nzzzv", opts)
-- zz centers the screen on the cursor. This keeps you oriented
-- during rapid navigation.
-- Better indenting in visual mode
keymap("v", "<", "<gv", opts)
keymap("v", ">", ">gv", opts)
-- Normally, indenting in visual mode exits visual mode.
-- These mappings maintain the selection so you can
-- indent multiple times.
-- Move lines up and down
keymap("n", "<A-j>", "<cmd>m .+1<CR>==", { desc = "Move line down" })
keymap("n", "<A-k>", "<cmd>m .-2<CR>==", { desc = "Move line up" })
keymap("v", "<A-j>", ":m '>+1<CR>gv=gv", { desc = "Move selection down" })
keymap("v", "<A-k>", ":m '<-2<CR>gv=gv", { desc = "Move selection up" })
-- Extremely useful for reorganizing code. Select lines and
-- Alt+j/k to move them around.
-- Duplicate lines
keymap("n", "<leader>d", "<cmd>t.<CR>", { desc = "Duplicate line" })
keymap("v", "<leader>d", "y'>p", { desc = "Duplicate selection" })
-- Clear search highlight
keymap("n", "<Esc>", "<cmd>nohlsearch<CR>", { desc = "Clear search highlight" })
-- After searching, pressing Escape clears the highlighting
-- without needing :noh
-- Better paste
keymap("v", "p", '"_dP', opts)
-- When pasting over a selection, this preserves the original
-- yanked text instead of replacing it with the deleted text
-- Terminal mappings
keymap("t", "<Esc><Esc>", "<C-\\><C-n>", { desc = "Exit terminal mode" })
keymap("t", "<C-h>", "<cmd>wincmd h<CR>", { desc = "Move to left window" })
keymap("t", "<C-j>", "<cmd>wincmd j<CR>", { desc = "Move to lower window" })
keymap("t", "<C-k>", "<cmd>wincmd k<CR>", { desc = "Move to upper window" })
keymap("t", "<C-l>", "<cmd>wincmd l<CR>", { desc = "Move to right window" })
-- Quick access to common actions
keymap("n", "<leader>w", "<cmd>w<CR>", { desc = "Save" })
keymap("n", "<leader>e", "<cmd>Explore<CR>", { desc = "File explorer" })
keymap("n", "<leader>/", "gcc", { remap = true, desc = "Toggle comment" })
keymap("v", "<leader>/", "gc", { remap = true, desc = "Toggle comment" })
The Leader Key Philosophy
The leader key creates a namespace for custom commands. Using Space as leader is popular because:
- It is easy to reach with either thumb
- It has no default function in Normal mode
- It creates intuitive mnemonics:
<leader>ffor find,<leader>gfor git, etc.
A well-organized leader key structure might look like:
<leader>f — Find/Files
<leader>g — Git
<leader>b — Buffers
<leader>l — LSP
<leader>t — Terminal/Toggle
<leader>w — Window/Write
<leader>c — Code
<leader>d — Debug/Duplicate
<leader>s — Search/Split
This organization makes keybindings discoverable. When you forget a mapping, you can press <leader> and pause—plugins like which-key will show available continuations.
Plugin Management with lazy.nvim
Plugins extend Neovim’s capabilities far beyond the defaults. The modern standard for plugin management is lazy.nvim, created by folke—one of the most prolific Neovim plugin authors.
Why lazy.nvim
lazy.nvim offers several advantages over older managers like packer.nvim or vim-plug:
- Lazy Loading: Plugins load only when needed, dramatically improving startup time
- Lockfile Support: Pin exact plugin versions for reproducible configurations
- Automatic Caching: Compiled Lua bytecode for faster loads
- Built-in UI: Visual interface for managing plugins
- Profiling: Built-in tools to identify slow plugins
Bootstrapping lazy.nvim
The bootstrap code goes in your init.lua before any plugin configuration:
-- init.lua
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.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)
-- Set leader before loading plugins
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
-- Load configuration
require("config.options")
require("config.keymaps")
-- Setup lazy.nvim
require("lazy").setup("plugins", {
defaults = {
lazy = true, -- Lazy-load all plugins by default
},
install = {
colorscheme = { "tokyonight", "habamax" },
},
checker = {
enabled = true, -- Check for plugin updates
notify = false, -- Don't notify on startup
},
performance = {
rtp = {
disabled_plugins = {
"gzip",
"tarPlugin",
"tohtml",
"tutor",
"zipPlugin",
},
},
},
})
This code:
- Clones lazy.nvim if not present
- Adds it to the runtime path
- Sets up lazy to load plugins from
lua/plugins/ - Configures sensible defaults
Plugin Specification Format
lazy.nvim uses a declarative format for plugin specs. Each file in lua/plugins/ returns a table (or list of tables) describing plugins:
-- lua/plugins/example.lua
return {
"author/plugin-name", -- GitHub shorthand
dependencies = { -- Plugins this depends on
"another/plugin",
},
event = "VeryLazy", -- When to load
cmd = { "SomeCommand" }, -- Load on command
keys = { -- Load on keymap
{ "<leader>p", "<cmd>SomeCommand<CR>", desc = "Do something" },
},
ft = { "lua", "go" }, -- Load for filetypes
opts = { -- Options passed to setup()
option1 = true,
},
config = function(_, opts) -- Custom configuration
require("plugin").setup(opts)
end,
}
Lazy Loading Strategies
Lazy loading is crucial for fast startup. lazy.nvim provides several triggers:
event: Load on Neovim events
event = "BufReadPost" -- After reading a buffer
event = "VeryLazy" -- After startup, during idle
event = "InsertEnter" -- When entering insert mode
event = { "BufReadPre", "BufNewFile" } -- Multiple events
cmd: Load when a command is run
cmd = "Telescope" -- Load when :Telescope is called
cmd = { "Git", "Gdiff" } -- Multiple commands
keys: Load when a key is pressed
keys = { "<leader>ff" } -- Load on this keymap
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find files" },
}
ft: Load for specific filetypes
ft = "go" -- Load for Go files
ft = { "dart", "flutter" } -- Multiple filetypes
The Lazy UI
Access the lazy.nvim interface with :Lazy. From here you can:
- Sync: Install missing plugins and update existing ones
- Update: Update all plugins
- Clean: Remove unused plugins
- Check: Check for updates without installing
- Profile: See startup time per plugin
- Log: View git log of plugin changes
Essential Plugins
With lazy.nvim configured, let us build a modern development environment plugin by plugin.
Colorscheme: tokyonight
A good colorscheme reduces eye strain and makes syntax highlighting meaningful. tokyonight is one of the most popular modern themes:
-- lua/plugins/colorscheme.lua
return {
"folke/tokyonight.nvim",
lazy = false, -- Load immediately (colorscheme must load first)
priority = 1000, -- Load before other plugins
opts = {
style = "night", -- night, storm, day, moon
transparent = false,
terminal_colors = true,
styles = {
comments = { italic = true },
keywords = { italic = true },
functions = {},
variables = {},
},
sidebars = { "qf", "help", "neo-tree" },
},
config = function(_, opts)
require("tokyonight").setup(opts)
vim.cmd.colorscheme("tokyonight")
end,
}
Other excellent options include catppuccin, gruvbox-material, rose-pine, and kanagawa.
Treesitter: Advanced Syntax Highlighting
Treesitter provides syntax highlighting based on actual parsing, not regex patterns. This means more accurate highlighting and enables structural editing:
-- lua/plugins/treesitter.lua
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
event = { "BufReadPost", "BufNewFile" },
dependencies = {
"nvim-treesitter/nvim-treesitter-textobjects",
},
opts = {
ensure_installed = {
"bash",
"dart",
"go",
"gomod",
"gosum",
"html",
"javascript",
"json",
"lua",
"markdown",
"markdown_inline",
"python",
"rust",
"typescript",
"vim",
"vimdoc",
"yaml",
},
auto_install = true,
highlight = {
enable = true,
additional_vim_regex_highlighting = false,
},
indent = {
enable = true,
},
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<C-space>",
node_incremental = "<C-space>",
scope_incremental = false,
node_decremental = "<bs>",
},
},
textobjects = {
select = {
enable = true,
lookahead = true,
keymaps = {
["af"] = "@function.outer",
["if"] = "@function.inner",
["ac"] = "@class.outer",
["ic"] = "@class.inner",
["aa"] = "@parameter.outer",
["ia"] = "@parameter.inner",
},
},
move = {
enable = true,
set_jumps = true,
goto_next_start = {
["]m"] = "@function.outer",
["]]"] = "@class.outer",
},
goto_prev_start = {
["[m"] = "@function.outer",
["[["] = "@class.outer",
},
},
},
},
config = function(_, opts)
require("nvim-treesitter.configs").setup(opts)
end,
}
Treesitter textobjects are particularly powerful. After installing, you can:
daf— Delete a functioncic— Change inside class]m— Jump to next function[m— Jump to previous function
Telescope: Fuzzy Finding Everything
Telescope is Neovim’s most popular fuzzy finder. It searches files, text, LSP symbols, git history, and much more:
-- lua/plugins/telescope.lua
return {
"nvim-telescope/telescope.nvim",
branch = "0.1.x",
dependencies = {
"nvim-lua/plenary.nvim",
{
"nvim-telescope/telescope-fzf-native.nvim",
build = "make",
cond = function()
return vim.fn.executable("make") == 1
end,
},
"nvim-telescope/telescope-ui-select.nvim",
},
cmd = "Telescope",
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find files" },
{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Live grep" },
{ "<leader>fb", "<cmd>Telescope buffers<CR>", desc = "Buffers" },
{ "<leader>fh", "<cmd>Telescope help_tags<CR>", desc = "Help tags" },
{ "<leader>fr", "<cmd>Telescope oldfiles<CR>", desc = "Recent files" },
{ "<leader>fc", "<cmd>Telescope git_commits<CR>", desc = "Git commits" },
{ "<leader>fs", "<cmd>Telescope git_status<CR>", desc = "Git status" },
{ "<leader>fd", "<cmd>Telescope diagnostics<CR>", desc = "Diagnostics" },
{ "<leader><leader>", "<cmd>Telescope buffers<CR>", desc = "Buffers" },
{ "<leader>/", "<cmd>Telescope live_grep<CR>", desc = "Search in project" },
},
opts = {
defaults = {
prompt_prefix = " ",
selection_caret = " ",
path_display = { "truncate" },
sorting_strategy = "ascending",
layout_config = {
horizontal = {
prompt_position = "top",
preview_width = 0.55,
},
vertical = {
mirror = false,
},
width = 0.87,
height = 0.80,
preview_cutoff = 120,
},
mappings = {
i = {
["<C-j>"] = "move_selection_next",
["<C-k>"] = "move_selection_previous",
["<C-q>"] = "send_to_qflist",
},
},
},
pickers = {
find_files = {
hidden = true,
find_command = { "rg", "--files", "--hidden", "--glob", "!.git/*" },
},
},
extensions = {
fzf = {
fuzzy = true,
override_generic_sorter = true,
override_file_sorter = true,
case_mode = "smart_case",
},
["ui-select"] = {
require("telescope.themes").get_dropdown(),
},
},
},
config = function(_, opts)
local telescope = require("telescope")
telescope.setup(opts)
pcall(telescope.load_extension, "fzf")
pcall(telescope.load_extension, "ui-select")
end,
}
The fzf-native extension dramatically improves fuzzy matching performance. The ui-select extension makes vim.ui.select() use Telescope, so things like code actions appear in a beautiful popup.
Neo-tree: File Explorer
Neo-tree provides a modern file tree with git integration:
-- lua/plugins/neo-tree.lua
return {
"nvim-neo-tree/neo-tree.nvim",
branch = "v3.x",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-tree/nvim-web-devicons",
"MunifTanjim/nui.nvim",
},
cmd = "Neotree",
keys = {
{ "<leader>e", "<cmd>Neotree toggle<CR>", desc = "Toggle file explorer" },
{ "<leader>o", "<cmd>Neotree focus<CR>", desc = "Focus file explorer" },
},
deactivate = function()
vim.cmd([[Neotree close]])
end,
opts = {
close_if_last_window = true,
popup_border_style = "rounded",
enable_git_status = true,
enable_diagnostics = true,
filesystem = {
bind_to_cwd = false,
follow_current_file = { enabled = true },
use_libuv_file_watcher = true,
filtered_items = {
hide_dotfiles = false,
hide_gitignored = false,
hide_by_name = {
".git",
"node_modules",
},
},
},
window = {
position = "left",
width = 35,
mappings = {
["<space>"] = "none",
["<cr>"] = "open",
["o"] = "open",
["s"] = "open_split",
["v"] = "open_vsplit",
["h"] = "close_node",
["l"] = "open",
},
},
default_component_configs = {
indent = {
with_expanders = true,
expander_collapsed = "",
expander_expanded = "",
expander_highlight = "NeoTreeExpander",
},
git_status = {
symbols = {
added = "",
modified = "",
deleted = "✖",
renamed = "",
untracked = "",
ignored = "",
unstaged = "",
staged = "",
conflict = "",
},
},
},
},
}
Which-key: Discoverability
which-key displays available keybindings in a popup. Press a key (like <leader>) and wait—a panel appears showing all possible continuations:
-- lua/plugins/which-key.lua
return {
"folke/which-key.nvim",
event = "VeryLazy",
opts = {
plugins = {
marks = true,
registers = true,
spelling = {
enabled = true,
suggestions = 20,
},
},
win = {
border = "rounded",
},
spec = {
{ "<leader>f", group = "Find" },
{ "<leader>g", group = "Git" },
{ "<leader>l", group = "LSP" },
{ "<leader>b", group = "Buffer" },
{ "<leader>t", group = "Terminal" },
{ "<leader>c", group = "Code" },
{ "<leader>F", group = "Flutter" },
},
},
keys = {
{
"<leader>?",
function()
require("which-key").show({ global = false })
end,
desc = "Buffer Local Keymaps",
},
},
}
Gitsigns: Git Integration
Gitsigns shows git diff information in the sign column and provides git actions:
-- lua/plugins/gitsigns.lua
return {
"lewis6991/gitsigns.nvim",
event = { "BufReadPost", "BufNewFile" },
opts = {
signs = {
add = { text = "▎" },
change = { text = "▎" },
delete = { text = "" },
topdelete = { text = "" },
changedelete = { text = "▎" },
untracked = { text = "▎" },
},
on_attach = function(bufnr)
local gs = package.loaded.gitsigns
local function map(mode, l, r, opts)
opts = opts or {}
opts.buffer = bufnr
vim.keymap.set(mode, l, r, opts)
end
-- Navigation
map("n", "]h", gs.next_hunk, { desc = "Next hunk" })
map("n", "[h", gs.prev_hunk, { desc = "Previous hunk" })
-- Actions
map("n", "<leader>gs", gs.stage_hunk, { desc = "Stage hunk" })
map("n", "<leader>gr", gs.reset_hunk, { desc = "Reset hunk" })
map("v", "<leader>gs", function()
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, { desc = "Stage hunk" })
map("v", "<leader>gr", function()
gs.reset_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, { desc = "Reset hunk" })
map("n", "<leader>gS", gs.stage_buffer, { desc = "Stage buffer" })
map("n", "<leader>gu", gs.undo_stage_hunk, { desc = "Undo stage hunk" })
map("n", "<leader>gR", gs.reset_buffer, { desc = "Reset buffer" })
map("n", "<leader>gp", gs.preview_hunk, { desc = "Preview hunk" })
map("n", "<leader>gb", function()
gs.blame_line({ full = true })
end, { desc = "Blame line" })
map("n", "<leader>gd", gs.diffthis, { desc = "Diff this" })
end,
},
}
Autopairs and Surround
These quality-of-life plugins handle matching pairs:
-- lua/plugins/autopairs.lua
return {
"windwp/nvim-autopairs",
event = "InsertEnter",
opts = {
check_ts = true,
ts_config = {
lua = { "string" },
javascript = { "template_string" },
},
fast_wrap = {
map = "<M-e>",
chars = { "{", "[", "(", '"', "'" },
pattern = [=[[%'%"%>%]%)%}%,]]=],
end_key = "$",
keys = "qwertyuiopzxcvbnmasdfghjkl",
check_comma = true,
highlight = "Search",
highlight_grey = "Comment",
},
},
}
-- lua/plugins/surround.lua
return {
"kylechui/nvim-surround",
version = "*",
event = "VeryLazy",
opts = {},
}
nvim-surround gives you powerful text object manipulation:
ysiw"— Surround inner word with quotesds"— Delete surrounding quotescs"'— Change surrounding quotes to single quotesyss)— Surround entire line with parentheses
Comment.nvim
Smart commenting with treesitter integration:
-- lua/plugins/comment.lua
return {
"numToStr/Comment.nvim",
event = { "BufReadPost", "BufNewFile" },
dependencies = {
"JoosepAlviste/nvim-ts-context-commentstring",
},
opts = function()
return {
pre_hook = require("ts_context_commentstring.integrations.comment_nvim").create_pre_hook(),
}
end,
}
This enables gcc to toggle line comments and gc in visual mode to toggle block comments. The ts-context-commentstring integration ensures correct comment syntax in embedded languages (like JavaScript inside HTML).
Language Server Protocol (LSP)
The Language Server Protocol is what transforms Neovim from a text editor into an IDE. LSP provides intelligent features: auto-completion, go-to-definition, find references, rename symbols, diagnostics, and more.
How LSP Works
LSP separates language intelligence from the editor. A language server (like gopls for Go or dart for Flutter) runs as a separate process, analyzing your code. Neovim communicates with it via JSON-RPC, requesting information and receiving responses.
This architecture means:
- Language intelligence is reusable across editors
- The editor stays fast while the server does heavy analysis
- You can use any language server that speaks the protocol
flowchart LR
subgraph Neovim
B[Buffer] --> C[LSP Client]
end
subgraph Servers
C <--> G[gopls]
C <--> D[dart]
C <--> T[typescript]
C <--> L[lua_ls]
end
Neovim 0.11 Built-in LSP
Neovim 0.11 simplified LSP configuration dramatically. You can now configure servers declaratively:
-- Using built-in LSP configuration (Neovim 0.11+)
vim.lsp.config('gopls', {
cmd = { 'gopls' },
filetypes = { 'go', 'gomod', 'gowork', 'gotmpl' },
root_markers = { 'go.mod', 'go.work', '.git' },
settings = {
gopls = {
analyses = {
unusedparams = true,
},
staticcheck = true,
},
},
})
vim.lsp.enable('gopls')
Default LSP keybindings in 0.11:
grn— Rename symbolgrr— Find referencesgri— Go to implementationgO— Document symbolsgra— Code actionK— Hover documentation
Mason: LSP Server Manager
While you can install language servers manually, Mason provides a convenient UI for managing them:
-- lua/plugins/mason.lua
return {
"williamboman/mason.nvim",
dependencies = {
"williamboman/mason-lspconfig.nvim",
"neovim/nvim-lspconfig",
},
config = function()
require("mason").setup({
ui = {
border = "rounded",
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
})
require("mason-lspconfig").setup({
ensure_installed = {
"lua_ls",
"gopls",
"pyright",
"ts_ls",
"html",
"cssls",
"tailwindcss",
"rust_analyzer",
},
automatic_installation = true,
})
end,
}
Run :Mason to open the UI. From there you can install, update, and remove language servers, formatters, linters, and debug adapters.
Complete LSP Configuration
Here is a comprehensive LSP setup that works with Mason:
-- lua/plugins/lsp.lua
return {
"neovim/nvim-lspconfig",
event = { "BufReadPost", "BufNewFile" },
dependencies = {
"williamboman/mason.nvim",
"williamboman/mason-lspconfig.nvim",
"hrsh7th/cmp-nvim-lsp",
{ "j-hui/fidget.nvim", opts = {} }, -- LSP progress indicator
},
config = function()
-- Diagnostic configuration
vim.diagnostic.config({
virtual_text = {
prefix = "●",
source = "if_many",
},
float = {
border = "rounded",
source = "always",
},
signs = true,
underline = true,
update_in_insert = false,
severity_sort = true,
})
-- Diagnostic signs
local signs = { Error = " ", Warn = " ", Hint = " ", Info = " " }
for type, icon in pairs(signs) do
local hl = "DiagnosticSign" .. type
vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = "" })
end
-- LSP attach callback
local on_attach = function(client, bufnr)
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })
end
-- Navigation
map("n", "gd", vim.lsp.buf.definition, "Go to definition")
map("n", "gD", vim.lsp.buf.declaration, "Go to declaration")
map("n", "gi", vim.lsp.buf.implementation, "Go to implementation")
map("n", "gr", vim.lsp.buf.references, "Find references")
map("n", "gt", vim.lsp.buf.type_definition, "Go to type definition")
-- Documentation
map("n", "K", vim.lsp.buf.hover, "Hover documentation")
map("n", "<C-k>", vim.lsp.buf.signature_help, "Signature help")
map("i", "<C-k>", vim.lsp.buf.signature_help, "Signature help")
-- Actions
map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")
map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action")
map("v", "<leader>ca", vim.lsp.buf.code_action, "Code action")
-- Diagnostics
map("n", "[d", vim.diagnostic.goto_prev, "Previous diagnostic")
map("n", "]d", vim.diagnostic.goto_next, "Next diagnostic")
map("n", "<leader>ld", vim.diagnostic.open_float, "Line diagnostics")
map("n", "<leader>lq", vim.diagnostic.setloclist, "Diagnostics to loclist")
-- Workspace
map("n", "<leader>wa", vim.lsp.buf.add_workspace_folder, "Add workspace folder")
map("n", "<leader>wr", vim.lsp.buf.remove_workspace_folder, "Remove workspace folder")
map("n", "<leader>wl", function()
print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
end, "List workspace folders")
-- Format on save (optional)
if client.supports_method("textDocument/formatting") then
vim.api.nvim_create_autocmd("BufWritePre", {
buffer = bufnr,
callback = function()
vim.lsp.buf.format({ bufnr = bufnr })
end,
})
end
end
-- Capabilities for nvim-cmp
local capabilities = require("cmp_nvim_lsp").default_capabilities()
-- Setup servers via mason-lspconfig
require("mason-lspconfig").setup_handlers({
-- Default handler
function(server_name)
require("lspconfig")[server_name].setup({
on_attach = on_attach,
capabilities = capabilities,
})
end,
-- Lua specific settings
["lua_ls"] = function()
require("lspconfig").lua_ls.setup({
on_attach = on_attach,
capabilities = capabilities,
settings = {
Lua = {
runtime = { version = "LuaJIT" },
diagnostics = {
globals = { "vim" },
},
workspace = {
library = vim.api.nvim_get_runtime_file("", true),
checkThirdParty = false,
},
telemetry = { enable = false },
},
},
})
end,
-- Go specific settings
["gopls"] = function()
require("lspconfig").gopls.setup({
on_attach = on_attach,
capabilities = capabilities,
settings = {
gopls = {
analyses = {
unusedparams = true,
shadow = true,
fieldalignment = true,
nilness = true,
unusedwrite = true,
useany = true,
},
staticcheck = true,
gofumpt = true,
usePlaceholders = true,
completeUnimported = true,
directoryFilters = { "-.git", "-node_modules" },
semanticTokens = true,
},
},
})
end,
})
end,
}
Auto-completion with nvim-cmp
While Neovim 0.11 has basic built-in completion, nvim-cmp provides a much richer experience with multiple sources, snippets, and customization.
The Completion Ecosystem
nvim-cmp is modular. The core plugin handles the completion menu and logic, while sources provide the actual completions:
- cmp-nvim-lsp: LSP completions
- cmp-buffer: Words from current buffer
- cmp-path: File system paths
- cmp-luasnip: Snippet expansions
- cmp-cmdline: Command line completion
Complete nvim-cmp Configuration
-- lua/plugins/cmp.lua
return {
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-nvim-lsp",
"hrsh7th/cmp-buffer",
"hrsh7th/cmp-path",
"hrsh7th/cmp-cmdline",
{
"L3MON4D3/LuaSnip",
version = "v2.*",
build = "make install_jsregexp",
dependencies = {
"rafamadriz/friendly-snippets",
},
},
"saadparwaiz1/cmp_luasnip",
"onsails/lspkind.nvim",
},
config = function()
local cmp = require("cmp")
local luasnip = require("luasnip")
local lspkind = require("lspkind")
-- Load friendly-snippets
require("luasnip.loaders.from_vscode").lazy_load()
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
mapping = cmp.mapping.preset.insert({
-- Navigation
["<C-j>"] = cmp.mapping.select_next_item(),
["<C-k>"] = cmp.mapping.select_prev_item(),
["<C-b>"] = cmp.mapping.scroll_docs(-4),
["<C-f>"] = cmp.mapping.scroll_docs(4),
-- Completion
["<C-Space>"] = cmp.mapping.complete(),
["<C-e>"] = cmp.mapping.abort(),
["<CR>"] = cmp.mapping.confirm({ select = false }),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
elseif luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
else
fallback()
end
end, { "i", "s" }),
["<S-Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item()
elseif luasnip.jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, { "i", "s" }),
}),
sources = cmp.config.sources({
{ name = "nvim_lsp", priority = 1000 },
{ name = "luasnip", priority = 750 },
{ name = "buffer", priority = 500 },
{ name = "path", priority = 250 },
}),
formatting = {
format = lspkind.cmp_format({
mode = "symbol_text",
maxwidth = 50,
ellipsis_char = "...",
menu = {
nvim_lsp = "[LSP]",
luasnip = "[Snip]",
buffer = "[Buf]",
path = "[Path]",
},
}),
},
experimental = {
ghost_text = true,
},
})
-- Command line completion
cmp.setup.cmdline(":", {
mapping = cmp.mapping.preset.cmdline(),
sources = cmp.config.sources({
{ name = "path" },
}, {
{ name = "cmdline" },
}),
})
-- Search completion
cmp.setup.cmdline({ "/", "?" }, {
mapping = cmp.mapping.preset.cmdline(),
sources = {
{ name = "buffer" },
},
})
end,
}
Understanding the Completion Flow
When you type in Insert mode:
- Sources gather candidates: Each enabled source (LSP, buffer, snippets) provides possible completions
- Ranking: nvim-cmp scores and sorts candidates based on priority and matching
- Display: The completion menu shows ranked items with icons and source labels
- Selection: You navigate with keybindings and confirm with Enter
- Expansion: For snippets, placeholders are inserted and you can jump between them
The Tab key behavior is particularly important. In this configuration:
- If completion menu is visible, Tab selects the next item
- If a snippet is active, Tab jumps to the next placeholder
- Otherwise, Tab inserts a normal tab character
LuaSnip: Snippets Engine
LuaSnip is a powerful snippet engine. Combined with friendly-snippets, you get hundreds of pre-made snippets for most languages.
To see available snippets, type a trigger and wait for completion. For example, in a Go file:
fnexpands to a function declarationiferrexpands to Go’s error handling patternpkgmexpands to a main package with main function
You can also create custom snippets:
-- lua/snippets/go.lua
local ls = require("luasnip")
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local f = ls.function_node
ls.add_snippets("go", {
s("errh", {
t("if err != nil {"),
t({ "", "\treturn " }),
i(1, "err"),
t({ "", "}" }),
}),
s("handler", {
t("func "),
i(1, "handlerName"),
t("(w http.ResponseWriter, r *http.Request) {"),
t({ "", "\t" }),
i(0),
t({ "", "}" }),
}),
})
Go Development Setup
Go and Neovim are a natural pairing. Go’s emphasis on simplicity and tooling aligns perfectly with Neovim’s philosophy. With gopls (the official Go language server), you get world-class IDE features.
Installing Go Tools
Before configuring Neovim, ensure Go is installed and the tools are available:
# Install Go (Arch Linux)
sudo pacman -S go
# Or download from golang.org for other systems
# Set GOPATH and GOBIN (add to your shell config)
export GOPATH=$HOME/go
export GOBIN=$GOPATH/bin
export PATH=$PATH:$GOBIN
# Install gopls
go install golang.org/x/tools/gopls@latest
# Install additional tools
go install github.com/go-delve/delve/cmd/dlv@latest # Debugger
go install golang.org/x/tools/cmd/goimports@latest # Import management
go install github.com/fatih/gomodifytags@latest # Struct tag manipulation
go install github.com/josharian/impl@latest # Interface implementation
gopls Configuration
The LSP configuration we created earlier includes gopls, but here are advanced settings specifically tuned for Go development:
-- In your LSP configuration, gopls handler
["gopls"] = function()
require("lspconfig").gopls.setup({
on_attach = on_attach,
capabilities = capabilities,
cmd = { "gopls" },
filetypes = { "go", "gomod", "gowork", "gotmpl" },
root_dir = require("lspconfig.util").root_pattern(
"go.work",
"go.mod",
".git"
),
settings = {
gopls = {
-- Build
buildFlags = { "-tags=integration" },
-- Formatting
gofumpt = true, -- Stricter formatting than gofmt
-- Diagnostics
analyses = {
unusedparams = true,
shadow = true,
fieldalignment = true,
nilness = true,
unusedwrite = true,
useany = true,
unusedvariable = true,
},
staticcheck = true,
vulncheck = "Imports", -- Check for known vulnerabilities
-- Completion
usePlaceholders = true,
completeUnimported = true,
-- Navigation
directoryFilters = { "-.git", "-node_modules", "-vendor" },
-- Inlay hints (Neovim 0.10+)
hints = {
assignVariableTypes = true,
compositeLiteralFields = true,
compositeLiteralTypes = true,
constantValues = true,
functionTypeParameters = true,
parameterNames = true,
rangeVariableTypes = true,
},
-- Code lens
codelenses = {
gc_details = true,
generate = true,
regenerate_cgo = true,
run_govulncheck = true,
test = true,
tidy = true,
upgrade_dependency = true,
vendor = true,
},
-- Semantic tokens
semanticTokens = true,
},
},
})
end,
Go-specific Keymaps
Add these to your keymaps for Go-specific actions:
-- lua/config/keymaps.lua (or in after/ftplugin/go.lua)
-- Only load for Go files
vim.api.nvim_create_autocmd("FileType", {
pattern = { "go" },
callback = function(ev)
local opts = { buffer = ev.buf }
-- Go to test file (alternates between foo.go and foo_test.go)
vim.keymap.set("n", "<leader>gt", function()
local file = vim.fn.expand("%")
if file:match("_test%.go$") then
vim.cmd("edit " .. file:gsub("_test%.go$", ".go"))
else
vim.cmd("edit " .. file:gsub("%.go$", "_test.go"))
end
end, vim.tbl_extend("force", opts, { desc = "Toggle test file" }))
-- Run current test
vim.keymap.set("n", "<leader>tt", function()
local test_func = vim.fn.search("func Test", "bcnW")
if test_func > 0 then
local line = vim.fn.getline(test_func)
local test_name = line:match("func (Test%w+)")
if test_name then
vim.cmd("!go test -v -run " .. test_name .. " ./...")
end
end
end, vim.tbl_extend("force", opts, { desc = "Run current test" }))
-- Run all tests in package
vim.keymap.set("n", "<leader>ta", "<cmd>!go test -v ./...<CR>",
vim.tbl_extend("force", opts, { desc = "Run all tests" }))
-- Generate struct tags
vim.keymap.set("n", "<leader>gj", "<cmd>GoAddTag json<CR>",
vim.tbl_extend("force", opts, { desc = "Add JSON tags" }))
-- Fill struct (requires gopls)
vim.keymap.set("n", "<leader>gf", function()
vim.lsp.buf.code_action({
filter = function(action)
return action.title == "Fill struct"
end,
apply = true,
})
end, vim.tbl_extend("force", opts, { desc = "Fill struct" }))
end,
})
Go Debugging with DAP
For debugging Go applications, use nvim-dap with delve:
-- lua/plugins/dap.lua
return {
"mfussenegger/nvim-dap",
dependencies = {
"rcarriga/nvim-dap-ui",
"nvim-neotest/nvim-nio",
"leoluz/nvim-dap-go",
},
keys = {
{ "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "Toggle breakpoint" },
{ "<leader>dc", function() require("dap").continue() end, desc = "Continue" },
{ "<leader>di", function() require("dap").step_into() end, desc = "Step into" },
{ "<leader>do", function() require("dap").step_over() end, desc = "Step over" },
{ "<leader>dO", function() require("dap").step_out() end, desc = "Step out" },
{ "<leader>dr", function() require("dap").repl.toggle() end, desc = "Toggle REPL" },
{ "<leader>du", function() require("dapui").toggle() end, desc = "Toggle DAP UI" },
},
config = function()
local dap = require("dap")
local dapui = require("dapui")
require("dap-go").setup({
dap_configurations = {
{
type = "go",
name = "Debug Package",
request = "launch",
program = "${fileDirname}",
},
{
type = "go",
name = "Debug Test",
request = "launch",
mode = "test",
program = "${file}",
},
{
type = "go",
name = "Attach to Process",
request = "attach",
processId = require("dap.utils").pick_process,
},
},
})
dapui.setup()
-- Auto open/close DAP UI
dap.listeners.after.event_initialized["dapui_config"] = function()
dapui.open()
end
dap.listeners.before.event_terminated["dapui_config"] = function()
dapui.close()
end
end,
}
Go Filetype Settings
Create filetype-specific settings:
-- after/ftplugin/go.lua
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.expandtab = false -- Go uses tabs, not spaces
-- Auto-organize imports on save
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.go",
callback = function()
-- Organize imports
local params = vim.lsp.util.make_range_params()
params.context = { only = { "source.organizeImports" } }
local result = vim.lsp.buf_request_sync(0, "textDocument/codeAction", params, 3000)
for _, res in pairs(result or {}) do
for _, r in pairs(res.result or {}) do
if r.edit then
vim.lsp.util.apply_workspace_edit(r.edit, "UTF-8")
end
end
end
-- Format
vim.lsp.buf.format({ async = false })
end,
})
Flutter Development Setup
Flutter development in Neovim is a joy with flutter-tools.nvim. This plugin provides hot reload, device selection, widget guides, and more—matching much of what Flutter’s official IDE integrations offer.
Prerequisites
Ensure Flutter is installed and in your PATH:
# Check Flutter installation
flutter doctor
# The Dart SDK comes with Flutter, but ensure dart is in PATH
which dart
which flutter
flutter-tools.nvim Configuration
-- lua/plugins/flutter.lua
return {
"akinsho/flutter-tools.nvim",
lazy = false, -- Load immediately for Flutter projects
dependencies = {
"nvim-lua/plenary.nvim",
"stevearc/dressing.nvim", -- Better UI for selections
},
ft = { "dart" },
opts = {
ui = {
border = "rounded",
notification_style = "native",
},
decorations = {
statusline = {
app_version = true,
device = true,
},
},
debugger = {
enabled = true,
run_via_dap = true,
exception_breakpoints = {},
register_configurations = function(paths)
require("dap").configurations.dart = {
{
type = "dart",
request = "launch",
name = "Launch Flutter",
dartSdkPath = paths.dart_sdk,
flutterSdkPath = paths.flutter_sdk,
program = "${workspaceFolder}/lib/main.dart",
cwd = "${workspaceFolder}",
},
{
type = "dart",
request = "attach",
name = "Attach to Flutter",
dartSdkPath = paths.dart_sdk,
flutterSdkPath = paths.flutter_sdk,
program = "${workspaceFolder}/lib/main.dart",
cwd = "${workspaceFolder}",
},
}
end,
},
widget_guides = {
enabled = true, -- Show widget hierarchy lines
},
closing_tags = {
highlight = "Comment",
prefix = "// ",
enabled = true, -- Show closing tags for widgets
},
dev_log = {
enabled = true,
notify_errors = true,
open_cmd = "tabedit",
},
dev_tools = {
autostart = true,
auto_open_browser = true,
},
outline = {
open_cmd = "30vnew",
auto_open = false,
},
lsp = {
color = {
enabled = true,
background = true,
virtual_text = true,
virtual_text_str = "■",
},
settings = {
showTodos = true,
completeFunctionCalls = true,
renameFilesWithClasses = "prompt",
enableSnippets = true,
updateImportsOnRename = true,
},
},
},
config = function(_, opts)
require("flutter-tools").setup(opts)
-- Telescope integration
require("telescope").load_extension("flutter")
end,
}
Flutter Keymaps
-- Flutter-specific keymaps (in flutter.lua or keymaps.lua)
local flutter_keys = {
{ "<leader>Fc", "<cmd>FlutterRun<CR>", desc = "Flutter Run" },
{ "<leader>Fr", "<cmd>FlutterReload<CR>", desc = "Flutter Hot Reload" },
{ "<leader>FR", "<cmd>FlutterRestart<CR>", desc = "Flutter Hot Restart" },
{ "<leader>Fq", "<cmd>FlutterQuit<CR>", desc = "Flutter Quit" },
{ "<leader>Fd", "<cmd>FlutterDevices<CR>", desc = "Flutter Devices" },
{ "<leader>FD", "<cmd>FlutterDetach<CR>", desc = "Flutter Detach" },
{ "<leader>Fo", "<cmd>FlutterOutlineToggle<CR>", desc = "Flutter Outline" },
{ "<leader>Fl", "<cmd>FlutterDevLog<CR>", desc = "Flutter Dev Log" },
{ "<leader>Fe", "<cmd>FlutterEmulators<CR>", desc = "Flutter Emulators" },
{ "<leader>Fv", "<cmd>FlutterVisualDebug<CR>", desc = "Flutter Visual Debug" },
{ "<leader>Fp", "<cmd>FlutterCopyProfilerUrl<CR>", desc = "Copy Profiler URL" },
{ "<leader>Ff", "<cmd>Telescope flutter commands<CR>", desc = "Flutter Commands" },
}
for _, key in ipairs(flutter_keys) do
vim.keymap.set("n", key[1], key[2], { desc = key.desc })
end
Dart LSP Enhancement
The Dart LSP is already configured by flutter-tools, but you can add extra settings:
-- Additional Dart settings via flutter-tools lsp config
lsp = {
on_attach = function(client, bufnr)
-- Your standard on_attach
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })
end
-- Standard LSP keymaps
map("n", "gd", vim.lsp.buf.definition, "Go to definition")
map("n", "gr", vim.lsp.buf.references, "Find references")
map("n", "K", vim.lsp.buf.hover, "Hover documentation")
map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")
map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action")
-- Dart-specific
map("n", "<leader>oi", function()
vim.lsp.buf.code_action({
filter = function(action)
return action.title == "Organize Imports"
end,
apply = true,
})
end, "Organize imports")
end,
capabilities = capabilities,
settings = {
dart = {
analysisExcludedFolders = {
vim.fn.expand("$HOME/.pub-cache"),
vim.fn.expand("$HOME/flutter"),
},
updateImportsOnRename = true,
completeFunctionCalls = true,
showTodos = true,
},
},
}
Dart Filetype Settings
-- after/ftplugin/dart.lua
vim.opt_local.tabstop = 2
vim.opt_local.shiftwidth = 2
vim.opt_local.expandtab = true
-- Flutter uses 2-space indentation
vim.opt_local.colorcolumn = "80"
-- Auto format on save
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.dart",
callback = function()
vim.lsp.buf.format({ async = false })
end,
})
Flutter Workflow
A typical Flutter development workflow in Neovim:
- Start the app:
<leader>Fc(FlutterRun) — selects device and starts - Make changes: Edit your Dart files
- Hot reload:
<leader>Fr— instant UI updates without losing state - Hot restart:
<leader>FR— full restart when hot reload is insufficient - View logs:
<leader>Fl— open dev log in a new tab - Debug: Use DAP breakpoints with
<leader>db, then<leader>dcto continue - Inspect widgets:
<leader>Fv— toggle visual debugging - Switch devices:
<leader>Fd— select different device/emulator
The widget guides feature shows vertical lines connecting parent and child widgets, making Flutter’s nested widget tree much easier to navigate.
Terminal Integration
One of Neovim’s strengths is its built-in terminal emulator. Combined with toggleterm.nvim, you get a powerful terminal experience without leaving your editor.
toggleterm Configuration
-- lua/plugins/toggleterm.lua
return {
"akinsho/toggleterm.nvim",
version = "*",
keys = {
{ "<leader>t", desc = "Terminal" },
{ "<C-\\>", desc = "Toggle terminal" },
},
opts = {
size = function(term)
if term.direction == "horizontal" then
return 15
elseif term.direction == "vertical" then
return vim.o.columns * 0.4
end
end,
open_mapping = [[<C-\>]],
hide_numbers = true,
shade_filetypes = {},
shade_terminals = true,
shading_factor = 2,
start_in_insert = true,
insert_mappings = true,
terminal_mappings = true,
persist_size = true,
persist_mode = true,
direction = "float",
close_on_exit = true,
shell = vim.o.shell,
float_opts = {
border = "curved",
winblend = 0,
},
winbar = {
enabled = false,
},
},
config = function(_, opts)
require("toggleterm").setup(opts)
-- Custom terminals
local Terminal = require("toggleterm.terminal").Terminal
-- Lazygit
local lazygit = Terminal:new({
cmd = "lazygit",
dir = "git_dir",
direction = "float",
float_opts = {
border = "double",
},
on_open = function(term)
vim.cmd("startinsert!")
vim.api.nvim_buf_set_keymap(
term.bufnr,
"n",
"q",
"<cmd>close<CR>",
{ noremap = true, silent = true }
)
end,
})
vim.keymap.set("n", "<leader>gg", function()
lazygit:toggle()
end, { desc = "Lazygit" })
-- Horizontal terminal
vim.keymap.set("n", "<leader>th", function()
vim.cmd("ToggleTerm direction=horizontal")
end, { desc = "Horizontal terminal" })
-- Vertical terminal
vim.keymap.set("n", "<leader>tv", function()
vim.cmd("ToggleTerm direction=vertical")
end, { desc = "Vertical terminal" })
-- Float terminal
vim.keymap.set("n", "<leader>tf", function()
vim.cmd("ToggleTerm direction=float")
end, { desc = "Float terminal" })
end,
}
Terminal Keymaps
Essential keymaps for terminal mode:
-- Terminal navigation (in keymaps.lua)
function _G.set_terminal_keymaps()
local opts = { buffer = 0 }
vim.keymap.set("t", "<Esc><Esc>", [[<C-\><C-n>]], opts)
vim.keymap.set("t", "<C-h>", [[<Cmd>wincmd h<CR>]], opts)
vim.keymap.set("t", "<C-j>", [[<Cmd>wincmd j<CR>]], opts)
vim.keymap.set("t", "<C-k>", [[<Cmd>wincmd k<CR>]], opts)
vim.keymap.set("t", "<C-l>", [[<Cmd>wincmd l<CR>]], opts)
end
vim.cmd("autocmd! TermOpen term://* lua set_terminal_keymaps()")
Running Commands
With toggleterm, you can send commands to terminals:
-- Run current file based on filetype
vim.keymap.set("n", "<leader>rf", function()
local ft = vim.bo.filetype
local file = vim.fn.expand("%")
local cmd = ""
if ft == "go" then
cmd = "go run " .. file
elseif ft == "python" then
cmd = "python " .. file
elseif ft == "javascript" then
cmd = "node " .. file
elseif ft == "typescript" then
cmd = "npx ts-node " .. file
elseif ft == "lua" then
cmd = "lua " .. file
elseif ft == "sh" or ft == "bash" then
cmd = "bash " .. file
end
if cmd ~= "" then
require("toggleterm").exec(cmd)
end
end, { desc = "Run file" })
Tips and Advanced Usage
After setting up the basics, here are techniques that separate proficient Neovim users from experts.
Marks and Jumps
Marks let you bookmark positions in files:
ma -- Set mark 'a' at current position
'a -- Jump to line of mark 'a'
`a -- Jump to exact position of mark 'a'
:marks -- List all marks
-- Special marks
'' -- Position before last jump
'. -- Position of last change
'^ -- Position of last insert
Capital letters create global marks that work across files:
mA -- Set global mark 'A'
'A -- Jump to file and line of mark 'A'
Macros
Macros record and replay keystrokes:
qa -- Start recording macro 'a'
... -- Your keystrokes
q -- Stop recording
@a -- Play macro 'a'
@@ -- Replay last macro
5@a -- Play macro 'a' 5 times
Practical macro example—converting a list of words to a Lua table:
qa -- Record to 'a'
I" -- Insert " at beginning
A", -- Append ", at end
j -- Move to next line
q -- Stop recording
10@a -- Apply to next 10 lines
Registers
Registers are like named clipboards:
"ayy -- Yank line to register 'a'
"ap -- Paste from register 'a'
"Ayy -- Append to register 'a' (capital letter)
"+y -- Yank to system clipboard
"+p -- Paste from system clipboard
"0p -- Paste from yank register (last yank, not delete)
:reg -- Show all registers
Search and Replace
Basic pattern replacement:
:%s/old/new/g -- Replace all occurrences in file
:%s/old/new/gc -- With confirmation
:5,10s/old/new/g -- Lines 5-10 only
:'<,'>s/old/new/g -- Visual selection only
Advanced patterns:
:%s/\<word\>/new/g -- Whole word only
:%s/word/\U&/g -- Replace with uppercase
:%s/\v(\w+)/"\1"/g -- Regex with capture groups
Quickfix and Location List
The quickfix list holds locations across files—grep results, compiler errors, etc.
:copen -- Open quickfix window
:cclose -- Close quickfix window
:cnext -- Next item
:cprev -- Previous item
:cfirst -- First item
:clast -- Last item
Useful keymaps:
vim.keymap.set("n", "]q", "<cmd>cnext<CR>", { desc = "Next quickfix" })
vim.keymap.set("n", "[q", "<cmd>cprev<CR>", { desc = "Prev quickfix" })
vim.keymap.set("n", "<leader>xq", "<cmd>copen<CR>", { desc = "Open quickfix" })
Window Management
Efficient window manipulation:
<C-w>s -- Split horizontal
<C-w>v -- Split vertical
<C-w>c -- Close window
<C-w>o -- Close all other windows
<C-w>= -- Equal size windows
<C-w>_ -- Maximize height
<C-w>| -- Maximize width
<C-w>T -- Move window to new tab
Folding
Code folding based on treesitter:
-- In options.lua
vim.opt.foldmethod = "expr"
vim.opt.foldexpr = "nvim_treesitter#foldexpr()"
vim.opt.foldlevel = 99 -- Start with all folds open
Fold commands:
za -- Toggle fold under cursor
zc -- Close fold
zo -- Open fold
zR -- Open all folds
zM -- Close all folds
zj -- Move to next fold
zk -- Move to previous fold
Session Management
Save and restore your workspace:
-- lua/plugins/session.lua
return {
"folke/persistence.nvim",
event = "BufReadPre",
opts = {
dir = vim.fn.stdpath("state") .. "/sessions/",
options = { "buffers", "curdir", "tabpages", "winsize" },
},
keys = {
{ "<leader>qs", function() require("persistence").load() end, desc = "Restore session" },
{ "<leader>ql", function() require("persistence").load({ last = true }) end, desc = "Restore last session" },
{ "<leader>qd", function() require("persistence").stop() end, desc = "Don't save session" },
},
}
Performance Tips
Startup Time Profiling
Check what slows down startup:
nvim --startuptime startup.log
Or within Neovim:
:Lazy profile
Lazy Loading Best Practices
- Use
event = "VeryLazy"for plugins you do not need immediately - Use
ftfor language-specific plugins - Use
cmdfor plugins you invoke via command - Use
keysfor plugins triggered by keymaps
Disable Unused Built-in Plugins
In your init.lua before loading lazy.nvim:
local disabled_built_ins = {
"netrw",
"netrwPlugin",
"netrwSettings",
"netrwFileHandlers",
"gzip",
"zip",
"zipPlugin",
"tar",
"tarPlugin",
"getscript",
"getscriptPlugin",
"vimball",
"vimballPlugin",
"2html_plugin",
"logipat",
"rrhelper",
"spellfile_plugin",
"matchit",
}
for _, plugin in pairs(disabled_built_ins) do
vim.g["loaded_" .. plugin] = 1
end
Conclusion
Neovim is more than a text editor—it is a development philosophy. The time invested in learning its patterns and configuring it to your needs pays dividends over a career of writing code. Every keystroke saved, every context switch eliminated, every second of waiting avoided compounds into hours and days of focused, productive work.
The configuration we have built throughout this guide gives you:
- Modal editing mastery through understanding the grammar of Vim
- Modern Lua configuration with lazy.nvim for plugin management
- Full IDE capabilities via LSP integration and nvim-cmp completion
- Language-specific setups for Go and Flutter development
- Powerful utilities like Telescope, Treesitter, and terminal integration
But this is just the beginning. Neovim’s extensibility means your configuration will evolve with you. You will discover new plugins, refine your keymaps, and build automations specific to your workflow. The editor that felt foreign will become an extension of your thinking.
Some parting advice:
- Learn incrementally: Do not try to memorize everything. Pick one new motion or command per day.
- Read the help: Neovim’s
:helpis exceptional.:help motion,:help text-objects,:help lua-guide. - Steal from others: Browse GitHub for Neovim configurations. Learn from what others have built.
- Contribute back: Found a bug? Have an improvement? The Neovim community thrives on contributions.
The path from Neovim beginner to expert is not measured in days but in the accumulation of small optimizations, the gradual internalization of motions, and the slow transformation of conscious commands into unconscious muscle memory.
Welcome to Neovim. Your journey has just begun.