Dossier - nvim-ultimate-dossier
Chapter 1: Introduction to Vim and Neovim
1.1 History and Philosophy
The Birth of Vi
To understand Vim and Neovim, we must first travel back to 1976, when Bill Joy created vi (visual) at the University of California, Berkeley. At the time, most text editors were line-oriented, meaning you could only edit one line at a time. Vi revolutionized text editing by allowing users to see and edit multiple lines simultaneously on their screen—a “visual” mode of editing that seems obvious today but was groundbreaking then.
Vi was built on top of ex, a line editor that
itself descended from ed, the standard Unix text
editor. This lineage explains why Vim still has an “Ex mode” and why
many commands begin with a colon (:). These Ex commands
are the DNA of vi, preserved through generations of evolution.
Vim: Vi IMproved
In 1991, Bram Moolenaar wanted to use vi on his Amiga computer but found no suitable port. So he created one, initially called “Vi IMitation.” As he added more features, the name evolved to “Vi IMproved”—Vim. The first public release came in November 1991 (Vim 1.14).
What started as a simple port became something far more powerful:
Multiple levels of undo (original vi only had one level)
Syntax highlighting for hundreds of languages
Split windows for viewing multiple files
Visual mode for selecting text
Plugin system for extensibility
Scripting language (VimScript/VimL) for customization
Extensive help system built into the editor
Over three decades, Vim became one of the most popular text editors in the world, beloved by programmers, system administrators, and writers. Bram Moolenaar maintained Vim until his passing in August 2023, leaving behind an incredible legacy.
The Neovim Revolution
In 2014, Thiago de Arruda started Neovim as a fork of Vim with ambitious goals:
Refactor the codebase to make it more maintainable
First-class support for embedding Neovim as a library
Built-in terminal emulator
Asynchronous job control
Lua as a first-class scripting language
Better default settings out of the box
Modern development practices (continuous integration, better testing)
The Neovim project wasn’t born from animosity toward Vim, but from a desire to modernize the codebase while preserving what made Vim great. The motto became: “Vim, but better.”
The Philosophical Divide
Both Vim and Neovim share the same core philosophy:
Modal Editing: Unlike most editors where keys directly insert characters, Vim and Neovim separate editing into distinct modes. In Normal mode, keys are commands; in Insert mode, they insert text. This separation allows for powerful editing commands without endless keyboard shortcuts.
Composability: Commands are built from simple,
composable parts. d means “delete,” w
means “word,” so dw deletes a word. 3dw
deletes three words. This grammar-like structure makes Vim
incredibly powerful once you learn its vocabulary.
Text as Data: Vim treats text editing as data manipulation. Macros, registers, and repeatable operations make complex edits simple and automatable.
Where they diverge:
Vim prioritizes backward compatibility and stability. Changes are conservative, and existing scripts should continue working.
Neovim prioritizes modern features and developer experience. Breaking changes are acceptable if they lead to a better future.
Why This Matters
Understanding this history helps you appreciate both editors:
Vim is mature, stable, and available everywhere. If you SSH into a server, Vim is likely there.
Neovim is innovative, extensible, and has a thriving plugin ecosystem leveraging Lua and modern APIs.
Both are actively developed. Both are excellent. This book focuses more on Neovim because:
It represents the future of modal editing
Its Lua integration makes plugin development more accessible
Its built-in LSP client brings IDE-like features natively
Its architecture is cleaner for learning concepts
But everything you learn applies to both—they share 95% of their DNA.
1.2 Vim vs Neovim: Key Differences
Architectural Differences
Codebase
Vim: Original C codebase, over 30 years of accumulated code. Approximately 450,000 lines.
Neovim: Refactored C codebase with aggressive removal of legacy code. Cleaner separation of concerns.
Scripting Languages
Vim: VimScript (VimL) is the primary scripting language. Lua support added in Vim 8.2+ but secondary.
Neovim: Both VimScript and Lua are first-class. Lua is preferred and faster. Most new plugins use Lua.
Asynchronous Operations
Vim: Added async support in Vim 8.0 (2016) via jobs and channels.
Neovim: Built with async in mind from the start. Uses libuv for async I/O.
Embedding
Vim: Not designed to be embedded.
Neovim: Built as a library that can be embedded in other applications. Remote UI protocol allows GUI clients.
Feature Differences
| Feature | Vim | Neovim |
|---|---|---|
| Built-in Terminal | :terminal (Vim 8.1+) |
:terminal with better implementation |
| LSP Client | Requires plugin | Built-in (vim.lsp.*) |
| Tree-sitter | Not available | Built-in for better syntax highlighting |
| Lua API | Limited | Extensive (vim.api, vim.fn,
vim.loop) |
| Configuration File | ~/.vimrc or ~/.vim/vimrc |
~/.config/nvim/init.vim or
init.lua |
| Plugin Directory | ~/.vim/ |
~/.config/nvim/ or
~/.local/share/nvim/ |
| Default Leader | \ |
\ (but many use Space) |
| Line Numbers | Off by default | Off by default (but easy to enable) |
| Mouse Support | Requires :set mouse=a |
Enabled by default |
| Clipboard Integration | Requires configuration | Better defaults |
| Floating Windows | Popup windows (Vim 8.2+) | Native floating windows with more features |
Configuration Differences
File Locations
# Vim
~/.vimrc # Main config file
~/.vim/ # Vim directory
~/.vim/autoload/ # Autoload scripts
~/.vim/plugin/ # Plugin files
# Neovim
~/.config/nvim/init.vim # VimScript config
~/.config/nvim/init.lua # Lua config (preferred)
~/.config/nvim/lua/ # Lua modules
~/.local/share/nvim/ # Data directory
~/.local/state/nvim/ # State files (undo, shada)Sample Configurations
Vim (VimScript)
" ~/.vimrc
set number
set relativenumber
set expandtab
set shiftwidth=4
set tabstop=4
" Plugin management with vim-plug
call plug#begin('~/.vim/plugged')
Plug 'tpope/vim-fugitive'
Plug 'junegunn/fzf.vim'
call plug#end()
" Key mappings
nnoremap <leader>ff :Files<CR>
nnoremap <leader>fg :Rg<CR>
Neovim (VimScript)
" ~/.config/nvim/init.vim
set number
set relativenumber
set expandtab
set shiftwidth=4
set tabstop=4
" Plugin management with vim-plug
call plug#begin('~/.local/share/nvim/plugged')
Plug 'tpope/vim-fugitive'
Plug 'nvim-telescope/telescope.nvim'
call plug#end()
" Key mappings
nnoremap <leader>ff <cmd>Telescope find_files<cr>
nnoremap <leader>fg <cmd>Telescope live_grep<cr>
Neovim (Lua)
-- ~/.config/nvim/init.lua
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.expandtab = true
vim.opt.shiftwidth = 4
vim.opt.tabstop = 4
-- Plugin management with lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git", "clone", "--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({
"tpope/vim-fugitive",
{
"nvim-telescope/telescope.nvim",
dependencies = { "nvim-lua/plenary.nvim" }
}
})
-- Key mappings
vim.keymap.set('n', '<leader>ff', '<cmd>Telescope find_files<cr>')
vim.keymap.set('n', '<leader>fg', '<cmd>Telescope live_grep<cr>')API Differences
VimScript Function Call
" Vim and Neovim
:echo expand('%:p')
:call setline(1, 'Hello, World!')
Neovim Lua API
-- Neovim only
print(vim.fn.expand('%:p'))
vim.api.nvim_buf_set_lines(0, 0, 1, false, {'Hello, World!'})
-- Better performance with direct API calls
local current_file = vim.api.nvim_buf_get_name(0)Performance Differences
Startup Time
Vim: Generally faster startup with minimal configuration
Neovim: Slightly slower due to Lua runtime initialization, but faster with optimized configs
Plugin Loading
Vim: Traditional runtime loading
Neovim: Lazy loading is more common and effective with modern plugin managers
Syntax Highlighting
Vim: Regex-based syntax highlighting
Neovim: Tree-sitter provides faster, more accurate highlighting
Default Behaviors
Neovim’s Better Defaults
" These are ON by default in Neovim but need to be set in Vim:
set autoindent " Copy indent from current line when starting new line
set autoread " Reload files changed outside of Vim
set backspace=indent,eol,start " Backspace over everything in insert mode
set belloff=all " Never ring the bell
set complete-=i " Don't scan included files for completion
set display=lastline " Show as much as possible of the last line
set encoding=utf-8 " Default encoding
set formatoptions=tcqj " Better format options
set history=10000 " Longer command history
set hlsearch " Highlight search matches
set incsearch " Incremental search
set langnoremap " Helps avoid mapping conflicts
set laststatus=2 " Always show status line
set listchars=tab:>\ ,trail:-,nbsp:+ " Better whitespace characters
set mouse=a " Enable mouse support (not in Vim by default in terminals)
set nrformats=bin,hex " Better number formats for <C-a>/<C-x>
set ruler " Show cursor position
set sessionoptions-=options " Don't save all options in sessions
set showcmd " Show partial commands
set sidescroll=1 " Smoother horizontal scrolling
set smarttab " Smart tab behavior
set tabpagemax=50 " More tab pages allowed
set tags=./tags;,tags " Better tags file search
set ttyfast " Faster terminal connection
set viminfo+=! " Better viminfo
set wildmenu " Command-line completion menu
When to Choose Which?
Choose Vim if:
You need maximum compatibility with older systems
You prefer conservative, battle-tested updates
You’re working on servers where Neovim isn’t installed
You have extensive VimScript customizations you don’t want to migrate
You prefer the traditional Unix philosophy of stability
Choose Neovim if:
You want modern IDE-like features (LSP, Tree-sitter)
You prefer Lua over VimScript for configuration
You want the latest features and active development
You’re building a new configuration from scratch
You appreciate better defaults out of the box
You want a more active plugin ecosystem
Use Both if:
You maintain systems with different editors installed
You want to learn both for maximum flexibility
You keep a shared configuration that works on both
Migration Path
If you’re coming from Vim to Neovim:
Start with your existing Vim config: Neovim reads
~/.vimrcif~/.config/nvim/init.vimdoesn’t existGradually adopt Neovim features: Add LSP, Tree-sitter, Lua configs incrementally
Use compatibility layers: Most Vim plugins work in Neovim unchanged
Keep both installed: They can coexist peacefully
-- init.lua: Load your old vimrc first
vim.cmd('source ~/.vimrc')
-- Then add Neovim-specific features
require('neovim-specific-config')1.3 Installation and Setup
Installing Vim
Linux
# Debian/Ubuntu
sudo apt update
sudo apt install vim
# For full features (Python, Ruby, etc.)
sudo apt install vim-gtk3 # or vim-gnome
# Fedora/RHEL/CentOS
sudo dnf install vim-enhanced
# Arch Linux
sudo pacman -S vim
# Check version and features
vim --versionmacOS
# macOS comes with Vim, but it's often outdated
vim --version # Check current version
# Install latest with Homebrew
brew install vim
# For GUI version (MacVim)
brew install macvimWindows
Download from vim.org
Run the installer (gvim##.exe)
Or use package managers:
# Chocolatey
choco install vim
# Scoop
scoop install vim
# Winget
winget install vim.vimBuilding from Source
# Get the source
git clone https://github.com/vim/vim.git
cd vim/src
# Configure with desired features
./configure --with-features=huge \
--enable-multibyte \
--enable-python3interp=yes \
--enable-rubyinterp=yes \
--enable-luainterp=yes \
--enable-perlinterp=yes \
--enable-gui=gtk3 \
--enable-cscope \
--prefix=/usr/local
# Compile and install
make
sudo make installInstalling Neovim
Linux
# Ubuntu/Debian (stable version)
sudo apt install neovim
# For latest stable via PPA
sudo add-apt-repository ppa:neovim-ppa/stable
sudo apt update
sudo apt install neovim
# Fedora
sudo dnf install neovim
# Arch Linux
sudo pacman -S neovim
# AppImage (works on most Linux distros)
curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage
chmod u+x nvim.appimage
./nvim.appimage
# Move to PATH
sudo mv nvim.appimage /usr/local/bin/nvimmacOS
# Homebrew
brew install neovim
# MacPorts
sudo port install neovimWindows
# Chocolatey
choco install neovim
# Scoop
scoop install neovim
# Winget
winget install Neovim.Neovim
# Or download from GitHub releases
# https://github.com/neovim/neovim/releasesBuilding from Source
# Install dependencies (Ubuntu/Debian)
sudo apt install ninja-build gettext cmake unzip curl
# Clone repository
git clone https://github.com/neovim/neovim
cd neovim
# Checkout stable version (or use master for bleeding edge)
git checkout stable
# Build
make CMAKE_BUILD_TYPE=RelWithDebInfo
sudo make installVerify Installation
# Check Neovim version
nvim --version
# Should show something like:
# NVIM v0.9.4
# Build type: Release
# LuaJIT 2.1.1692716794Initial Configuration
Neovim Directory Structure
# Create Neovim config directory
mkdir -p ~/.config/nvim
# Create subdirectories for organization
mkdir -p ~/.config/nvim/lua
mkdir -p ~/.config/nvim/after/plugin
mkdir -p ~/.config/nvim/colorsFirst init.lua
Create ~/.config/nvim/init.lua:
-- Basic settings
vim.opt.number = true -- Show line numbers
vim.opt.relativenumber = true -- Show relative line numbers
vim.opt.mouse = 'a' -- Enable mouse support
vim.opt.ignorecase = true -- Case insensitive search
vim.opt.smartcase = true -- Unless search contains uppercase
vim.opt.hlsearch = false -- Don't highlight all search matches
vim.opt.wrap = false -- Don't wrap lines
vim.opt.breakindent = true -- Preserve indentation in wrapped text
vim.opt.tabstop = 4 -- Tabs are 4 spaces
vim.opt.shiftwidth = 4 -- Indent by 4 spaces
vim.opt.expandtab = true -- Use spaces instead of tabs
vim.opt.termguicolors = true -- True color support
-- Set leader key to space
vim.g.mapleader = ' '
vim.g.maplocalleader = ' '
-- Basic keymaps
vim.keymap.set('n', '<leader>w', '<cmd>write<cr>', {desc = 'Save'})
vim.keymap.set('n', '<leader>q', '<cmd>quit<cr>', {desc = 'Quit'})
-- Navigate between windows
vim.keymap.set('n', '<C-h>', '<C-w>h')
vim.keymap.set('n', '<C-j>', '<C-w>j')
vim.keymap.set('n', '<C-k>', '<C-w>k')
vim.keymap.set('n', '<C-l>', '<C-w>l')
print("Neovim configuration loaded!")Alternative: VimScript Configuration
Create ~/.config/nvim/init.vim:
" Basic settings
set number
set relativenumber
set mouse=a
set ignorecase
set smartcase
set nohlsearch
set nowrap
set breakindent
set tabstop=4
set shiftwidth=4
set expandtab
set termguicolors
" Set leader key
let mapleader = ' '
let maplocalleader = ' '
" Basic keymaps
nnoremap <leader>w :write<CR>
nnoremap <leader>q :quit<CR>
" Navigate between windows
nnoremap <C-h> <C-w>h
nnoremap <C-j> <C-w>j
nnoremap <C-k> <C-w>k
nnoremap <C-l> <C-w>l
echo "Neovim configuration loaded!"
Health Check
Neovim includes a health check system:
:checkhealth
This command checks:
Python provider setup
Ruby provider setup
Node.js provider setup
Clipboard integration
Terminal support
And more…
Fix any warnings or errors shown. Common issues:
# Install Python provider
pip3 install pynvim
# Install Node.js provider
npm install -g neovim
# Install Ruby provider
gem install neovimSetting Up a Plugin Manager
We’ll use lazy.nvim, the modern plugin manager for Neovim.
Installing lazy.nvim
Add to your init.lua:
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
-- Setup plugins
require("lazy").setup({
-- Your plugins will go here
{
"folke/which-key.nvim",
event = "VeryLazy",
init = function()
vim.o.timeout = true
vim.o.timeoutlen = 300
end,
opts = {}
},
-- Colorscheme
{
"folke/tokyonight.nvim",
lazy = false,
priority = 1000,
config = function()
vim.cmd([[colorscheme tokyonight]])
end,
},
})Managing Plugins
:Lazy " Open lazy.nvim UI
:Lazy sync " Install/update/clean plugins
:Lazy install " Install missing plugins
:Lazy update " Update plugins
:Lazy clean " Remove unused plugins
:Lazy health " Run health checks
Creating a Modular Configuration
For better organization, split your configuration:
File Structure: ~/.config/nvim/
├│── init.lua
├│── lua/
││ ├── config/
││ │ ├── options.lua
││ │ ├── keymaps.lua
││ │ └── autocmds.lua
││ └── plugins/
││ ├── init.lua
││ ├── telescope.lua
││ ├── treesitter.lua
││ └── lsp.lua
init.lua:
-- Load core configuration
require('config.options')
require('config.keymaps')
require('config.autocmds')
-- Bootstrap and load plugins
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git", "clone", "--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup("plugins")lua/config/options.lua:
local opt = vim.opt
-- Line numbers
opt.number = true
opt.relativenumber = true
-- Tabs and indentation
opt.tabstop = 4
opt.shiftwidth = 4
opt.expandtab = true
opt.autoindent = true
-- Search
opt.ignorecase = true
opt.smartcase = true
opt.hlsearch = false
opt.incsearch = true
-- Appearance
opt.termguicolors = true
opt.signcolumn = "yes"
opt.cursorline = true
-- Behavior
opt.mouse = "a"
opt.clipboard = "unnamedplus"
opt.undofile = true
opt.updatetime = 250
opt.timeoutlen = 300lua/config/keymaps.lua:
local keymap = vim.keymap
-- Set leader
vim.g.mapleader = " "
vim.g.maplocalleader = " "
-- Save and quit
keymap.set("n", "<leader>w", "<cmd>write<cr>", { desc = "Save file" })
keymap.set("n", "<leader>q", "<cmd>quit<cr>", { desc = "Quit" })
-- Window navigation
keymap.set("n", "<C-h>", "<C-w>h", { desc = "Go to left window" })
keymap.set("n", "<C-j>", "<C-w>j", { desc = "Go to lower window" })
keymap.set("n", "<C-k>", "<C-w>k", { desc = "Go to upper window" })
keymap.set("n", "<C-l>", "<C-w>l", { desc = "Go to right window" })
-- Better visual mode indenting
keymap.set("v", "<", "<gv")
keymap.set("v", ">", ">gv")
-- Move lines up and down
keymap.set("n", "<A-j>", ":m .+1<cr>==", { desc = "Move line down" })
keymap.set("n", "<A-k>", ":m .-2<cr>==", { desc = "Move line up" })
keymap.set("v", "<A-j>", ":m '>+1<cr>gv=gv", { desc = "Move selection down" })
keymap.set("v", "<A-k>", ":m '<-2<cr>gv=gv", { desc = "Move selection up" })1.4 First Steps: Understanding Modes
The most fundamental concept in Vim/Neovim is modal editing. Unlike traditional editors where every key press inserts a character, Vim has different modes for different tasks.
The Primary Modes
1. Normal Mode
This is the default mode when you open Vim. In Normal mode, keys are commands, not text input.
" You start here when opening Vim
" Press keys to execute commands:
j " Move down one line
k " Move up one line
dd " Delete current line
yy " Yank (copy) current line
p " Paste after cursor
u " Undo
<C-r> " Redo (Ctrl+r)
To return to Normal mode from any other mode, press
<Esc> or <C-[>
(Control+[).
2. Insert Mode
This is where you actually type text, like in a normal editor.
Enter Insert mode from Normal mode:
i " Insert before cursor
a " Append after cursor
I " Insert at beginning of line
A " Append at end of line
o " Open new line below
O " Open new line above
In Insert mode, you can type normally. Return to Normal mode with
<Esc>.
3. Visual Mode
For selecting text. There are three variants:
v " Character-wise visual mode
V " Line-wise visual mode
<C-v> " Block-wise visual mode (Ctrl+v)
Once in Visual mode:
" Navigate to expand selection
h, j, k, l " Expand in any direction
w, e, b " Expand by word
$, 0 " Expand to line end/start
" Then perform operations
d " Delete selection
y " Yank (copy) selection
c " Change selection (delete and enter insert mode)
> " Indent selection
< " Unindent selection
4. Command-Line Mode
For executing Ex commands, searches, and filters.
: " Enter command-line mode for Ex commands
/ " Search forward
? " Search backward
Examples:
:w " Write (save) file
:q " Quit
:wq " Write and quit
:e file.txt " Edit file.txt
:20 " Go to line 20
:%s/old/new/g " Replace all 'old' with 'new'
5. Terminal Mode
Neovim’s built-in terminal emulator (also available in Vim 8.1+).
:terminal " Open terminal in current window
:split | terminal " Open terminal in horizontal split
In terminal mode:
<C-\><C-n> " Return to Normal mode
i or a " Return to Terminal mode from Normal mode
6. Replace Mode
Overwrites existing text instead of inserting.
R " Enter Replace mode
<Esc> " Return to Normal mode
Mode Indicators
Vim By default, Vim shows the current mode in the bottom-left:
– INSERT –
– VISUAL –
– REPLACE – (nothing shown in Normal mode)
Neovim Similar to Vim, but can be customized more easily.
To always see the mode in your statusline:
vim.opt.showmode = true -- Default in both Vim and NeovimModal Thinking
The power of modal editing comes from composability. In Normal mode, you combine:
Operators (actions)
Motions (where to apply the action)
Text objects (what to act upon)
Operators:
d " Delete
c " Change (delete and enter insert mode)
y " Yank (copy)
> " Indent
< " Unindent
= " Auto-indent
Motions:
w " Next word
e " End of word
b " Beginning of word
$ " End of line
0 " Beginning of line
gg " Top of file
G " Bottom of file
} " Next paragraph
{ " Previous paragraph
Text Objects:
iw " Inner word
aw " A word (includes surrounding space)
i" " Inside double quotes
a" " Around double quotes (includes quotes)
it " Inside tag
at " Around tag
ip " Inner paragraph
Combining them:
dw " Delete word
d$ " Delete to end of line
d2w " Delete 2 words
di" " Delete inside quotes
da" " Delete around quotes (including quotes)
ciw " Change inner word
yap " Yank a paragraph
>ip " Indent paragraph
The Grammar of Vim
Think of Vim commands as sentences:
[count] operator [count] motion/text-object
Examples:
3dw " 3 times, delete a word
2dd " 2 times, delete a line (dd = delete line)
d3w " Delete 3 words (same as 3dw)
c2w " Change 2 words
y5j " Yank current line plus 5 lines down
Practice Exercise: Modal Workflow
Let’s walk through a common editing task:
Task: You have this text: The quick brown fox jumps over the lazy dog
You want to change “brown” to “red”:
Navigate: Use
/brown<Enter>to search for “brown”Change: Type
ciw(change inner word)Insert: Type “red”
Exit Insert: Press
<Esc>
The full sequence:
/brown<Enter>ciwred<Esc>
Another example: Delete everything inside parentheses:
Text:
print("Hello, World!")Cursor on the line:
Navigate to inside the parentheses:
f((find opening paren)Delete inside:
di"(delete inside quotes)
Or more directly: f(ldi" (find paren, move right one
char, delete inside quotes)
Or even simpler if cursor is anywhere on the line:
/print<Enter>f"di"
Mode-Specific Best Practices
Normal Mode:
Stay in Normal mode as much as possible
Use motions instead of arrow keys
Learn to compose commands
Use
.to repeat the last change
Insert Mode:
Get in, make your change, get out
Don’t use arrow keys (use
<C-o>for one Normal mode command)Use
<C-w>to delete a word backwardUse
<C-u>to delete to start of line
Visual Mode:
Use sparingly; often Normal mode commands are faster
Great for irregular selections
Perfect for block operations (
<C-v>)
Command-Line Mode:
Use
<Up>and<Down>to browse historyUse
<C-r>"to paste from default registerUse
<C-f>to open command-line window for editing
Your First Vim Session
Let’s put it all together. Open Neovim:
nvim practice.txtStep 1: Enter Insert mode and type some text:
i
This is my first Neovim file.
I am learning modal editing.
It feels strange but powerful.
<Esc>
Step 2: Navigate (in Normal mode):
gg " Go to top
j " Down one line
$ " End of line
0 " Beginning of line
w " Next word
b " Previous word
Step 3: Make some edits:
" Go to the word 'strange'
/strange<Enter>
" Change it to 'different'
ciwdifferent<Esc>
" Go to end of second line and add text
j$aIt's starting to make sense.<Esc>
" Delete the third line
jdd
Step 4: Save and quit:
:wq
Understanding Why Modes Matter
Efficiency: Your fingers stay on the home row. No reaching for arrow keys, mouse, or complex Ctrl combinations.
Precision: Normal mode commands are surgical. You can express exactly what you want to do.
Repeatability: The . command
repeats your last change. Combined with n (next search
result), you can make identical edits across a file rapidly.
Composability: Learning 10 operators and 10 motions gives you 100 combinations, not 20 separate commands to memorize.
Example: Suppose you want to delete everything from your cursor to the next occurrence of “end”:
d/end<Enter>
This combines:
d(delete operator)/end<Enter>(motion: to the next “end”)
No special “delete to search result” command needed—you just composed two concepts you already know.
Common Beginner Mistakes
1. Staying in Insert Mode Wrong approach: Enter Insert mode, use arrow keys to move, delete with Backspace. Right approach: Stay in Normal mode, use motions, enter Insert mode briefly.
2. Not Using Text Objects Wrong:
dllll (delete and then press l four times)
Right: dw (delete word)
3. Fighting the Modes Wrong: Getting frustrated that keys don’t type letters in Normal mode. Right: Embrace the modes. Normal is your home; Insert is a quick visit.
4. Ignoring the Dot Command Wrong: Manually
repeating the same edit multiple times. Right: Make the edit once,
then use . to repeat it.
5. Not Learning Motions Wrong: Using
jjjjj to move down five lines. Right: Use
5j or relative line numbers with 5j.
Next Steps
Now that you understand modes:
Practice switching between modes until it feels natural
Learn the basic motions (
h,j,k,l,w,e,b,$,0)Learn the basic operators (
d,c,y)Start combining them
Gradually add text objects to your vocabulary
In the next chapters, we’ll dive deep into each of these concepts, building your Vim vocabulary until you’re thinking in Vim.
Chapter Summary:
In this chapter, you learned:
The history of vi, Vim, and Neovim
The philosophical differences between Vim and Neovim
Key architectural and feature differences
How to install and configure both editors
The fundamental concept of modal editing
The six primary modes and when to use each
How to think compositionally in Vim
Your first practical editing session
With this foundation, you’re ready to explore the powerful features that make Vim and Neovim beloved by millions of developers worldwide.
Chapter 2: Navigation Mastery
Navigation is the foundation of Vim efficiency. While other editors rely on arrow keys and mouse clicks, Vim provides a rich vocabulary of motion commands that keep your hands on the home row and your mind in a flow state. Master navigation, and everything else in Vim becomes exponentially more powerful.
2.1 Basic Navigations
The Home Row Movement: h, j, k, l
The most fundamental navigation commands:
h " Move left (←)
j " Move down (↓)
k " Move up (↑)
l " Move right (→)
Why these keys? When Bill Joy created vi, he used an ADM-3A terminal where these keys had arrow symbols on them. More importantly, they keep your fingers on the home row.
Mnemonic:
jlooks like a down arrowkis abovej, so it goes uphis on the left, goes leftlis on the right, goes right
Using counts:
5j " Move down 5 lines
10k " Move up 10 lines
3h " Move left 3 characters
7l " Move right 7 characters
Practice exercise: Open any file and navigate
using only hjkl for 5 minutes. Resist the urge to use
arrow keys.
Word-Wise Movement
Moving by words is much faster than character-by-character:
w " Move to the beginning of the next word
W " Move to the beginning of the next WORD (whitespace-separated)
e " Move to the end of the current/next word
E " Move to the end of the current/next WORD
b " Move to the beginning of the previous word
B " Move to the beginning of the previous WORD
ge " Move to the end of the previous word
gE " Move to the end of the previous WORD
Understanding word vs WORD:
# Example text:
my_variable = get_data(user.id)
# Using 'w' (word): stops at underscores and dots
# Cursor positions: my | _ | variable | = | get | _ | data | ( | user | . | id | )
# Using 'W' (WORD): stops only at whitespace
# Cursor positions: my_variable | = | get_data(user.id)word: Delimited by non-keyword characters (punctuation, whitespace)
WORD: Delimited only by whitespace
Example with counts:
3w " Move forward 3 words
2b " Move backward 2 words
5e " Move to the end of the 5th word forward
Practical use case:
Suppose you want to delete the next 3 words:
d3w " Delete 3 words forward
Or change the next word:
cw " Change word (delete word and enter insert mode)
Line-Wise Movement
0 " Move to the first column of the line (column 0)
^ " Move to the first non-blank character of the line
$ " Move to the end of the line
g_ " Move to the last non-blank character of the line
Example:
print("Hello, World!")
# ^ $
# | |
# First non-blank End of line0 " Takes you to the very beginning (before the spaces)
^ " Takes you to 'p' (first non-blank)
$ " Takes you after the exclamation mark
g_ " Takes you to the exclamation mark (last non-blank)
Combining with operators:
d$ " Delete from cursor to end of line
d0 " Delete from cursor to beginning of line
d^ " Delete from cursor to first non-blank character
c$ " Change from cursor to end of line (same as C)
y^ " Yank from cursor to first non-blank character
Line Number Movement
gg " Go to the first line of the file
G " Go to the last line of the file
{n}G " Go to line number n (e.g., 42G goes to line 42)
:{n} " Same as above (e.g., :42 goes to line 42)
{n}gg " Alternative to {n}G
Examples:
1G " Go to line 1 (same as gg)
100G " Go to line 100
:250 " Go to line 250
With relative line numbers enabled:
-- In your init.lua
vim.opt.relativenumber = true
vim.opt.number = trueYour screen shows: 3 ” some line 2 ” another line 1 ” yet another line 42 ” current line (absolute line number) 1 ” next line 2 ” line after that 3 ” three lines down
Now you can jump precisely:
5j " Jump down 5 lines (you can see it's 5 lines away)
3k " Jump up 3 lines
Percentage Movement
{n}% " Go to n% through the file
Examples:
50% " Go to the middle of the file
25% " Go to 1/4 through the file
75% " Go to 3/4 through the file
The special meaning of %:
When your cursor is on a bracket, parenthesis, or brace:
% " Jump to the matching bracket/paren/brace
Example:
function example() {
if (condition) {
doSomething();
}
}With cursor on the opening { after
example(), pressing % takes you to the
matching } at the end of the function.
2.2 Jump Lists and Change Lists
Vim keeps track of where you’ve been and what you’ve changed. This creates a navigational history you can traverse.
Jump List
Every time you make a “jump” (movement that goes farther than one line), Vim records it.
What counts as a jump?
G,gg,{n}G(line number jumps)/or?(searching)%(matching bracket)(or)(sentence movement){or}(paragraph movement)H,M,L(screen position jumps)Opening a file
Jump list navigation:
<C-o> " Jump to older position in jump list (go back)
<C-i> " Jump to newer position in jump list (go forward)
Think of it like a web browser’s back/forward buttons.
Example workflow:
:e file1.txt " Open file1
/search_term " Search for something (creates a jump)
<C-o> " Go back to previous position
gg " Go to top (creates a jump)
<C-o> " Go back
<C-o> " Go back again
<C-i> " Go forward
View the jump list:
:jumps
Output looks like: jump line col file/text 4 100 10 ~/file1.txt 3 42 5 ~/file2.txt 2 15 0 ~/file1.txt 1 88 12 ~/file3.txt >
The > indicates your current position.
Change List
Vim also tracks every position where you made a change.
g; " Go to previous change position
g, " Go to next change position
View the change list:
:changes
Practical use case:
You’re editing a function, make a change, then scroll around the file. To quickly return to where you were editing:
g; " Jump back to last change
Jump to Definition (with tags)
If you have a tags file (generated by ctags or
LSP):
<C-]> " Jump to definition
<C-t> " Jump back from definition
g<C-]> " If multiple matches, show a list
" Preview definition without jumping
<C-w>} " Open definition in preview window
Example workflow with LSP in Neovim:
-- In your LSP configuration
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { desc = 'Go to definition' })
vim.keymap.set('n', 'gr', vim.lsp.buf.references, { desc = 'Go to references' })
vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, { desc = 'Go to implementation' })Then:
gd " Jump to definition (uses LSP)
<C-o> " Jump back
gr " See all references
2.3 Marks and Bookmarks
Marks are like bookmarks in your code. You can set a mark and instantly jump back to it.
Setting Marks
m{a-z} " Set a mark in the current file (lowercase = file-local)
m{A-Z} " Set a global mark (uppercase = across files)
Example:
ma " Set mark 'a' at current cursor position
mA " Set global mark 'A'
Jumping to Marks
'{mark} " Jump to the line of {mark}
`{mark} " Jump to the exact position (line and column) of {mark}
Examples:
'a " Jump to line of mark a
`a " Jump to exact position of mark a
'A " Jump to line of global mark A
`A " Jump to exact position of global mark A
Difference between ’ and `:
'(single quote): Jumps to the first non-blank character of the line`(backtick): Jumps to the exact character position
Special Marks
Vim automatically sets some marks:
'' " Jump to position before the latest jump
`` " Jump to exact position before the latest jump
'. " Jump to line of last change
`. " Jump to exact position of last change
'^ " Jump to position where Insert mode was last stopped
`" " Jump to position when last editing this file
`[ " Jump to beginning of last changed or yanked text
`] " Jump to end of last changed or yanked text
`< " Jump to beginning of last visual selection
`> " Jump to end of last visual selection
Practical examples:
" You make an edit, then navigate away
" To return to where you were editing:
`.
" You paste some text
" To go to the beginning of what you just pasted:
`[
" To go to the end:
`]
" To select what you just pasted:
`[v`]
Viewing All Marks
:marks " Show all marks
:marks aB " Show only marks a and B
Deleting Marks
:delmarks a " Delete mark a
:delmarks a-d " Delete marks a, b, c, d
:delmarks! " Delete all lowercase marks in current buffer
Practical Mark Usage
Use case 1: Comparing two sections of code
" At first section
ma
" Navigate to second section
/second_function<CR>
" Set another mark
mb
" Now you can jump between them
'a " First section
'b " Second section
'a " Back to first
Use case 2: Refactoring across files
" In file1.txt
mA " Set global mark A
" Open file2.txt
:e file2.txt
" Do some work...
" Jump back to file1.txt at mark A
`A
Use case 3: Quick navigation in a large file
" At the function you're working on
mf " Mark 'f' for "function"
" At important variable declaration
mv " Mark 'v' for "variable"
" At test section
mt " Mark 't' for "test"
" Now jump around easily
'f " Back to function
'v " Check variable
't " Check test
'f " Back to function
2.4 Same-Line Navigation
Character Search
f{char} " Find next occurrence of {char} on current line
F{char} " Find previous occurrence of {char} on current line
t{char} " Till next {char} (cursor stops before the character)
T{char} " Till previous {char}
; " Repeat last f, t, F, or T command
, " Repeat last f, t, F, or T command in opposite direction
Examples:
Line of text: const result = calculateTotal(price, quantity);
fc " Find next 'c' → moves to 'c' in 'const'
; " Repeat → moves to 'c' in 'calculate'
; " Repeat → moves to 'c' in 'price'
, " Reverse → moves back to 'c' in 'calculate'
fp " Find next 'p' → moves to 'p' in 'price'
2fp " Find 2nd occurrence of 'p' → moves to 'p' in 'price' (if first was earlier)
Using ‘t’ (till):
ct, " Change till comma → deletes everything up to (but not including) comma
dt) " Delete till closing paren
Line: print(“Hello, World!”)
Cursor at the start, type f" to find the quote, then
dt" to delete till the closing quote: print(““)
Combining with Operators
This is where same-line navigation becomes powerful:
df, " Delete from cursor through the next comma
dF, " Delete from cursor back through the previous comma
ct. " Change from cursor till (before) the next period
yf) " Yank from cursor through the next closing paren
Real-world example:
Changing a function argument:
function example(oldParam, anotherParam, thirdParam) {Cursor on ‘oldParam’, want to change it:
ciw " Change inner word
Or if you want to delete everything up to the comma:
df, " Delete through comma
Column Movement
{n}| " Move to column n
0 " Move to column 0 (beginning of line)
Example:
50| " Move to column 50
This is useful when working with formatted text or data files.
Inline Search
For more complex same-line searches, you can use:
/{pattern}\%{line}l
But typically f, F, t,
T are sufficient for same-line navigation.
2.5 Screen Adjust
These commands reposition the screen without moving the cursor in the file:
zt " Move current line to top of screen
zz " Move current line to center of screen (mnemonic: 'z'oom to center)
zb " Move current line to bottom of screen
Visual representation:
Before zt: 1 some line 2 another line 3 target line
(cursor here) 4 next line 5 more lines
After zt: 3 target line (cursor here) ← now at top
of screen 4 next line 5 more lines 6 … 7 …
Practical use:
You’re debugging and want to see what comes after a particular line:
/error_handler<CR> " Find the error handler
zt " Put it at top of screen to see what follows
Or you want to see context before and after:
/critical_function<CR>
zz " Center it to see surrounding code
Screen Position Jumps
H " Jump to the top (High) of the screen
M " Jump to the middle (Middle) of the screen
L " Jump to the bottom (Low) of the screen
With counts:
3H " Jump to 3rd line from top of screen
5L " Jump to 5th line from bottom of screen
Combining with operators:
dL " Delete from cursor to bottom of screen
yH " Yank from cursor to top of screen
2.6 Scroll
Smooth Scrolling
<C-e> " Scroll down one line (Extra line appears at bottom)
<C-y> " Scroll up one line (Yester line appears at top)
<C-d> " Scroll down half a screen
<C-u> " Scroll up half a screen
<C-f> " Scroll forward one full screen (Forward)
<C-b> " Scroll backward one full screen (Backward)
Mnemonic:
<C-e>: Extra line at bottom<C-y>: Yester line at top<C-d>: Down half page<C-u>: Up half page<C-f>: Forward full page<C-b>: Backward full page
Customizing scroll amount:
-- In init.lua
vim.opt.scroll = 10 -- <C-d> and <C-u> scroll 10 lines instead of half-pageSmooth Scrolling Configuration
For a more pleasant scrolling experience:
-- Enable smooth scrolling in Neovim
vim.opt.scrolloff = 8 -- Keep 8 lines visible above/below cursor
vim.opt.sidescrolloff = 8 -- Keep 8 columns visible left/right of cursorWith scrolloff=8, the cursor never gets closer than
8 lines to the edge of the screen when scrolling.
Horizontal Scrolling
When wrap is off:
zh " Scroll right (move screen to the left, revealing text on left)
zl " Scroll left (move screen to the right, revealing text on right)
zH " Scroll half a screen width right
zL " Scroll half a screen width left
-- Disable line wrapping to use horizontal scroll
vim.opt.wrap = false2.7 Paragraph and Section Movement
Paragraph Movement
{ " Jump to previous paragraph (or code block)
} " Jump to next paragraph (or code block)
A “paragraph” is text separated by blank lines.
Example:
def function_one():
print("First function")
return True
def function_two():
print("Second function")
return False
def function_three():
print("Third function")
return None{ " Jump to previous blank line (previous function)
} " Jump to next blank line (next function)
Combining with operators:
d} " Delete to end of paragraph
y{ " Yank to beginning of paragraph
c} " Change to end of paragraph
Sentence Movement
( " Jump to previous sentence
) " Jump to next sentence
A sentence ends with ., !, or
? followed by whitespace or end of line.
Example: This is the first sentence. This is the second sentence. This is the third sentence.
) " Next sentence
( " Previous sentence
Useful in text editing:
das " Delete a sentence
cis " Change inner sentence
Section Movement
[[ " Jump to previous section (or function in some languages)
]] " Jump to next section (or function in some languages)
[] " Jump to previous end of section
][ " Jump to next end of section
What constitutes a “section” depends on the file type and syntax.
In C/C++: Sections are typically defined by
{ at column 0.
In Markdown: Sections are headings.
For more precise function navigation, use:
[m " Jump to previous start of method (in some languages)
]m " Jump to next start of method
[M " Jump to previous end of method
]M " Jump to next end of method
2.8 File Navigation and Buffer Jumping
The Alternate File
<C-^> " Toggle between current and alternate file (same as :e #)
Example workflow:
:e file1.txt " Open file1
:e file2.txt " Open file2 (file1 becomes alternate)
<C-^> " Toggle back to file1
<C-^> " Toggle back to file2
This is incredibly useful for switching between header/implementation files, test files, etc.
Buffer Navigation
:ls " List all buffers
:buffer {n} " Go to buffer number n
:bnext " Go to next buffer
:bprevious " Go to previous buffer
:bfirst " Go to first buffer
:blast " Go to last buffer
:buffer {name} " Go to buffer by name (supports tab completion)
Shortcuts:
:bn " Short for :bnext
:bp " Short for :bprevious
:bf " Short for :bfirst
:bl " Short for :blast
:b# " Go to alternate buffer (same as <C-^>)
Buffer by partial name:
:b fil<Tab> " Tab completion to find buffer with 'fil' in name
Useful mappings:
-- In init.lua
vim.keymap.set('n', '[b', ':bprevious<CR>', { desc = 'Previous buffer' })
vim.keymap.set('n', ']b', ':bnext<CR>', { desc = 'Next buffer' })
vim.keymap.set('n', '<leader>bd', ':bdelete<CR>', { desc = 'Delete buffer' })
vim.keymap.set('n', '<leader>ba', ':%bd|e#|bd#<CR>', { desc = 'Delete all buffers except current' })Window Navigation
When you have split windows:
<C-w>h " Move to window on the left
<C-w>j " Move to window below
<C-w>k " Move to window above
<C-w>l " Move to window on the right
<C-w>w " Cycle through windows
<C-w>p " Go to previous window
<C-w>t " Go to top-left window
<C-w>b " Go to bottom-right window
Better mappings (removes the Ctrl-w prefix):
vim.keymap.set('n', '<C-h>', '<C-w>h')
vim.keymap.set('n', '<C-j>', '<C-w>j')
vim.keymap.set('n', '<C-k>', '<C-w>k')
vim.keymap.set('n', '<C-l>', '<C-w>l')Now you can use <C-h> directly instead of
<C-w>h.
Tab Navigation
:tabnew " Create new tab
:tabnext " Go to next tab
:tabprevious " Go to previous tab
:tabfirst " Go to first tab
:tablast " Go to last tab
:tabclose " Close current tab
gt " Go to next tab (in normal mode)
gT " Go to previous tab (in normal mode)
{n}gt " Go to tab number n
Better mappings:
vim.keymap.set('n', '<leader>tn', ':tabnew<CR>', { desc = 'New tab' })
vim.keymap.set('n', '<leader>tc', ':tabclose<CR>', { desc = 'Close tab' })
vim.keymap.set('n', '[t', ':tabprevious<CR>', { desc = 'Previous tab' })
vim.keymap.set('n', ']t', ':tabnext<CR>', { desc = 'Next tab' })2.9 Search-Based Navigation
Basic Search
/pattern " Search forward for pattern
?pattern " Search backward for pattern
n " Go to next match
N " Go to previous match (opposite direction)
* " Search forward for word under cursor
# " Search backward for word under cursor
g* " Search forward for partial word under cursor
g# " Search backward for partial word under cursor
Example:
In a Python file, cursor on user:
* " Finds next exact match of 'user'
# " Finds previous exact match of 'user'
g* " Finds 'user', 'username', 'user_id', etc.
Search Offset
You can specify where the cursor lands after a search:
/pattern/+2 " Search for pattern, land 2 lines below
/pattern/-3 " Search for pattern, land 3 lines above
/pattern/e " Search for pattern, land at end of match
/pattern/e+1 " Search for pattern, land one char after end
/pattern/b " Search for pattern, land at beginning (default)
/pattern/b+2 " Search for pattern, land 2 chars after beginning
Example:
/function/+1 " Find "function", move cursor 1 line down
This lands you inside the function body instead of on the function declaration.
Search Highlighting
:set hlsearch " Highlight all search matches
:set nohlsearch " Don't highlight search matches
:set incsearch " Show matches as you type
:nohlsearch " Temporarily turn off highlight (until next search)
Common mapping:
-- Clear search highlight with Esc in normal mode
vim.keymap.set('n', '<Esc>', ':nohlsearch<CR><Esc>', { silent = true })Search History
/{pattern} " Start a search
<Up> " Previous search from history
<Down> " Next search from history
/ " Start search, then press Up to cycle through history
View complete search history:
:history / " or :history search
Case-Sensitive Search Control
:set ignorecase " Case-insensitive search
:set smartcase " Case-sensitive only if search contains uppercase
With these settings:
/test " Matches: test, Test, TEST, TeSt
/Test " Matches: Test only (because of uppercase)
/TEST " Matches: TEST only
Force case sensitivity in a single search:
/\Cpattern " Case-sensitive search (even with ignorecase on)
/\cpattern " Case-insensitive search (even with noignorecase)
Count Matches
In Neovim/Vim 8.1+:
:%s/pattern//gn " Count occurrences without replacing
This shows: X matches on Y lines
Or use a plugin:
" With vim-searchindex plugin (shows "match X of Y")
" Or in Neovim, configure:
vim.opt.shortmess:append("S") -- Show search count in command lineNow when you search, you see: [1/5] (match 1 of
5).
2.10 Advanced Navigation Patterns
Pattern Matching in Navigation
You can use regex patterns in your navigation:
/\<word\> " Search for exact word (word boundaries)
/\d\+ " Search for one or more digits
/^function " Search for lines starting with "function"
/TODO\|FIXME " Search for TODO or FIXME
Example - Navigate to next TODO:
/\cTODO " Case-insensitive search for TODO
n " Next TODO
n " Next TODO
Navigate to Matching Pairs
% " Jump to matching parenthesis/bracket/brace
[{ " Jump to previous unmatched {
]} " Jump to next unmatched }
[( " Jump to previous unmatched (
]) " Jump to next unmatched )
Example:
function example() {
if (condition) {
doSomething();
}
return result;
}Cursor inside doSomething():
[{ " Jump to opening { of if statement
[{ " Jump to opening { of function
Method/Function Navigation
Using built-in commands:
[m " Jump to start of previous method/function
]m " Jump to start of next method/function
[M " Jump to end of previous method/function
]M " Jump to end of next method/function
Better with plugins:
With nvim-treesitter-textobjects:
require'nvim-treesitter.configs'.setup {
textobjects = {
move = {
enable = true,
goto_next_start = {
["]m"] = "@function.outer",
["]]"] = "@class.outer",
},
goto_next_end = {
["]M"] = "@function.outer",
["]["] = "@class.outer",
},
goto_previous_start = {
["[m"] = "@function.outer",
["[["] = "@class.outer",
},
goto_previous_end = {
["[M"] = "@function.outer",
["[]"] = "@class.outer",
},
},
},
}Now ]m reliably jumps to the next function in any
language Tree-sitter supports.
Navigating Diagnostics (LSP)
In Neovim with LSP:
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, { desc = 'Previous diagnostic' })
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, { desc = 'Next diagnostic' })
vim.keymap.set('n', '[e', function()
vim.diagnostic.goto_prev({ severity = vim.diagnostic.severity.ERROR })
end, { desc = 'Previous error' })
vim.keymap.set('n', ']e', function()
vim.diagnostic.goto_next({ severity = vim.diagnostic.severity.ERROR })
end, { desc = 'Next error' })Now you can jump between errors and warnings easily.
Navigating Quickfix List
:cnext " Go to next item in quickfix list
:cprevious " Go to previous item
:cfirst " Go to first item
:clast " Go to last item
:cnfile " Go to first item in next file
:cpfile " Go to first item in previous file
Shortcuts:
:cn " :cnext
:cp " :cprevious
:cf " :cfirst
:cl " :clast
Better mappings:
vim.keymap.set('n', '[q', ':cprevious<CR>', { desc = 'Previous quickfix' })
vim.keymap.set('n', ']q', ':cnext<CR>', { desc = 'Next quickfix' })
vim.keymap.set('n', '[Q', ':cfirst<CR>', { desc = 'First quickfix' })
vim.keymap.set('n', ']Q', ':clast<CR>', { desc = 'Last quickfix' })Location List (Buffer-Local Quickfix)
:lnext " Next location list item
:lprevious " Previous location list item
:lfirst " First location list item
:llast " Last location list item
Mappings:
vim.keymap.set('n', '[l', ':lprevious<CR>', { desc = 'Previous location' })
vim.keymap.set('n', ']l', ':lnext<CR>', { desc = 'Next location' })2.11 File Explorer Navigation
Netrw (Built-in File Explorer)
:Explore " Open explorer in current window
:Sexplore " Open explorer in horizontal split
:Vexplore " Open explorer in vertical split
:Texplore " Open explorer in new tab
Shortcuts:
:Ex " :Explore
:Sex " :Sexplore
:Vex " :Vexplore
Inside Netrw:
<Enter> " Open file/directory
- " Go to parent directory
D " Delete file
R " Rename file
% " Create new file
d " Create new directory
i " Change view style
s " Change sort order
Better alternative: Use a modern file explorer plugin like nvim-tree or neo-tree.
nvim-tree example:
require("nvim-tree").setup()
vim.keymap.set('n', '<leader>e', ':NvimTreeToggle<CR>', { desc = 'Toggle file explorer' })Then inside nvim-tree:
<Enter> " Open file
h " Collapse directory
l " Expand directory
a " Create file
d " Delete file
r " Rename file
y " Copy name
2.12 Fuzzy Finding Navigation
Modern Neovim workflows heavily rely on fuzzy finders like Telescope.
Telescope.nvim
require('telescope').setup{}
local builtin = require('telescope.builtin')
vim.keymap.set('n', '<leader>ff', builtin.find_files, { desc = 'Find files' })
vim.keymap.set('n', '<leader>fg', builtin.live_grep, { desc = 'Live grep' })
vim.keymap.set('n', '<leader>fb', builtin.buffers, { desc = 'Find buffers' })
vim.keymap.set('n', '<leader>fh', builtin.help_tags, { desc = 'Help tags' })
vim.keymap.set('n', '<leader>fr', builtin.oldfiles, { desc = 'Recent files' })
vim.keymap.set('n', '<leader>fc', builtin.commands, { desc = 'Commands' })
vim.keymap.set('n', '<leader>fk', builtin.keymaps, { desc = 'Keymaps' })
vim.keymap.set('n', '<leader>fs', builtin.lsp_document_symbols, { desc = 'Document symbols' })
vim.keymap.set('n', '<leader>fw', builtin.lsp_workspace_symbols, { desc = 'Workspace symbols' })Inside Telescope:
<C-n> " Next item
<C-p> " Previous item
<C-c> " Close
<CR> " Select
<C-x> " Open in horizontal split
<C-v> " Open in vertical split
<C-t> " Open in new tab
This becomes your primary navigation method for:
Finding files by name
Searching file contents
Jumping to symbols/functions
Navigating buffers
Finding help
2.13 Navigation Best Practices
1. Think in Text Objects, Not Lines
Instead of:
jjjjdw " Move down 4 lines, delete word
Think:
4jdw " More intentional
Or better, if you’re targeting a specific word:
/word<CR>daw " Search for word, delete it
2. Use Relative Line Numbers
vim.opt.relativenumber = true
vim.opt.number = true -- Shows absolute number on current lineThis lets you see exactly how many lines away things are.
3. Minimize Keystrokes
Instead of:
lllllll " Move right 7 times
Use:
7l " Move right 7 times
Or even better, if you’re targeting the end of line:
$ " End of line
Or a specific character:
fx " Find next 'x'
4. Use Search for Long Distances
Instead of:
15j " Move down 15 lines (requires counting)
Use:
/function_name<CR> " Search for what you want
Search is often faster than counting lines.
5. Combine Navigation with Editing
Don’t navigate, then delete separately:
" Don't do this:
5j " Navigate
dd " Delete
" Do this instead:
d5j " Delete down 5 lines
6. Set Up Jump-Related Mappings
-- Quick jump to beginning/end of line
vim.keymap.set({'n', 'v'}, 'H', '^', { desc = 'Start of line' })
vim.keymap.set({'n', 'v'}, 'L', '$', { desc = 'End of line' })
-- Keep cursor centered when jumping
vim.keymap.set('n', 'n', 'nzzzv', { desc = 'Next search result' })
vim.keymap.set('n', 'N', 'Nzzzv', { desc = 'Previous search result' })
vim.keymap.set('n', '<C-d>', '<C-d>zz', { desc = 'Scroll down' })
vim.keymap.set('n', '<C-u>', '<C-u>zz', { desc = 'Scroll up' })
-- Join lines but keep cursor position
vim.keymap.set('n', 'J', 'mzJ`z', { desc = 'Join lines' })7. Use Marks for Context Switching
When you’re about to navigate far away but want to come back:
ma " Set mark 'a'
/some_pattern " Navigate far away
... " Do some work
'a " Jump back
8. Learn the Quickfix Workflow
For navigating search results across files:
:vimgrep /pattern/ **/*.py " Search all Python files
:copen " Open quickfix window
Then navigate with ]q and [q.
2.14 Navigation Exercises
Exercise 1: Basic Movement
Open any code file and navigate using only these commands (no arrow keys!):
h j k l w e b 0 $ gg G
Goal: Navigate for 5 minutes without using arrow keys or mouse.
Exercise 2: Search Navigation
Open a large file
Search for a common word:
/the<CR>Navigate through all occurrences using
nandNUse
*on a word and navigate through those occurrences
Exercise 3: Mark Usage
Set mark
aat the top of a functionNavigate to the bottom of the file
Set mark
bJump between them using
'aand'bTry using
`aand`bto see the difference
Exercise 4: Text Object Navigation
Practice these patterns:
di" " Delete inside quotes
ci( " Change inside parentheses
va} " Visually select around braces
dap " Delete a paragraph
cit " Change inside HTML tag
Exercise 5: Combined Navigation
Delete from cursor to next occurrence of word “end”:
d/end<CR>
Change from cursor to the 3rd comma:
c3f,
Yank the current paragraph:
yap
2.15 Performance Tips
Lazy Redraw
When executing macros or complex operations:
vim.opt.lazyredraw = true -- Don't redraw during macrosEfficient Searching
vim.opt.ignorecase = true
vim.opt.smartcase = trueThis makes searching faster (case-insensitive) but still precise when needed.
Disable Mouse (for Faster Navigation Practice)
vim.opt.mouse = "" -- Forces you to use keyboard navigationRe-enable later:
vim.opt.mouse = "a"Chapter Summary
In this chapter, you learned:
Basic navigation with
hjkl, words, and linesJump lists and change lists for navigating your history
Marks for bookmarking positions
Same-line navigation with
f,t,;, and,Screen positioning with
zt,zz,zb,H,M,LScrolling commands for viewing more code
Paragraph and section movement with
{}and[[]]Buffer, window, and tab navigation
Search-based navigation with
/,?,*, and#Advanced patterns including LSP navigation and fuzzy finding
Best practices for efficient navigation
Practical exercises to build muscle memory
Navigation mastery is the foundation of Vim expertise. These commands become second nature with practice, and once they do, you’ll find yourself navigating code faster than you ever thought possible. In the next chapter, we’ll dive deep into editing fundamentals, where navigation combines with powerful text manipulation commands.
Chapter 3: Editing Fundamentals
Navigation gets you to the text you want to modify.
Editing transforms it. In Vim, editing is where the philosophy of
composability truly shines. You’ve already seen glimpses of
it—operators like d, c, and y
combined with motions. Now we’ll systematically explore the full
editing vocabulary that makes Vim a text-editing language.
3.1 The Editing Grammar: Operators, Motions, and Text Objects
Vim’s editing commands follow a grammatical structure:
[count] [operator] [count] [motion/text-object]
Components:
Operator: The action to perform (
dfor delete,cfor change,yfor yank)Motion: Where to apply the action (
wfor word,$for end of line)Text Object: What to act upon (
iwfor inner word,apfor a paragraph)Count: How many times to repeat (optional)
Examples:
d2w " Delete 2 words
c$ " Change to end of line
y3j " Yank current line plus 3 below
2dd " Delete 2 lines
3cw " Change 3 words
gUiw " Uppercase inner word
The Complete Operator Set
d " Delete (cut)
c " Change (delete and enter insert mode)
y " Yank (copy)
~ " Toggle case
g~ " Toggle case (takes a motion)
gu " Make lowercase
gU " Make uppercase
> " Indent right
< " Indent left
= " Auto-indent
! " Filter through external program
gw " Format text (wrap lines)
gq " Format text (hard wrap)
g? " ROT13 encode
Complete Motion Reference (Quick)
" Character-wise
h l 0 ^ $ f F t T ; , |
" Word-wise
w W e E b B ge gE
" Line-wise
j k + - _ gg G {n}G :{n}
" Paragraph/Section
{ } ( ) [[ ]] [] ][
" Search
/ ? n N * # g* g#
" Screen
H M L
" Marks
'{mark} `{mark}
Text Objects: The Inner and Around
Text objects are preceded by i (inner) or
a (around):
iw " Inner word (word only)
aw " A word (word + trailing space)
iW " Inner WORD
aW " A WORD
is " Inner sentence
as " A sentence
ip " Inner paragraph
ap " A paragraph
i" " Inner quotes (content between ")
a" " Around quotes (includes quotes)
i' " Inner single quotes
a' " Around single quotes
i` " Inner backticks
a` " Around backticks
i( " Inner parentheses (same as ib)
a( " Around parentheses (same as ab)
i[ " Inner brackets
a[ " Around brackets
i{ " Inner braces (same as iB)
a{ " Around braces (same as aB)
i< " Inner angle brackets
a< " Around angle brackets
it " Inner tag (HTML/XML)
at " Around tag
Examples:
# Text: print("Hello, World!")
# Cursor on 'H' in Hello
diw " Delete 'Hello' → print(", World!")
daw " Delete 'Hello, ' → print("World!")
di" " Delete 'Hello, World!' → print("")
da" " Delete "Hello, World!" → print()
di( " Delete entire content → print()
da( " Delete including parens → print<!-- Text: <div class="container">Content here</div> -->
<!-- Cursor anywhere in 'Content here' -->
dit " Delete 'Content here'
dat " Delete <div class="container">Content here</div>
cit " Change inner tag (delete content, enter insert mode)Combining Operators and Text Objects
This is where Vim becomes incredibly powerful:
ciw " Change inner word
dap " Delete a paragraph
yit " Yank inner HTML tag
gUaw " Uppercase a word
>i{ " Indent inner braces
=a{ " Auto-indent a block
gqap " Format a paragraph
Real-world example - Refactoring a function call:
result = calculate_total(price, quantity, tax_rate)Cursor on calculate_total:
ciw " Change function name
# Type new name
new_function<Esc>
Result:
result = new_function(price, quantity, tax_rate)Or to change the first argument:
f( " Find opening paren
w " Move to first argument
ciw " Change inner word
3.2 Basic Editing Commands
Insert and Append
i " Insert before cursor
I " Insert at beginning of line (first non-blank)
gi " Insert at last insert position
gI " Insert at column 1 (absolute beginning)
a " Append after cursor
A " Append at end of line
o " Open new line below
O " Open new line above
Examples:
print("Hello")
# ^cursor here
I " Insert at beginning → cursor before 'p'
gI " Insert at column 1 → cursor before indentationReplace
r{char} " Replace single character with {char}
R " Enter Replace mode (like Insert, but overwrites)
gr{char} " Virtual replace (respects tabs)
Examples:
# Text: Hello World
# ^cursor on H
rx " Replace H with x → xello World
3r- " Replace next 3 chars with - → ---lo World
Replace mode:
R " Enter Replace mode
# Type 'Goodbye' (overwrites existing text)
<Esc> " Exit Replace mode
Substitute
s " Substitute character (delete char and enter insert mode)
S " Substitute line (delete line and enter insert mode)
# With counts
3s " Substitute 3 characters
2S " Substitute 2 lines
Note: s is equivalent to
cl (change letter), and S is equivalent to
cc (change line).
Change
c{motion} " Change (delete and enter insert mode)
cc " Change entire line
C " Change to end of line (same as c$)
# Examples
cw " Change word
c2w " Change 2 words
c$ " Change to end of line
c^ " Change to beginning of line
ciw " Change inner word
cit " Change inner tag
ci" " Change inside quotes
Practical example - Changing a string:
const message = "Old message here";Cursor on ‘O’ in “Old”:
ci" " Change inside quotes
# Type new message
New message<Esc>
Result:
const message = "New message";3.3 Delete Commands
Basic Deletion
x " Delete character under cursor (like 'del' key)
X " Delete character before cursor (like 'backspace')
d{motion}" Delete text defined by motion
dd " Delete entire line
D " Delete to end of line (same as d$)
# With counts
3x " Delete 3 characters
2dd " Delete 2 lines
d3w " Delete 3 words
Delete with Motions
dw " Delete word
de " Delete to end of word
db " Delete to beginning of word
d2w " Delete 2 words
d$ " Delete to end of line
d^ " Delete to first non-blank character
d0 " Delete to beginning of line
dG " Delete to end of file
dgg " Delete to beginning of file
d} " Delete to end of paragraph
d{ " Delete to beginning of paragraph
Delete with Text Objects
diw " Delete inner word
daw " Delete a word (including surrounding whitespace)
dis " Delete inner sentence
das " Delete a sentence
dip " Delete inner paragraph
dap " Delete a paragraph
di" " Delete inside quotes
da" " Delete around quotes (including quotes)
di( " Delete inside parentheses
da( " Delete around parentheses
dit " Delete inner HTML tag
dat " Delete around HTML tag
di{ " Delete inside braces
da{ " Delete around braces (including braces)
Practical examples:
def calculate(price, quantity):
total = price * quantity
return totalCursor on calculate:
daw " Delete 'calculate ' (function name with space)
Cursor inside the function body:
di{ " Delete all lines inside the function
Cursor on a parameter:
daw " Delete the parameter and trailing comma/space
Delete to Character
df{char} " Delete forward to and including {char}
dt{char} " Delete forward till (before) {char}
dF{char} " Delete backward to and including {char}
dT{char} " Delete backward till (after) {char}
Example:
const result = getValue(param1, param2);Cursor at start of line:
dt( " Delete till opening paren → (param1, param2);
df) " Delete through closing paren → ;
Delete Lines Matching Pattern
:g/pattern/d " Delete all lines containing pattern
:g!/pattern/d " Delete all lines NOT containing pattern
:v/pattern/d " Same as above (v = inverse of g)
Examples:
:g/TODO/d " Delete all lines with TODO
:g/^$/d " Delete all empty lines
:g/^\s*$/d " Delete all blank lines (including whitespace-only)
:v/import/d " Delete all lines that don't contain 'import'
3.4 Copy (Yank) and Paste
Yank Commands
y{motion} " Yank text defined by motion
yy " Yank entire line (same as Y)
Y " Yank entire line
# With counts
2yy " Yank 2 lines
y3w " Yank 3 words
y$ " Yank to end of line
yG " Yank to end of file
ygg " Yank to beginning of file
Yank with Text Objects
yiw " Yank inner word
yaw " Yank a word
yip " Yank inner paragraph
yap " Yank a paragraph
yi" " Yank inside quotes
ya" " Yank around quotes
yi( " Yank inside parentheses
ya( " Yank around parentheses
yit " Yank inner HTML tag
yat " Yank around HTML tag
Paste Commands
p " Paste after cursor/below line
P " Paste before cursor/above line
gp " Paste and move cursor after pasted text
gP " Paste before and move cursor after pasted text
# With counts
3p " Paste 3 times
5P " Paste 5 times above
Line-wise vs Character-wise paste:
If you yanked a full line with yy:
p " Pastes below current line
P " Pastes above current line
If you yanked characters/words with yw:
p " Pastes after cursor
P " Pastes before cursor
Paste from Different Registers
"ayy " Yank line into register 'a'
"ap " Paste from register 'a'
"bdiw " Delete inner word into register 'b'
"bp " Paste from register 'b'
"+y " Yank to system clipboard
"+p " Paste from system clipboard
"*y " Yank to primary selection (X11)
"*p " Paste from primary selection
Practical workflow:
" Yank function definition to register 'f'
"fyy
" Navigate elsewhere
/another_function<CR>
" Paste it
"fp
Paste in Insert Mode
<C-r>{register} " Paste from register while in insert mode
<C-r>" " Paste from default register
<C-r>+ " Paste from system clipboard
<C-r>0 " Paste from yank register (not delete)
Example:
i " Enter insert mode
<C-r>" " Paste what was last yanked/deleted
<C-r>+ " Paste from system clipboard
Paste Over Selection
In visual mode:
v " Visual select
p " Paste, replacing selection
Important: After p in visual mode,
the replaced text goes to the unnamed register. To avoid this:
" Use a specific register for yanking first
"ay " Yank to register 'a'
v " Visual select other text
"ap " Paste from 'a', replacing selection
Or use the black hole register for deletion:
v " Visual select
"_dP " Delete to black hole, then paste
3.5 Undo and Redo
Basic Undo/Redo
u " Undo last change
<C-r> " Redo (undo the undo)
U " Undo all changes on current line
# With counts
3u " Undo 3 times
5<C-r> " Redo 5 times
Undo Branches
Vim maintains a tree of changes, not just a linear history. If you undo and then make a new change, Vim doesn’t discard the undone changes—they become a branch.
g- " Go to older text state
g+ " Go to newer text state
:earlier 5m " Go to text state 5 minutes ago
:later 10s " Go to text state 10 seconds later
:earlier 3 " Go back 3 changes
:later 2 " Go forward 2 changes
Time-based undo:
:earlier 1h " Text as it was 1 hour ago
:earlier 30m " 30 minutes ago
:earlier 2d " 2 days ago
:later 5m " 5 minutes later than current state
View Undo Tree
:undolist " Show undo branches
Better visualization with plugin:
-- Using undotree plugin
vim.keymap.set('n', '<leader>u', ':UndotreeToggle<CR>', { desc = 'Toggle undo tree' })Persistent Undo
Neovim can save undo history across sessions:
vim.opt.undofile = true
vim.opt.undodir = vim.fn.stdpath('cache') .. '/undo'Now you can close Neovim, reopen a file, and still undo changes from previous sessions.
3.6 Repeat and Reverse
The Dot Command
The single most powerful editing command:
. " Repeat last change
What counts as a “change”?
Any command that modified the buffer in normal mode
Everything typed in a single insert mode session
Examples:
# Change word:
ciw " Change inner word
new_name<Esc>
# Move to another word and repeat:
w " Move to next word
. " Repeat the change → changes this word to 'new_name'Delete line pattern:
dd " Delete line
j " Move down
. " Delete next line
j. " Move and delete
j. " Move and delete
Append semicolon to multiple lines:
A;<Esc> " Append semicolon and exit insert mode
j " Move down
. " Repeat (append semicolon)
j.j.j. " Continue on multiple lines
Making Changes Repeatable
Structure your edits to be dot-repeatable:
Not ideal:
/target<CR> " Search
dwi " Delete word, enter insert
new_text<Esc>
n " Next match
dwi " Delete word again
new_text<Esc> " Type same thing again
Better (repeatable):
/target<CR> " Search
cwnew_text<Esc> " Change word to 'new_text'
n " Next match
. " Repeat the change
n.n.n. " Continue on other matches
Reverse Last f, t, F, T
; " Repeat last f, t, F, or T
, " Reverse last f, t, F, or T
Example:
const result = calculateTotal(price, quantity, tax);f, " Find first comma
. " Not applicable (. doesn't work with motions)
; " Find next comma
; " Find next comma
, " Go back to previous comma
But when combined with operators:
df, " Delete to comma
; " Find next comma
. " Repeat delete to comma
Repeat Ex Commands
@: " Repeat last Ex command
@@ " Repeat last @: (after first use)
Example:
:s/old/new/g " Substitute in current line
j " Move down
@: " Repeat substitution
j@@ " Move down and repeat again
3.7 Visual Mode Editing
Entering Visual Mode
v " Character-wise visual mode
V " Line-wise visual mode
<C-v> " Block-wise visual mode (column mode)
gv " Reselect last visual selection
Visual Mode Operators
Once in visual mode, select text and apply operators:
d " Delete selection
c " Change selection
y " Yank selection
~ " Toggle case
u " Make lowercase
U " Make uppercase
> " Indent right
< " Indent left
= " Auto-indent
J " Join lines
gq " Format/wrap text
Example:
def calculate_total(price, quantity):
subtotal = price * quantity
tax = subtotal * 0.1
total = subtotal + tax
return totalSelect the function body:
vi{ " Visual select inside braces
> " Indent right
Visual Block Mode (Column Editing)
This is one of Vim’s killer features:
<C-v> " Enter visual block mode
Example - Add semicolons to multiple lines:
const name = "John"
const age = 30
const city = "NYC"Position cursor at end of first line
<C-v>to enter block mode2jto select down 2 lines (creates column)$to extend to end of each lineAto appendType
;<Esc>(semicolon appears on all lines)
Result:
const name = "John";
const age = 30;
const city = "NYC";Example - Insert at beginning of multiple lines:
print("Line 1")
print("Line 2")
print("Line 3")Position cursor at start of first line
<C-v>block mode2jto select downIto insert beforeType
#<Esc>
Result:
# print("Line 1")
# print("Line 2")
# print("Line 3")Visual Block with Ragged Lines
When lines have different lengths:
<C-v> " Block mode
3j " Down 3 lines
$ " Extend to end of each line (ragged)
A text<Esc> " Append to all lines
Visual Line Mode
V " Enter line-wise visual mode
3j " Select 3 lines down
d " Delete selected lines
Or directly:
3Vd " Select 3 lines and delete
Increment/Decrement in Visual Block
<C-v> " Block mode (select column of numbers)
g<C-a> " Increment sequentially (1, 2, 3, 4...)
g<C-x> " Decrement sequentially
<C-a> " Increment all by 1
<C-x> " Decrement all by 1
Example - Create numbered list:
Item Item Item Item
Cursor at start of first line
<C-v>3jto select columnI1. <Esc>to insert “1.”Result:
Item
Item
Item
Item
Reselect:
gvg<C-a>to increment sequentially
Result:
Item
Item
Item
Item
3.8 Search and Replace
Basic Substitution
:s/old/new/ " Replace first occurrence in current line
:s/old/new/g " Replace all occurrences in current line
:s/old/new/gc " Replace all with confirmation
:%s/old/new/g " Replace all in entire file
:%s/old/new/gc " Replace all in entire file with confirmation
:5,10s/old/new/g " Replace in lines 5-10
:'<,'>s/old/new/g " Replace in visual selection
Flags:
g- Global (all occurrences in line)c- Confirm each substitutioni- Case insensitiveI- Case sensitiven- Report number of matches without substituting
Examples:
:%s/foo/bar/g " Replace all 'foo' with 'bar'
:%s/foo/bar/gc " Same, but ask for confirmation
:%s/foo/bar/gn " Count how many 'foo' exist
:%s/\<foo\>/bar/g " Replace whole word 'foo' only
:%s/foo/bar/gi " Case-insensitive replacement
Range Substitution
:1,10s/old/new/g " Lines 1 to 10
:.,+5s/old/new/g " Current line plus next 5
:.-3,.+3s/old/new/g " 3 lines before to 3 lines after
:1,$s/old/new/g " All lines (same as %)
:.,$s/old/new/g " Current line to end
Pattern Matching in Substitution
:%s/\d\+/NUMBER/g " Replace all numbers with 'NUMBER'
:%s/^/# / " Comment out lines (add # at start)
:%s/$/ END/ " Append ' END' to all lines
:%s/\s\+$// " Remove trailing whitespace
:%s/^\_s\+$// " Remove blank lines
Capture Groups and Backreferences
:%s/\(\w\+\) \(\w\+\)/\2 \1/ " Swap first two words
:%s/"\(.*\)"/'\1'/ " Change double quotes to single
:%s/\(.*\)/[\1]/ " Wrap each line in brackets
Example:
Text: John Doe Jane Smith
Command:
:%s/\(\w\+\) \(\w\+\)/\2, \1/
Result: Doe, John Smith, Jane
Using \= for
Expression Substitution
:%s/\d\+/\=submatch(0) * 2/ " Double all numbers
:%s/.*/\=line('.')/ " Replace each line with its line number
Example:
Text: Price: 10 Price: 20 Price: 30
Command:
:%s/\d\+/\=submatch(0) * 2/g
Result: Price: 20 Price: 40 Price: 60
Special Characters in Replacement
\r " Insert newline
\n " Insert null (appears as ^@)
\t " Insert tab
& " Insert the whole matched pattern
~ " Use replacement from previous substitute
\L " Make following chars lowercase
\U " Make following chars uppercase
\0 " Insert whole matched pattern (same as &)
\1-\9 " Insert captured groups
Example - Add newline after comma:
:%s/, /,\r/g
Text: apple, banana, cherry
Result: apple, banana, cherry
Global Command
:g/pattern/command " Execute command on lines matching pattern
:g!/pattern/command " Execute on lines NOT matching
:v/pattern/command " Same as g! (inverse)
Examples:
:g/TODO/d " Delete all lines with TODO
:g/^$/d " Delete empty lines
:g/pattern/m$ " Move matching lines to end of file
:g/pattern/t$ " Copy matching lines to end of file
:g/pattern/normal @a " Execute macro 'a' on matching lines
:g/import/s/$/;/ " Add semicolon to lines with 'import'
Complex example - Sort CSS properties:
.container {
padding: 10px;
color: red;
margin: 5px;
display: flex;
}:g/{/+1,/}/-1 sort
Result (properties sorted):
.container {
color: red;
display: flex;
margin: 5px;
padding: 10px;
}3.9 Case Conversion
Toggle Case
~ " Toggle case of character under cursor
g~{motion} " Toggle case of text defined by motion
g~~ " Toggle case of entire line
g~iw " Toggle case of inner word
Make Lowercase
gu{motion} " Make lowercase
guu " Make current line lowercase
guiw " Make inner word lowercase
gue " Make to end of word lowercase
gu$ " Make to end of line lowercase
guG " Make to end of file lowercase
Make Uppercase
gU{motion} " Make uppercase
gUU " Make current line uppercase
gUiw " Make inner word uppercase
gUe " Make to end of word uppercase
gU$ " Make to end of line uppercase
Visual Mode Case Change
v " Visual select
u " Make selection lowercase
U " Make selection uppercase
~ " Toggle case of selection
Title Case
Vim doesn’t have built-in title case, but you can use substitution:
:%s/\<\(\w\)\(\w*\)\>/\u\1\L\2/g
This capitalizes the first letter of each word and lowercases the rest.
3.10 Indentation
Manual Indentation
>> " Indent current line right
<< " Indent current line left
>>{motion} " Indent motion right
>i{ " Indent inside braces
>ap " Indent a paragraph
5>> " Indent 5 lines right
Visual Mode Indentation
v " Visual select
> " Indent right
< " Indent left
" Stay in visual mode after indenting:
v
> " Indent
gv " Reselect
> " Indent again
Better: Configure indentation to stay in visual mode:
vim.keymap.set('v', '<', '<gv', { desc = 'Indent left and reselect' })
vim.keymap.set('v', '>', '>gv', { desc = 'Indent right and reselect' })Now you can repeatedly press > or
< without losing selection.
Auto-Indent
={motion} " Auto-indent motion
== " Auto-indent current line
=ap " Auto-indent paragraph
=i{ " Auto-indent inside braces
gg=G " Auto-indent entire file
With LSP formatting:
vim.keymap.set('n', '<leader>f', vim.lsp.buf.format, { desc = 'Format buffer' })Or:
:lua vim.lsp.buf.format()
Set Indentation Settings
vim.opt.shiftwidth = 4 -- Indent by 4 spaces
vim.opt.tabstop = 4 -- Tab displays as 4 spaces
vim.opt.expandtab = true -- Convert tabs to spaces
vim.opt.smartindent = true -- Auto-indent new lines
vim.opt.autoindent = true -- Copy indent from current lineReindent Pasted Text
]p " Paste and adjust indent to match current line
[p " Same, but paste above
Or after pasting normally:
p " Paste
=`] " Reindent from start to end of pasted text
3.11 Joining and Splitting Lines
Join Lines
J " Join current line with next (adds space)
gJ " Join without adding space
3J " Join next 3 lines
Examples:
name = "John"
age = 30Cursor on first line, J:
name = "John" age = 30With gJ:
name = "John"age = 30Visual Mode Join
V " Line-wise visual
3j " Select 3 more lines
J " Join all selected lines
Join with Specific Separator
No built-in command, but can use substitution:
:%s/\n/, /g " Join all lines with comma-space
Or with a range:
:5,10s/\n/, /g " Join lines 5-10 with comma
Split Lines
Vim doesn’t have a dedicated split command, but you can:
Method 1: Insert newline in normal mode
i<CR><Esc> " Split at cursor position
Method 2: Substitute
:s/, /,\r/g " Split at each comma
Method 3: Macro for splitting
:norm f,a^M " Find comma, append newline (^M = <C-v><CR>)
3.12 Increment and Decrement
Basic Increment/Decrement
<C-a> " Increment number under cursor
<C-x> " Decrement number under cursor
5<C-a> " Increment by 5
10<C-x> " Decrement by 10
Examples:
count = 10Cursor on 10, press <C-a>:
count = 11Press 5<C-a>:
count = 16Increment/Decrement in Visual Mode
<C-v> " Block mode, select column of numbers
g<C-a> " Increment sequentially (1, 2, 3, 4...)
g<C-x> " Decrement sequentially
<C-a> " Increment all by same amount
Example:
Item 0 Item 0 Item 0
Select the numbers with <C-v>, then
g<C-a>: Item 1 Item 2 Item 3
Alphabetic Increment
By default, Vim only increments numbers. To increment letters:
vim.opt.nrformats:append('alpha')Now: version_a
Cursor on a, press <C-a>:
version_b
3.13 Formatting and Wrapping
Format/Wrap Lines
gq{motion} " Format (hard wrap) text
gqq " Format current line
gqap " Format a paragraph
gqG " Format from cursor to end of file
Example with textwidth:
vim.opt.textwidth = 80Long line: This is a very long line that exceeds the textwidth setting and should be wrapped to multiple lines.
Position cursor on line, gqq: This is a very long
line that exceeds the textwidth setting and should be wrapped to
multiple lines.
Format Without Moving
gw{motion} " Format but keep cursor position
gwap " Format paragraph, stay at cursor
Join Lines Without Extra Space
gJ " Join lines without inserting space
Format Comments
With formatoptions set correctly:
vim.opt.formatoptions:append('r') -- Auto-insert comment leader on <CR>
vim.opt.formatoptions:append('o') -- Auto-insert comment leader on 'o'/'O'
vim.opt.formatoptions:append('q') -- Allow formatting comments with gq
vim.opt.formatoptions:append('n') -- Recognize numbered lists
vim.opt.formatoptions:append('j') -- Remove comment leader when joiningNow gqap in a comment block properly formats it.
External Formatting
!{motion}{program} " Filter through external program
!!{program} " Filter current line
5!!{program} " Filter 5 lines
Examples:
!apsort " Sort paragraph
!5jsort " Sort next 5 lines
:%!python -m json.tool " Format entire file as JSON
:'<,'>!column -t " Align visual selection as columns
3.14 Working with Registers
Register Types
" " Unnamed register (default)
0-9 " Numbered registers (yank and delete history)
a-z " Named registers (user-defined)
A-Z " Append to named registers
- " Small delete register (< 1 line)
+ " System clipboard
* " Primary selection (X11)
/ " Last search pattern
: " Last command
. " Last inserted text
% " Current filename
# " Alternate filename
= " Expression register
_ " Black hole register
Using Named Registers
"ayy " Yank line to register 'a'
"bdd " Delete line to register 'b'
"ap " Paste from register 'a'
"bp " Paste from register 'b'
Appending to Registers
"ayy " Yank to register 'a'
j
"Ayy " Append next line to register 'a' (uppercase A)
"ap " Paste both lines
Black Hole Register
"_d " Delete to black hole (doesn't overwrite other registers)
"_dd " Delete line to black hole
"_x " Delete character to black hole
Use case: You want to delete something without affecting your yank register:
yiw " Yank word
/other " Find other location
diw " This would overwrite your yank!
" Instead:
yiw
/other
"_diw " Delete to black hole
p " Paste your original yank
Expression Register
"= " Access expression register (in insert mode: <C-r>=)
<C-r>=5*8 " Insert result of 5*8
In normal mode:
"=5*8<CR>p " Calculate 5*8 and paste result
View Register Contents
:reg " View all registers
:reg a b " View registers a and b
:reg " " View unnamed register
:reg + " View clipboard register
Last Inserted Text
. " Repeat last change
".p " Paste last inserted text
3.15 Recording Macros (Detailed)
Basic Macro Recording
q{register} " Start recording to {register}
# Perform actions
q " Stop recording
@{register} " Play back macro
@@ " Repeat last played macro
5@{register} " Play macro 5 times
Example - Add quotes around words:
apple banana cherry
Position cursor on ‘a’ in ‘apple’
qa- start recording to register ‘a’i"<Esc>- insert quote beforeea"<Esc>- append quote afterj0- move to next line, column 0q- stop recording
Now execute:
2@a " Run on next 2 lines
Result: “apple” “banana” “cherry”
Recursive Macros
qaq " Clear register a
qa " Start recording
# Perform action
@a " Call macro recursively
q " Stop recording
@a " Execute (will run until it fails)
Example - Process until end of file:
qaq " Clear a
qa
dd " Delete line
j " Move down
@a " Recursive call
q
@a " Runs on all lines until end
Editing Macros
" View macro content:
:reg a
" Paste macro to edit:
"ap
" Edit the line
# Make changes
" Yank back to register:
"add
" Or directly:
:let @a = 'i"<Esc>ea"<Esc>j0'
Parallel Macro Execution
Run macro on multiple lines:
:5,10normal @a " Run macro 'a' on lines 5-10
:%normal @a " Run on all lines
:'<,'>normal @a " Run on visual selection
Macro Best Practices
Start from known position (e.g.,
0for beginning of line)End in a repeatable position (e.g.,
j0to move to next line start)Use motions, not counts when possible (more robust)
Test on a few lines first
Use recursive macros for unknown quantity of items
Example of robust vs fragile:
Fragile:
qa
3l " Move right 3 times (fails if line is shorter)
x
q
Robust:
qa
f, " Find comma (more reliable)
x
j0 " Next line, start
q
3.16 Advanced Text Objects (with Plugins)
Targets.vim Extended Text Objects
If you install targets.vim plugin, you get many more
text objects:
" Pair text objects work with:
da) " Delete around ) even when not inside
di) " Delete inside )
dI) " Delete inside ) excluding whitespace
dA) " Delete around ) including whitespace
" Work with: () {} [] <> `` '' ""
" Separator text objects:
di, " Delete inside commas
da, " Delete around commas (including separators)
dI, " Delete inside, skip boundaries
dA, " Delete around, including boundaries
" Work with: , . ; : + - = ~ _ * # / | \ & $
" Argument text objects:
cia " Change inner argument
daa " Delete an argument (including comma)
Example:
function example(arg1, arg2, arg3) {Cursor anywhere in arg2:
daa " Deletes ', arg2'
Tree-sitter Text Objects
With nvim-treesitter-textobjects:
require'nvim-treesitter.configs'.setup {
textobjects = {
select = {
enable = true,
keymaps = {
["af"] = "@function.outer",
["if"] = "@function.inner",
["ac"] = "@class.outer",
["ic"] = "@class.inner",
["aa"] = "@parameter.outer",
["ia"] = "@parameter.inner",
},
},
},
}Now you have:
daf " Delete a function
cif " Change inside function
vac " Visual select a class
daa " Delete a parameter/argument
These work across multiple programming languages!
3.17 Editing Best Practices
1. Prefer Text Objects Over Motions
Instead of:
dw " Delete word (leaves trailing space)
Use:
daw " Delete a word (cleaner)
Instead of:
f" " Find quote
dt" " Delete till quote
Use:
di" " Delete inside quotes
2. Make Changes Repeatable
Structure edits so . works effectively:
Example - Replace multiple words:
/old_name<CR> " Find first occurrence
cwnew_name<Esc> " Change word
n. " Next occurrence, repeat
n. " Continue
3. Use Macros for Complex Repetitions
If . isn’t enough, record a macro:
qa " Start recording
# Complex multi-step edit
q " Stop
@a " Replay
4. Minimize Mode Switching
Instead of:
A<space><Esc> " Append space, exit
A;<Esc> " Append semicolon, exit (separate action)
Do:
A ;<Esc> " Append both in one insert session
This makes . repeat both actions.
5. Use Composability
Combine operators and motions creatively:
c3w " Change 3 words
d/end<CR> " Delete to 'end'
y} " Yank to end of paragraph
gU4j " Uppercase 4 lines
6. Keep Register Hygiene
Use specific registers to avoid conflicts:
"ay " Important yank to register 'a'
# Do other work
"ap " Still have it
Use black hole register for throwaway deletes:
"_dd " Delete without polluting registers
3.18 Editing Exercises
Exercise 1: Text Object Mastery
Open a code file and practice:
1. Position cursor in a function
2. Try: dif (delete inside function)
3. Undo: u
4. Try: vif (visual select inside function)
5. Try: yaf (yank around function)
Repeat with:
- di" and da" (quotes)
- di( and da( (parentheses)
- di{ and da{ (braces)
- diw and daw (word)
- dip and dap (paragraph)
Exercise 2: Dot Command Practice
// Original text (3 lines): const oldName = value const oldName = value const oldName = value
// Goal: Change all ‘oldName’ to ‘newName’
Solution:
/oldName<CR> " Find first
ciwnewName<Esc> " Change it
n. " Next and repeat
n. " Next and repeat
Exercise 3: Visual Block Magic
// Original: Item 1 Item 1 Item 1 Item 1
// Goal: Item 1 Item 2 Item 3 Item 4
Solution:
<C-v> " Block mode
3j " Select down
$ " To end of lines
A<Backspace><Esc> " Delete the '1'
gv " Reselect
g<C-a> " Sequential increment
Exercise 4: Macro Recording
// Original (lines of words): apple banana cherry date
// Goal (wrap in array): [“apple”], [“banana”], [“cherry”], [“date”]
Solution:
qa " Start recording to 'a'
I["<Esc> " Insert '["' at beginning
A"],<Esc> " Append '"],' at end
j0 " Next line, column 0
q " Stop recording
3@a " Repeat on remaining 3 lines
Exercise 5: Search and Replace
// Original: function calculate(x, y, z) result = calculate(a, b, c) value = calculate(1, 2, 3)
// Goal: Change ‘calculate’ to ‘compute’
Solution:
:%s/\<calculate\>/compute/g
Or with confirmation:
:%s/\<calculate\>/compute/gc
Exercise 6: Complex Deletion
Delete all lines containing “TODO” and “FIXME”:
:g/TODO\|FIXME/d
Or delete all empty lines:
:g/^$/d
Exercise 7: Indentation Challenge
# Badly indented:
def example():
print("hello")
if True:
print("world")
returnFix it:
vi{ " Visual select inside function
= " Auto-indent
Or entire file:
gg=G " From top to bottom, indent
Chapter Summary
In this chapter, you learned:
Vim’s editing grammar: Operators + Motions + Text Objects forming a composable language
Complete operator set:
d,c,y,~,gu,gU,>,<,=, and moreAll text objects:
iw/aw,i"/a",i(/a(,it/at,ip/ap, etc.Insert mode commands:
i,I,a,A,o,O,giDeletion techniques:
x,dd,D,d{motion}, text object deletionYank and paste: Registers, system clipboard integration, paste modes
Undo/redo system: Linear undo, undo branches, time-based undo, persistent undo
Dot command: The most powerful editing tool for repeating changes
Visual modes: Character, line, and block-wise editing
Search and replace: Substitution, ranges, capture groups, global commands
Case conversion:
~,gu,gUwith motions and text objectsIndentation: Manual and automatic, with LSP formatting
Joining and splitting:
J,gJ, and techniques for line manipulationIncrement/decrement:
<C-a>,<C-x>, sequential incrementing in visual blockText formatting:
gq,gw, external formattersRegisters: Named, special, system clipboard, black hole
Macro recording: Basic, recursive, editing macros, parallel execution
Advanced text objects: Plugin-enhanced with targets.vim and tree-sitter
Best practices: Repeatability, composability, efficiency
You now have a complete editing vocabulary. The key to mastery is practice—these commands need to become muscle memory. In the next chapter, we’ll explore Visual Mode in depth, including advanced selection techniques and visual mode operators that weren’t covered here.
Chapter 4: Search and Replace
Search and replace is where Vim’s modal editing and powerful pattern matching converge. While we touched on basic substitution in Chapter 3, this chapter dives deep into Vim’s search capabilities, regular expressions, the substitution command, and advanced pattern-matching techniques that make complex text transformations trivial.
4.1 Search Fundamentals
Basic Search Commands
/{pattern} " Search forward for pattern
?{pattern} " Search backward for pattern
n " Repeat search in same direction
N " Repeat search in opposite direction
* " Search forward for word under cursor
# " Search backward for word under cursor
g* " Search forward for word under cursor (partial match)
g# " Search backward for word under cursor (partial match)
Examples:
/function " Search forward for "function"
n " Next occurrence
N " Previous occurrence
?return " Search backward for "return"
n " Next (backward)
N " Previous (forward)
* " Search for exact word under cursor
# " Search backward for word under cursor
Case Sensitivity
/pattern " Case sensitivity depends on 'ignorecase' setting
/pattern\c " Force case-insensitive search
/pattern\C " Force case-sensitive search
Configuration:
vim.opt.ignorecase = true -- Ignore case in searches
vim.opt.smartcase = true -- Override ignorecase if pattern has uppercaseWith smartcase:
/hello " Matches: hello, Hello, HELLO
/Hello " Matches: Hello only (uppercase present)
Search Highlighting
vim.opt.hlsearch = true -- Highlight all matches
vim.opt.incsearch = true -- Show matches as you typeClear highlighting:
:noh " Or :nohlsearch - clear search highlighting
Better: Map a key to clear:
vim.keymap.set('n', '<Esc>', '<cmd>nohlsearch<CR>', { desc = 'Clear search highlighting' })Search History
/{up-arrow} " Cycle through previous search patterns
/{down-arrow} " Cycle forward
/<C-r>/ " Insert last search pattern
q/ " Open search history window (editable)
Search history window (q/):
Edit any previous search
Press
<CR>to executePress
<C-c>to cancel
Search Offsets
Position cursor relative to match:
/pattern/ " Cursor at start of match
/pattern/e " Cursor at end of match
/pattern/+2 " 2 lines below match
/pattern/-1 " 1 line above match
/pattern/e+1 " 1 character after end of match
/pattern/e-2 " 2 characters before end of match
/pattern/b+3 " 3 characters after beginning of match
Examples:
def calculate_total(price, quantity):
return price * quantity/def/e " Cursor on 'f' (end of 'def')
/def/+1 " Cursor on line below
/price/e+2 " Cursor 2 chars after 'price' (on 'c' in 'calculate')
Search Under Cursor
* " Search for exact word under cursor (forward)
# " Search backward
g* " Search forward (partial match)
g# " Search backward (partial match)
gd " Go to local definition
gD " Go to global definition
Difference between * and
g*:
count = 10
account = 20Cursor on count, press *:
- Matches:
countonly (exact word)
Press g*:
- Matches:
countandaccount(partial)
Visual Search
Select text in visual mode and search for it:
Manual:
v " Visual select
y " Yank
/<C-r>" " Paste into search
Better with mapping:
vim.keymap.set('v', '*', 'y/\\V<C-R>=escape(@",\'/\\\')<CR><CR>',
{ desc = 'Search for visual selection' })Now:
v " Visual select
* " Search for selection
4.2 Regular Expressions in Vim
Vim uses its own regex flavor, which differs slightly from other tools.
Magic Modes
Vim has different “magic” levels affecting which characters need escaping:
\v " Very magic (most chars have special meaning)
\m " Magic (default)
\M " No magic (most chars literal)
\V " Very no magic (all chars literal except \)
Examples:
/\vword\d+ " Very magic: word followed by digits
/word\d\+ " Magic (default): same thing
/\Mword\d\+ " No magic: same, but needs escaping
/\Vword " Very no magic: literal 'word' search
Best practice: Use \v (very magic)
for complex patterns:
/\v(foo|bar) " Match 'foo' or 'bar'
/(foo\|bar) " Same in default magic mode (needs escaping)
Character Classes
. " Any character except newline
\s " Whitespace character (space, tab, newline)
\S " Non-whitespace
\d " Digit [0-9]
\D " Non-digit
\w " Word character [a-zA-Z0-9_]
\W " Non-word character
\h " Head of word character [a-zA-Z_]
\a " Alphabetic character
\l " Lowercase letter
\u " Uppercase letter
\x " Hexadecimal digit [0-9a-fA-F]
\_s " Whitespace including newline
\_^ " Start of line (in multiline pattern)
\_$ " End of line (in multiline pattern)
\_. " Any character including newline
Examples:
/\d\+ " One or more digits
/\w\+@\w\+\.\w\+ " Simple email pattern
/\s\+ " One or more whitespace chars
/\h\w* " Identifier (letter/underscore + word chars)
Quantifiers
* " 0 or more (greedy)
\+ " 1 or more (greedy)
\? " 0 or 1 (optional)
\{n} " Exactly n times
\{n,} " At least n times
\{,m} " At most m times
\{n,m} " Between n and m times
\{-} " 0 or more (non-greedy)
\{-1,} " 1 or more (non-greedy)
\{-,1} " 0 or 1 (non-greedy)
Examples:
/\d\{3} " Exactly 3 digits
/\d\{3,5} " 3 to 5 digits
/\d\{3,} " 3 or more digits
/\w\+ " 1 or more word characters
/\s\? " Optional whitespace
Greedy vs Non-greedy:
Text: <div>content</div>
/<.*> " Greedy: matches '<div>content</div>' (entire string)
/<.\{-}> " Non-greedy: matches '<div>' (first tag)
Anchors
^ " Start of line
$ " End of line
\< " Start of word
\> " End of word
\%^ " Start of file
\%$ " End of file
\%V " Inside visual selection
\%# " Cursor position
Examples:
/^import " Lines starting with 'import'
/;$ " Lines ending with semicolon
/\<def\> " Exact word 'def' (not 'define' or 'ifdef')
/\%^#!/ " Shebang at start of file
/\%$ " Match at end of file
Grouping and Alternation
\(pattern\) " Capture group (default magic)
\%(pattern\) " Non-capturing group
\| " Alternation (OR)
" With very magic (\v):
(pattern) " Capture group
%(pattern) " Non-capturing group
| " Alternation
Examples:
/\(foo\|bar\) " Match 'foo' or 'bar'
/\v(foo|bar) " Same with very magic
/\(Mr\|Ms\|Dr\)\. " Match 'Mr.', 'Ms.', or 'Dr.'
/\v(Mr|Ms|Dr)\. " Same with very magic
Backreferences
\1, \2, \3... " Reference captured groups (in pattern)
\0 " Entire match
Examples:
/\(\w\+\) \1 " Match repeated word: "the the"
/\v(\w+) \1 " Same with very magic
/\v"(.{-})" " Match quoted string (capture content)
/\v(['"])(.\{-})\1 " Match quoted string with same quote type
Example - Find duplicate words:
/\v<(\w+)\_s+\1> " Find duplicate words (even across lines)
Text: The the cat sat on the mat. This is is a test.
Both duplicate pairs will be found.
Character Ranges
[abc] " Match a, b, or c
[a-z] " Match lowercase letter
[A-Z] " Match uppercase letter
[0-9] " Match digit
[a-zA-Z0-9] " Match alphanumeric
[^abc] " Match anything except a, b, c
[^0-9] " Match non-digit
Examples:
/[aeiou] " Match vowel
/[^aeiou] " Match consonant (and other chars)
/[0-9]\{3}-[0-9]\{4} " Match phone pattern: 123-4567
/\v[A-Z][a-z]+ " Capitalized word
Lookahead and Lookbehind
\@= " Positive lookahead
\@! " Negative lookahead
\@<= " Positive lookbehind
\@<! " Negative lookbehind
Positive lookahead - Match X only if followed by Y:
/\vfoo(@=bar) " Match 'foo' only if followed by 'bar'
/foo\(bar\)\@= " Same in default magic
Text: foobar foobaz
- Matches:
fooinfoobaronly
Negative lookahead - Match X only if NOT followed by Y:
/\vfoo(@!bar) " Match 'foo' NOT followed by 'bar'
Text: foobar foobaz
- Matches:
fooinfoobazonly
Positive lookbehind - Match X only if preceded by Y:
/\v(@<=foo)bar " Match 'bar' only if preceded by 'foo'
/\(foo\)\@<=bar " Same in default magic
Negative lookbehind - Match X only if NOT preceded by Y:
/\v(@<!foo)bar " Match 'bar' NOT preceded by 'foo'
Practical example - Match numbers not in comments:
# price = 100
total = 200/\v^[^#]*\zs\d+ " Match numbers not in lines starting with #
Matches: 200 only (not 100 because that
line starts with #)
Zero-width Assertions
\zs " Start of match (ignore everything before)
\ze " End of match (ignore everything after)
Examples:
/foo\zsbar " Match 'bar' (in 'foobar'), but highlight only 'bar'
/foo\zebar " Match 'foo' (in 'foobar'), but highlight only 'foo'
Text: foobar
/foo\zsbar " Cursor on 'b' (match starts at 'b')
/foo\zebar " Cursor on 'f' (match ends before 'b')
Practical use - Extract URL path:
https://example.com/path/to/file.html
/\v\.com\/\zs.* " Match everything after '.com/'
Match highlighted: path/to/file.html
4.3 Search Configuration
Essential Search Settings
-- Case handling
vim.opt.ignorecase = true -- Ignore case by default
vim.opt.smartcase = true -- Case-sensitive if uppercase present
-- Highlighting
vim.opt.hlsearch = true -- Highlight matches
vim.opt.incsearch = true -- Incremental search (show as you type)
-- Wrapping
vim.opt.wrapscan = true -- Wrap around end of file
-- Show match count
vim.opt.shortmess:append("S") -- Don't show "search hit BOTTOM"Advanced Search Options
-- Show search count in command line
vim.opt.shortmess:remove("S") -- Shows [1/5] when searching
-- Center screen on search
vim.keymap.set('n', 'n', 'nzz', { desc = 'Next search result (centered)' })
vim.keymap.set('n', 'N', 'Nzz', { desc = 'Previous search result (centered)' })
-- Clear search with Escape
vim.keymap.set('n', '<Esc>', '<cmd>nohlsearch<CR>', { desc = 'Clear search highlight' })
-- Use ripgrep for :grep if available
if vim.fn.executable('rg') == 1 then
vim.opt.grepprg = 'rg --vimgrep --smart-case --follow'
vim.opt.grepformat = '%f:%l:%c:%m'
endSearch-Related Keymaps
-- Keep cursor centered when searching
vim.keymap.set('n', 'n', 'nzzzv', { desc = 'Next result (centered, unfold)' })
vim.keymap.set('n', 'N', 'Nzzzv', { desc = 'Previous result (centered, unfold)' })
-- Better star command (don't move on first *)
vim.keymap.set('n', '*', '*N', { desc = 'Search word under cursor (stay)' })
vim.keymap.set('n', '#', '#N', { desc = 'Search word backward (stay)' })
-- Visual mode search
vim.keymap.set('v', '*', 'y/\\V<C-R>=escape(@",\'/\\\')<CR><CR>',
{ desc = 'Search for visual selection' })4.4 The Substitute Command
Basic Syntax
:[range]s/{pattern}/{replacement}/{flags}
Components:
[range]: Lines to operate on (optional, defaults to current line){pattern}: What to search for (regex){replacement}: What to replace with{flags}: Modifiers (g, c, i, etc.)
Common Ranges
:s/old/new/ " Current line only
:%s/old/new/ " Entire file
:5,10s/old/new/ " Lines 5 to 10
:'<,'>s/old/new/ " Visual selection
:.,$s/old/new/ " Current line to end of file
:.,+5s/old/new/ " Current line plus 5 lines
:.-3,.+3s/old/new/ " 3 lines before to 3 after current
:1,$s/old/new/ " Line 1 to last line (same as %)
Mark-based ranges:
:'a,'bs/old/new/ " From mark a to mark b
:'{,'}s/old/new/ " Last visual selection (same as '<,'>)
Substitute Flags
g " Global (all occurrences in line)
c " Confirm each substitution
i " Case insensitive
I " Case sensitive
n " Report count without substituting
e " No error if pattern not found
& " Reuse flags from previous substitute
Examples:
:%s/foo/bar/g " Replace all 'foo' with 'bar'
:%s/foo/bar/gc " Same, but ask for confirmation
:%s/foo/bar/gi " Case-insensitive replacement
:%s/foo/bar/gn " Count matches without replacing
:%s/foo/bar/ge " No error if no matches found
Confirmation prompts: When using c
flag: y - Yes, substitute this match n - No, skip this match a -
All, substitute all remaining matches q - Quit, stop substituting l
- Last, substitute this match and quit ^E - Scroll up ^Y - Scroll
down
Special Characters in Replacement
\r " Insert newline
\n " Insert null byte (appears as ^@)
\t " Insert tab
& " Insert entire matched pattern
~ " Use replacement from previous substitute
\L " Make following characters lowercase
\U " Make following characters uppercase
\E " End \L or \U
\l " Make next character lowercase
\u " Make next character uppercase
\0 " Insert entire match (same as &)
\1, \2, etc. " Insert captured groups
Examples:
:%s/foo/& bar/ " Replace 'foo' with 'foo bar'
:%s/\(.*\)/[\1]/ " Wrap each line in brackets
:%s/\w\+/\u&/g " Capitalize first letter of each word
:%s/\(.\)/\U\1/g " Uppercase entire file
:%s/\(\w\)/\L\1/g " Lowercase first letter of each word
Replace with Newline
:%s/, /,\r/g " Replace comma-space with comma-newline
Example:
Text: apple, banana, cherry
After :s/, /,\r/g: apple, banana, cherry
Using Captured Groups
:\%s/\(\w\+\) \(\w\+\)/\2, \1/ " Swap first two words
Example:
Text: John Doe Jane Smith
After :%s/\(\w\+\) \(\w\+\)/\2, \1/: Doe, John
Smith, Jane
Multiple Captures
:%s/\v(\d+)-(\d+)-(\d+)/\3\/\2\/\1/ " Date format: YYYY-MM-DD → DD/MM/YYYY
Example:
Text: 2024-10-19
After substitution: 19/10/2024
Expression Register in Replacement
Use \= to evaluate expressions:
:%s/\d\+/\=submatch(0) * 2/ " Double all numbers
:%s/$/\=line('.')/ " Append line number to each line
:%s/\d\+/\=printf('%03d', submatch(0))/ " Zero-pad numbers to 3 digits
Examples:
Text: Price: 10 Price: 25 Price: 100
After :%s/\d\+/\=submatch(0) * 2/g: Price: 20 Price:
50 Price: 200
Functions available in expression:
submatch(0)- entire matchsubmatch(1)- first capture groupline('.')- current line numberstrlen()- string lengthstrftime()- date/time formattingAny Vimscript/Lua function
Conditional Replacement
:%s/foo/\=submatch(0) == 'FOO' ? 'bar' : 'baz'/g
Replace ‘FOO’ with ‘bar’, other matches with ‘baz’.
Case-Aware Replacement
:%s/old/new/g " Literal replacement
:%s/old/new/gi " Case-insensitive search, literal replace
Smart case replacement (preserve original case):
:%s/\<old\>/new/gi " Case-insensitive, but...
This doesn’t preserve case. For that, use a plugin or expression:
:%s/\c\<old\>/\=submatch(0) =~ '\u' ? 'New' : 'new'/g
This checks if match starts with uppercase, and adjusts replacement.
4.5 Global Command
The global command executes commands on lines matching a pattern.
Basic Syntax
:[range]g/{pattern}/{command}
:[range]g!/{pattern}/{command} " Inverse (lines NOT matching)
:[range]v/{pattern}/{command} " Same as g! (v = inverse)
Common Global Commands
:g/pattern/d " Delete all lines matching pattern
:g!/pattern/d " Delete lines NOT matching (keep matching)
:v/pattern/d " Same as g! (keep matching lines)
:g/pattern/p " Print matching lines
:g/pattern/m$ " Move matching lines to end
:g/pattern/t$ " Copy matching lines to end
:g/pattern/normal @a " Execute macro 'a' on matching lines
Examples:
:g/TODO/d " Delete all TODO lines
:g/^$/d " Delete empty lines
:g/^\s*$/d " Delete blank lines (including whitespace-only)
:v/import/d " Delete lines that don't contain 'import'
:g/debug/normal gcc " Comment out lines with 'debug' (with commentary.vim)
Multiple Commands
:g/pattern/cmd1 | cmd2 | cmd3
Example:
:g/function/normal A {<CR><Esc> " Append ' {' and newline to function declarations
Global with Ranges
:g/class/+1,/^}/d " Delete content of all classes
:g/TODO/.+1d " Delete line after each TODO
:g/START/,/END/s/foo/bar/g " Replace in blocks between START and END
Example - Delete function bodies:
def func1():
pass
def func2():
pass:g/^def/+1,/^$/d " Delete from line after 'def' to next empty line
Inverse Global
:v/pattern/d " Keep only matching lines (delete non-matching)
:v/^#/d " Delete lines not starting with #
:g!/import/d " Same as above (using g!)
Global with Substitute
:g/class/s/def/function/g " In lines with 'class', replace 'def' with 'function'
:v/^#/.s/^/# / " Comment non-comment lines
Collect Matching Lines
Copy to register:
qaq " Clear register a
:g/pattern/y A " Append matching lines to register a
Copy to end of file:
:g/pattern/t$ " Copy matching lines to end
Move to new buffer:
:g/pattern/d A " Delete matching lines, append to register a
:new " New buffer
"aP " Paste collected lines
Global Across Multiple Files
:args *.js " Load all .js files
:argdo %s/var/let/ge | update " Replace in all files and save
Or with buffers:
:bufdo %s/old/new/ge | w " Replace in all buffers and save
Negated Patterns
:g!/import/d " Delete lines without 'import'
:v/export/d " Delete lines without 'export' (same as above)
:g!/^\s*#/d " Delete lines that aren't comments
4.6 Advanced Search Techniques
Multi-line Search
/foo\_sbar " Match 'foo' and 'bar' with any whitespace between (including newline)
/foo\_.bar " Match 'foo' and 'bar' with any characters between (including newline)
/foo\(\n.*\)*bar " Match 'foo' and 'bar' on different lines
Example:
Text:
def calculate(
price,
quantity
):/def\_s*calculate\_s*\( " Find function definition across lines
Search in Folds
vim.opt.foldopen:append('search') -- Automatically open folds when searchingOr manually:
zn " Disable folding temporarily
/pattern " Search
zN " Restore folding
Search in Visual Selection
\%V " Inside visual selection atom
First visually select, then:
:'<,'>s/\%Vpattern/replacement/g " Only in selection
Or use the visual selection range:
v " Visual select
:'<,'>s/old/new/g " Substitute only in selection
Search History Management
:history / " Show search history
q/ " Open search history window (editable)
/<C-r>/ " Insert last search pattern
/<Up> " Navigate search history
Clear search history:
:call histdel('search') " Clear all search history
:call histdel('search', -1) " Remove last entry
Search with Very Magic
Always use very magic for complex patterns:
/\v(foo|bar)\d+ " Very magic: easier syntax
Configure default:
-- Make search use very magic by default
vim.keymap.set('n', '/', '/\\v', { desc = 'Search (very magic)' })
vim.keymap.set('n', '?', '?\\v', { desc = 'Backward search (very magic)' })Now:
/(foo|bar)\d+ " No need for \v prefix
Search with Offset and Command
/pattern/+2 " Jump 2 lines below match
/pattern/e " Jump to end of match
Combine with command:
/pattern/+1|d " Find pattern, go 1 line down, delete it
4.7 Search and Replace Patterns Cookbook
Remove Trailing Whitespace
:%s/\s\+$//e " Remove trailing whitespace from all lines
Remove All Empty Lines
:g/^$/d " Using global command
:%s/\n\n/\r/g " Or using substitute (collapse double newlines)
Remove Blank Lines (Including Whitespace)
:g/^\s*$/d " Delete lines with only whitespace
Swap Two Words
:%s/\v(\w+)(\s+)(\w+)/\3\2\1/ " Swap word1 and word2
Add Line Numbers
:%s/^/\=line('.') . '. '/ " Prepend line number
Convert Tabs to Spaces
:%s/\t/ /g " Replace tabs with 4 spaces
Or use retab:
:set expandtab
:retab " Convert all tabs based on tabstop setting
Convert Spaces to Tabs
:set noexpandtab
:retab! " Convert spaces to tabs
Remove Duplicate Lines
:sort u " Sort and remove duplicates
Or without sorting:
:g/^\(.*\)$\n\1$/d " Remove consecutive duplicates
Increment All Numbers
:%s/\d\+/\=submatch(0) + 1/g " Increment all numbers by 1
Zero-Pad Numbers
:%s/\d\+/\=printf('%03d', submatch(0))/g " Pad to 3 digits: 1 → 001
Extract URLs
:g/https\?:\/\//t$ " Copy all lines with URLs to end of file
Comment/Uncomment Lines
:g/pattern/s/^/# / " Add # comment to matching lines
:g/pattern/s/^# // " Remove # comment
:'<,'>s/^/# / " Comment visual selection
:'<,'>s/^# // " Uncomment visual selection
Convert CamelCase to snake_case
:%s/\v([a-z])([A-Z])/\1_\l\2/g " camelCase → camel_case
Example:
myVariableName anotherExample
After: my_variable_name another_example
Convert snake_case to CamelCase
:%s/_\(\w\)/\u\1/g " snake_case → snakeCase
:%s/\v_(.)/\u\1/g " Same with very magic
Example:
my_variable_name another_example
After: myVariableName anotherExample
Extract Email Addresses
:g/\v\w+@\w+\.\w+/t$ " Copy lines with emails to end
Remove HTML Tags
:%s/<[^>]*>//g " Remove all HTML tags
:%s/<\_.\{-}>//g " Remove tags across lines (non-greedy)
Wrap Long Lines
:g/./ normal gqq " Wrap all non-empty lines
:%normal gqq " Wrap all lines
Or set textwidth:
vim.opt.textwidth = 80Then:
gqG " Wrap from cursor to end
Add Semicolons to Lines
:g/^[^#]/s/$/;/ " Add ; to non-comment lines
:v/;$/normal A; " Add ; to lines without it
Replace Smart Quotes
:%s/[""]/"/g " Replace smart quotes with straight quotes
:%s/['']/'/g " Replace smart single quotes
Convert Windows Line Endings to Unix
:%s/\r$// " Remove carriage returns
:set ff=unix " Set file format to Unix
:w " Save
Match Whole Words Only
:%s/\<word\>/replacement/g " Match 'word' but not 'words' or 'password'
Case-Insensitive Whole Word Match
:%s/\c\<word\>/replacement/g " Case-insensitive whole word
4.8 Working with Multiple Files
Search in Multiple Files (Vimgrep)
:vim /pattern/ **/*.js " Search in all .js files recursively
:vim /pattern/ % " Search in current file
:vim /pattern/ `find . -name '*.py'` " Search in result of shell command
Navigate results:
:cn " Next match
:cp " Previous match
:copen " Open quickfix window
:cclose " Close quickfix window
:cfirst " First match
:clast " Last match
Quickfix list:
:clist " List all matches
:cc 5 " Jump to 5th match
Search with External Tools
Using ripgrep:
:grep pattern **/*.js " If grepprg is set to ripgrep
:grep! pattern **/*.js " Don't jump to first match
Configuration:
if vim.fn.executable('rg') == 1 then
vim.opt.grepprg = 'rg --vimgrep --smart-case --follow'
vim.opt.grepformat = '%f:%l:%c:%m'
endReplace in Multiple Files
:args **/*.js " Load all .js files
:argdo %s/old/new/ge | update " Replace and save each
Explanation:
args **/*.js- Load files into argument listargdo- Execute command on each file in args%s/old/new/ge- Substitute (e flag: no error if not found)|- Command separatorupdate- Save only if modified
With confirmation:
:argdo %s/old/new/gce | update " Confirm each replacement
Search and Replace with Quickfix
:vim /old/ **/*.js " Find all occurrences
:cdo s/old/new/gc | update " Replace in each file (with confirmation)
Difference between cdo and
cfdo:
cdo- Execute on each quickfix entrycfdo- Execute once per file in quickfix
:cfdo %s/old/new/ge | update " Replace in each file (once per file)
Populate Quickfix from Search
:vimgrep /{pattern}/ % " Search in current file, populate quickfix
Or use location list:
:lvim /pattern/ % " Search, populate location list
:lopen " Open location list
4.9 Search and Replace Best Practices
1. Test Patterns First
Before replacing:
/pattern " Test your pattern
:%s/pattern/replacement/gn " Count matches without replacing
2. Use Confirmation for Important Changes
:%s/old/new/gc " Confirm each change
3. Save Before Mass Replace
:w " Save first
:%s/old/new/g " Replace
u " Undo if needed
Or use undo branches:
:%s/old/new/g
:earlier 1f " Go back to before last file write
4. Use Very Magic for Complex Patterns
/\v(foo|bar)\d+ " Clearer than /\(foo\|bar\)\d\+
5. Capture Groups for Rearranging
:%s/\v(\w+) (\w+)/\2, \1/ " Swap words
6. Check Range Before Substituting
:5,10s/old/new/g " Only lines 5-10
:'<,'>s/old/new/g " Visual selection
7. Use Global Command for Line Operations
:g/pattern/d " Delete matching lines
:g/pattern/normal @a " Execute macro on matches
8. Preserve Original in Register
let @a = @/ " Save search pattern to register a
:%s/old/new/g
let @/ = @a " Restore search pattern
9. Use
:help pattern for Reference
:help pattern " Complete regex reference
:help sub-replace-expression " Expression replacement
:help :substitute " Substitute command details
10. Combine Search with Marks
/pattern<CR> " Find pattern
ma " Mark position
# Navigate elsewhere
`a " Return to mark
4.10 Search Integration with Plugins
Telescope (Fuzzy Finder)
require('telescope.builtin').live_grep() -- Search in files
require('telescope.builtin').grep_string() -- Search word under cursor
require('telescope.builtin').search_history() -- Search historyKeymaps:
vim.keymap.set('n', '<leader>fg', ':Telescope live_grep<CR>',
{ desc = 'Live grep' })
vim.keymap.set('n', '<leader>fw', ':Telescope grep_string<CR>',
{ desc = 'Grep word under cursor' })
vim.keymap.set('n', '<leader>fo', ':Telescope oldfiles<CR>',
{ desc = 'Recent files' })Hop / Flash (Quick Navigation)
require('hop').hint_patterns({ current_line_only = true }) -- Search on line
require('hop').hint_patterns() -- Search in bufferKeymaps:
vim.keymap.set('n', 's', ':HopPattern<CR>', { desc = 'Hop to pattern' })
vim.keymap.set('n', 'S', ':HopChar2<CR>', { desc = 'Hop to 2 chars' })nvim-hlslens (Enhanced Search)
Shows search count and positions in virtual text:
require('hlslens').setup({
calm_down = true,
nearest_only = true,
})
vim.keymap.set('n', 'n', [[<Cmd>execute('normal! ' . v:count1 . 'n')<CR><Cmd>lua require('hlslens').start()<CR>]])
vim.keymap.set('n', 'N', [[<Cmd>execute('normal! ' . v:count1 . 'N')<CR><Cmd>lua require('hlslens').start()<CR>]])Searchbox.nvim (Better Search UI)
require('searchbox').setup({
popup = {
border = { style = 'rounded' },
},
})
vim.keymap.set('n', '/', ':SearchBoxIncSearch<CR>', { desc = 'Incremental search' })
vim.keymap.set('n', '?', ':SearchBoxIncSearch reverse=true<CR>', { desc = 'Reverse search' })4.11 Practical Search and Replace Scenarios
Scenario 1: Refactoring Function Names
Task: Rename calculateTotal to
computeTotal across multiple files.
:args **/*.js " Load all JS files
:argdo %s/\<calculateTotal\>/computeTotal/ge | update
Scenario 2: Update Import Paths
Task: Change import from '../utils'
to import from '@/utils'.
:args src/**/*.js
:argdo %s/from ['"]\.\.\/utils['"]/from '@\/utils'/ge | update
Scenario 3: Add Type Annotations
Task: Add TypeScript type to function parameters.
Before:
function process(data) {After:
function process(data: any) {Command:
:%s/\vfunction (\w+)\((\w+)\)/function \1(\2: any)/g
Scenario 4: Format JSON
Task: Pretty-print JSON data.
:%!python -m json.tool " Use external formatter
Or with jq:
:%!jq . " Format with jq
Scenario 5: Extract Configuration Values
Task: Find all hardcoded API URLs.
:vimgrep /https:\/\/api\./ **/*.js
:copen " Open quickfix to review all
Scenario 6: Batch Comment
Task: Comment out all console.log statements.
:g/console\.log/s/^/\/\/ / " Add // comment
Or with visual block:
/console\.log<CR> " Find first
<C-v> " Block mode
# Select multiple lines
I// <Esc> " Comment
Scenario 7: Clean Up Logs
Task: Remove all debug print statements.
:g/print.*debug/d " Delete debug prints
:g/console\.debug/d " Delete console.debug
Scenario 8: Normalize Whitespace
Task: Ensure consistent spacing around operators.
:%s/\s*=\s*/ = /g " Space around =
:%s/\s*+\s*/ + /g " Space around +
:%s/\s*,\s*/, /g " Space after comma
Scenario 9: Extract TODO Comments
Task: Collect all TODO comments to end of file.
:g/TODO/t$ " Copy to end
Or to a new buffer:
qaq " Clear register a
:g/TODO/y A " Append to register a
:new " New buffer
"aP " Paste
Scenario 10: Version Number Update
Task: Increment version numbers.
Before:
"version": "1.2.3"After:
"version": "1.2.4"Command:
:g/version/s/\d\+$/\=submatch(0) + 1/
4.12 Search and Replace Exercises
Exercise 1: Basic Pattern Matching
Text: Contact: john@example.com Email: jane@test.org Reach us: support@company.net
Task: Extract all email addresses.
Solution:
:g/\v\w+@\w+\.\w+/t$
Exercise 2: Swap Date Format
Text: 2024-10-19 2024-11-25 2024-12-31
Task: Convert to DD/MM/YYYY.
Solution:
:%s/\v(\d{4})-(\d{2})-(\d{2})/\3\/\2\/\1/
Exercise 3: Remove Code Comments
Text:
x = 10 # This is a comment
y = 20 # Another comment
z = x + yTask: Remove inline comments.
Solution:
:%s/\s*#.*$//
Exercise 4: Capitalize First Letter
Text: hello world welcome to vim happy coding
Task: Capitalize first letter of each line.
Solution:
:%s/\v^(\w)/\u\1/
Exercise 5: Create Numbered List
Text: apple banana cherry date
Task: Convert to numbered list.
Solution:
:%s/^/\=line('.') . '. '/
Result:
apple
banana
cherry
date
Exercise 6: Complex Refactoring
Text:
var name = "John";
var age = 30;
var city = "NYC";Task: Convert var to
const and add type annotations.
Solution:
:%s/\vvar (\w+) = (.+);/const \1: string = \2;/
For numbers, you’d need multiple passes or more complex pattern.
Chapter Summary
In this chapter, you mastered:
Search fundamentals:
/,?,*,#,n,N, search offsetsRegular expressions: Magic modes, character classes, quantifiers, anchors
Advanced regex: Grouping, backreferences, lookahead/lookbehind,
\zs,\zeSearch configuration: Case sensitivity, highlighting, incremental search
Substitute command: Full syntax, ranges, flags, special replacements
Captured groups: Using
\1,\2for rearranging textExpression replacement:
\=for calculations and transformationsGlobal command:
:g,:vfor line-based operationsMulti-line search:
\_s,\_.for patterns across linesSearch history:
q/, command history, clearing highlightsVery magic mode:
\vfor cleaner complex patternsPattern cookbook: Common search/replace patterns for real tasks
Multiple files:
vimgrep,grep,args,argdo,cdo,cfdoQuickfix integration: Navigate search results across files
Best practices: Testing patterns, using confirmation, saving state
Plugin integration: Telescope, Hop, hlslens for enhanced search
Real-world scenarios: Refactoring, formatting, extracting data
The combination of Vim’s regex engine, substitute command, and global command creates an incredibly powerful text transformation system. With practice, you’ll find yourself solving complex text manipulation tasks in seconds that would take minutes in other editors.
In the next chapter, we’ll explore Command-Line Mode in depth, covering Ex commands, command-line editing, command ranges, and building custom commands to automate your workflow.
Chapter 5: Command-Line Mode and Ex Commands
Command-Line Mode is Vim’s powerhouse for executing complex operations, automating workflows, and manipulating text across entire files or projects. While Normal mode handles interactive editing, Command-Line mode excels at batch operations, file management, and advanced text transformations. This chapter explores Ex commands, command-line editing, ranges, and building custom commands.
5.1 Entering and Exiting Command-Line Mode
Entry Methods
: " Enter command-line mode
/ " Enter search forward
? " Enter search backward
! " Enter filter (with motion)
From Insert mode:
<C-o>: " Execute one command, return to Insert mode
From Visual mode:
: " Automatically adds :'<,'> (visual range)
Exit Methods
<CR> " Execute command
<Esc> " Cancel and return to Normal mode
<C-c> " Cancel (same as Esc)
<C-[> " Cancel (same as Esc)
Command-Line Window
Open an editable command history window:
q: " Open command-line window (from Normal mode)
<C-f> " Open from Command-line mode
q/ " Open search history window
q? " Open backward search history window
Usage:
Edit commands like regular text
j/kto navigate history<CR>to execute command under cursor<C-c>to close without executing
Configuration:
vim.opt.cmdwinheight = 10 -- Height of command window5.2 Command-Line Editing
Cursor Movement
<C-b> " Beginning of line (or <Home>)
<C-e> " End of line (or <End>)
<Left>/<Right> " Move one character
<C-Left>/<C-Right> " Move one word
Editing Operations
<C-u> " Delete to beginning of line
<C-w> " Delete word before cursor
<C-h> " Delete character before cursor (or <BS>)
<Del> " Delete character under cursor
Examples:
:echo "hello world"
" Press <C-u> → clears entire line
" Press <C-w> → deletes "world"
Insert from Registers
<C-r>{register} " Insert contents of register
<C-r><C-w> " Insert word under cursor
<C-r><C-a> " Insert WORD under cursor (includes punctuation)
<C-r><C-f> " Insert filename under cursor
<C-r><C-l> " Insert line under cursor
Common registers:
<C-r>" " Insert unnamed register (last yank/delete)
<C-r>+ " Insert system clipboard
<C-r>/ " Insert last search pattern
<C-r>: " Insert last command
<C-r>% " Insert current filename
<C-r># " Insert alternate filename
Examples:
" Yank a word in Normal mode
yiw
" In Command-line mode:
:echo "<C-r>"" " Pastes yanked word
" With cursor on a filename:
:e <C-r><C-f> " Opens that file
Command-Line History
<Up>/<Down> " Navigate command history
<C-p>/<C-n> " Navigate history (Vim style)
<PageUp>/<PageDown> " Scroll history
<S-Up>/<S-Down> " Navigate filtered history (matching typed prefix)
Smart history navigation:
:set " Type 'set' then <Up> to cycle only 'set' commands
Command-Line Completion
<Tab> " Complete command/file/option
<C-d> " List all completions
<C-a> " Insert all completions
<S-Tab> " Complete backward
Completion modes:
vim.opt.wildmenu = true -- Enhanced command-line completion
vim.opt.wildmode = 'longest:full,full' -- Completion behavior
vim.opt.wildignore = '*.o,*.pyc,*.class' -- Ignore patternsExamples:
:colorsche<Tab> " Completes to :colorscheme
:e ~/.con<Tab> " Completes filename
:set number<C-d> " Lists all options starting with 'number'
Better completion with settings:
vim.opt.wildmenu = true
vim.opt.wildmode = 'longest:full,full'
vim.opt.wildoptions = 'pum' -- Use popup menu for completion (Neovim)Expression Register in Command-Line
<C-r>= " Insert result of expression
Examples:
:echo <C-r>=2+2<CR> " Inserts '4'
:e file_<C-r>=strftime("%Y%m%d")<CR>.txt " file_20241019.txt
Advanced usage:
" In Lua (Neovim):
:lua print(<C-r>=vim.fn.expand('%:t')<CR>) " Print current filename
5.3 Command Ranges
Ranges specify which lines a command affects.
Basic Range Syntax
:[range]command
:5,10command " Lines 5 to 10
:%command " All lines (entire file)
:.command " Current line (default for most commands)
:$command " Last line
Range Specifiers
. " Current line
$ " Last line
% " All lines (equivalent to 1,$)
'm " Line with mark m
'< " Start of visual selection
'> " End of visual selection
/pattern/ " Next line matching pattern
?pattern? " Previous line matching pattern
\& " Line with last substitute match
Range Arithmetic
.+5 " 5 lines after current
.-3 " 3 lines before current
$-10 " 10 lines before end
/pattern/+2 " 2 lines after pattern match
Examples:
:.-5,.+5s/old/new/g " Replace in 11 lines (5 before, current, 5 after)
:/foo/,/bar/d " Delete from 'foo' to 'bar'
:.,$y " Yank from current line to end
:1,10t$ " Copy lines 1-10 to end of file
Common Range Patterns
:% " Entire file (most common)
:'<,'> " Visual selection (automatically added)
:.,.+5 " Current line plus next 5
:1,$ " First to last (same as %)
:.,/pattern/ " Current line to next pattern match
:/start/,/end/ " Between two patterns
Range Offset Examples
:10,20 " Lines 10 through 20
:10,+5 " Line 10 plus next 5 lines (10-15)
:-5,+5 " 5 before current to 5 after current
:/TODO/,/DONE/ " From line with TODO to line with DONE
:'a,'b " From mark a to mark b
Using Patterns as Ranges
:/function/,/end/delete " Delete from 'function' to 'end'
:/class/+1,/}/s/^/ / " Indent inside class definition
:g/pattern/,/^$/delete " Delete each pattern block (pattern to empty line)
Complex example:
# START
def function():
pass
# END
# START
def another():
pass
# END:g/# START/+1,/# END/-1delete " Delete function bodies between markers
Visual Selection Range
When you press : in Visual mode:
:'<,'> " Automatically inserted
This represents the selected lines. Common usage:
:'<,'>s/old/new/g " Replace in selection
:'<,'>!sort " Sort selected lines
:'<,'>w partial.txt " Write selection to file
:'<,'>normal @a " Execute macro on each line
5.4 Essential Ex Commands
File Operations
:e {file} " Edit file
:e! " Reload current file (discard changes)
:e # " Edit alternate file
:e %:h/{file} " Edit file in same directory
:enew " New empty buffer
:find {file} " Find and edit file in 'path'
:w " Write (save) file
:w {file} " Write to specific file
:w! " Force write
:w >> {file} " Append to file
:sav {file} " Save as (and edit new file)
:up[date] " Write only if modified
:wa " Write all modified buffers
:wq " Write and quit
:x " Write if modified, then quit (same as :wq)
:q " Quit
:q! " Quit without saving
:qa " Quit all windows
:qa! " Force quit all, discard changes
Examples:
:e ~/.config/nvim/init.lua " Edit Neovim config
:w " Save
:w backup.lua " Save copy as backup.lua
:e # " Return to previous file
:sav new_name.lua " Save as and switch to new file
:up " Save only if changed
:x " Save and exit
Buffer Commands
:ls " List buffers
:buffers " Same as :ls
:files " Same as :ls
:b {n} " Switch to buffer n
:b {name} " Switch to buffer by name (partial match)
:bn " Next buffer
:bp " Previous buffer
:bf " First buffer
:bl " Last buffer
:b# " Alternate buffer
:bd " Delete current buffer
:bd {n} " Delete buffer n
:bd! " Force delete buffer (discard changes)
:%bd " Delete all buffers
:%bd|e#|bd# " Delete all except current
Buffer listing symbols: % - Current buffer # - Alternate buffer a - Active buffer (loaded and visible) h - Hidden buffer (loaded but not visible)
- Non-modifiable buffer = - Readonly buffer
- Modified buffer x - Buffer with errors
Examples:
:ls " List all buffers
:b init " Switch to buffer matching 'init'
:bd 3 " Delete buffer 3
:bn " Next buffer
:b# " Previous buffer (alternate)
Delete all buffers except current:
:%bd|e#|bd#
Explanation: Delete all, edit alternate (#), delete alternate again.
Window Commands
:sp[lit] {file} " Split horizontally, optionally open file
:vs[plit] {file} " Split vertically
:new " New horizontal split
:vnew " New vertical split
:on[ly] " Close all windows except current
:clo[se] " Close current window
:q " Quit window (same as :close)
:hide " Hide current window
:resize {n} " Resize horizontal split to n lines
:resize +5 " Increase height by 5
:resize -5 " Decrease height by 5
:vertical resize {n} " Resize vertical split to n columns
Window navigation (Ex commands):
<C-w>h " Move to left window
<C-w>j " Move to window below
<C-w>k " Move to window above
<C-w>l " Move to right window
<C-w>w " Cycle through windows
<C-w>p " Previous window
<C-w>o " Close all except current (same as :only)
Examples:
:sp " Split current buffer
:vs ~/.bashrc " Vertical split, open bashrc
:resize 20 " Set height to 20 lines
:vertical resize 80 " Set width to 80 columns
:only " Close all other windows
Tab Commands
:tabe[dit] {file} " Open file in new tab
:tabnew " New empty tab
:tabc[lose] " Close current tab
:tabo[nly] " Close all tabs except current
:tabn[ext] " Next tab
:tabp[revious] " Previous tab
:tabfirst " First tab
:tablast " Last tab
:tabs " List all tabs
:tabm[ove] {n} " Move tab to position n
:tabm[ove] +1 " Move tab right
:tabm[ove] -1 " Move tab left
Tab navigation shortcuts:
gt " Next tab
gT " Previous tab
{n}gt " Go to tab n
Examples:
:tabe file.txt " Open in new tab
:tabn " Next tab
:tabm 0 " Move to first position
:tabo " Close all other tabs
Display Commands
:echo {expr} " Display expression
:echom {expr} " Display and save to message history
:echon {expr} " Display without newline
:mes[sages] " Show message history
:mes clear " Clear message history
:echohl {group} " Set highlight group for next echo
:echohl None " Reset highlight
Examples:
:echo "Hello" " Display message
:echo expand('%:p') " Display full file path
:echom "Saved to history" " Display and save
:messages " View history
:echohl WarningMsg | echo "Warning!" | echohl None
Neovim-specific (Lua):
:lua print("Hello from Lua")
:lua vim.notify("Notification", vim.log.levels.INFO)Marks and Jumps
:marks " List all marks
:marks {a-z} " List specific marks
:delmarks {marks} " Delete marks
:delmarks! " Delete all lowercase marks
:jumps " List jump history
:changes " List change history
:clearjumps " Clear jump history
Examples:
ma " Set mark 'a'
:marks " View all marks
'a " Jump to mark 'a'
:delmarks a " Delete mark 'a'
:delmarks a-z " Delete marks a through z
Registers
:reg[isters] " Display all registers
:reg {register} " Display specific register
:let @a='text' " Set register 'a' to 'text'
Examples:
:reg " Show all registers
:reg a " Show register 'a'
:let @a='hello' " Set register a
"ap " Paste from register a
Options
:set {option} " Enable boolean option
:set no{option} " Disable boolean option
:set {option}! " Toggle boolean option
:set {option}? " Query option value
:set {option}={value} " Set option to value
:set {option}+=value " Append to option
:set {option}-=value " Remove from option
:set all " Show all options
:set all& " Reset all options to defaults
:setlocal {option} " Set option locally to buffer/window
:setglobal {option} " Set option globally
Examples:
:set number " Enable line numbers
:set nonumber " Disable line numbers
:set number! " Toggle line numbers
:set number? " Query current value
:set tabstop=4 " Set tab width to 4
:set path+=** " Add recursive search to path
:set all " Show all settings
Neovim (Lua API):
:lua vim.opt.number = true
:lua vim.opt.tabstop = 4
:lua print(vim.opt.number:get())5.5 Advanced Ex Commands
Substitute (Detailed)
We covered substitution in Chapter 4, but here are Ex-specific aspects:
:[range]s/{pattern}/{replacement}/{flags}
:s/old/new/ " Current line
:%s/old/new/g " Entire file
:'<,'>s/old/new/g " Visual selection
:5,10s/old/new/gc " Lines 5-10 with confirmation
Repeat last substitute:
:s " Repeat with same flags
:& " Repeat with same flags
:~ " Repeat with same replacement, new pattern
:&& " Repeat with same flags on all lines
Substitute special cases:
:s/pattern// " Delete pattern (replace with nothing)
:s//new/ " Use last search pattern
:s/old/\=expression/ " Replace with expression result
Global Command
:[range]g/{pattern}/{command}
:[range]g!/{pattern}/{command}
:[range]v/{pattern}/{command} " Inverse (same as g!)
Common global patterns:
:g/pattern/d " Delete matching lines
:g/pattern/m$ " Move matching lines to end
:g/pattern/t$ " Copy matching lines to end
:g/pattern/y A " Yank matching lines to register A
:g/pattern/normal @a " Execute macro 'a' on matching lines
:g/pattern/s/old/new/g " Substitute on matching lines
Complex global commands:
:g/TODO/+1,/^$/d " Delete from line after TODO to next blank line
:g/^$/,/./-1sort " Sort paragraphs (blank to blank)
:g/class/.,/^}/>> " Indent class bodies
Nested global:
:g/outer/g/inner/d " On lines with 'outer', delete if also has 'inner'
Normal Command
Execute Normal mode commands from Ex:
:[range]norm[al][!] {commands}
Examples:
:%norm A; " Append ';' to every line
:'<,'>norm I// " Comment visual selection
:5,10norm 0d$ " Delete content of lines 5-10
:%norm @a " Execute macro 'a' on all lines
:g/pattern/norm dd " Delete matching lines (alternative to :g/pattern/d)
With ! flag:
:norm! x " Execute 'x' without mappings
Sort
:[range]sor[t][!] [options]
Options:
:sort " Sort lines
:sort! " Reverse sort
:sort i " Case-insensitive sort
:sort u " Remove duplicates
:sort n " Numeric sort
:sort /pattern/ " Sort by pattern match
Examples:
:%sort " Sort entire file
:'<,'>sort " Sort selection
:sort! n " Reverse numeric sort
:sort u " Sort and remove duplicates
:10,20sort i " Case-insensitive sort of lines 10-20
Sort by pattern:
:sort /\d\+/ n " Sort by numbers in lines
Text: item_3 item_1 item_10 item_2
After :sort /\d\+/ n: item_1 item_2 item_3
item_10
External Commands
:!{command} " Execute shell command
:r !{command} " Read command output into buffer
:w !{command} " Pipe buffer to command
:[range]!{filter} " Filter lines through command
Examples:
:!ls " Execute 'ls' in shell
:!git status " Show git status
:r !date " Insert current date
:r !ls " Insert directory listing
:%!sort " Sort entire file with external sort
:'<,'>!column -t " Format selection as table
:w !wc -l " Count lines (without saving)
:w !sh " Execute buffer as shell script
Pretty-print JSON:
:%!python -m json.tool
Or with jq:
:%!jq .
Read and Write
:r {file} " Read file into buffer
:r !{command} " Read command output
:w {file} " Write to file
:w !{command} " Pipe to command
:w >> {file} " Append to file
:{range}w {file} " Write range to file
Examples:
:r template.txt " Insert template file
:r !date " Insert date
:5,10w partial.txt " Write lines 5-10 to file
:.w >> log.txt " Append current line to log
:%w !pbcopy " Copy buffer to macOS clipboard (pipe to pbcopy)
Copy and Move
:[range]co[py] {address}
:[range]t {address} " Short form of :copy
:[range]m[ove] {address}
Examples:
:t$ " Copy current line to end
:5,10t. " Copy lines 5-10 to after current line
:.,+3m$ " Move current line and next 3 to end
:g/TODO/t$ " Copy all TODO lines to end
:'<,'>m0 " Move selection to top of file
Join
:[range]j[oin][!]
Examples:
:j " Join current and next line
:5,10j " Join lines 5-10 into one
:j! " Join without adding space
:'<,'>j " Join visual selection
Print and List
:[range]p[rint] " Print lines
:[range]l[ist] " Print with special chars visible
:[range]# " Print with line numbers
:[range]p # " Print with line numbers (same as :#)
Examples:
:10,20p " Print lines 10-20
:l " Show current line with invisible chars
:%l " Show all lines with special chars
:g/pattern/p " Print matching lines (like grep)
:g/pattern/# " Print matching lines with numbers
Undo and Redo
:u[ndo] " Undo
:u {n} " Undo to state n (from :undolist)
:red[o] " Redo
:earlier {count} " Go back in time
:later {count} " Go forward in time
:undolist " Show undo states
Time-based undo:
:earlier 5m " Go back 5 minutes
:earlier 10s " Go back 10 seconds
:earlier 1h " Go back 1 hour
:earlier 2d " Go back 2 days
:later 5m " Go forward 5 minutes
File-based undo:
:earlier 1f " Before last file save
:earlier 2f " Before 2 file saves ago
:later 1f " After next file save
Examples:
:earlier 3m " Undo to 3 minutes ago
:later 30s " Redo to 30 seconds later
:undolist " View undo tree states
:undo 5 " Undo to state 5
Runtime Commands
:so[urce] {file} " Execute commands from file
:so % " Source current file
:runtime {file} " Source file from runtimepath
Examples:
:source ~/.vimrc " Reload vimrc
:so % " Source current file
:runtime colors/desert.vim " Load color scheme
Neovim Lua:
:luafile {file} " Execute Lua file
:luafile % " Execute current Lua fileHelp
:h[elp] {topic} " Open help
:h[elp] " Open general help
:helpgrep {pattern} " Search help files
:h {topic}<C-d> " List help topics
Examples:
:h :substitute " Help on substitute command
:h pattern " Help on patterns
:h nvim-lua " Neovim Lua guide
:h options " Help on all options
:helpgrep TODO " Search for TODO in help
Navigate help:
<C-]> " Jump to tag under cursor
<C-t> " Jump back
:h motion.txt " Open specific help file
5.6 Command Modifiers
Modifiers change how commands execute:
:verbose {command} " Show verbose output
:silent {command} " Suppress output
:silent! {command} " Suppress output and errors
:hide {command} " Hide buffer instead of unloading
:keepalt {command} " Don't change alternate file
:keepjumps {command} " Don't change jump list
:keepmarks {command} " Don't change marks
:keeppatterns {command} " Don't change search pattern
:lockmarks {command} " Don't adjust marks
:noautocmd {command} " Don't trigger autocommands
:topleft {command} " Open splits at top/left
:botright {command} " Open splits at bottom/right
:vertical {command} " Make split vertical
:tab {command} " Open in new tab
Examples:
:verbose set number? " Show where 'number' was last set
:silent !make " Compile without output
:silent! source ~/.extra.vim " Source if exists, no error
:keepjumps /pattern " Search without affecting jump list
:noautocmd write " Save without triggering autocmds
:vertical help pattern " Open help in vertical split
:tab help motion " Open help in new tab
:topleft split " Split at top
:botright vsplit " Vertical split at right
Combining modifiers:
:silent! noautocmd write " Save silently without autocmds
:vertical botright split " Vertical split at bottom-right
5.7 Custom Commands
Defining Commands
:command[!] {name} {replacement}
Components:
!- Force overwrite existing command{name}- Command name (must start with uppercase){replacement}- What the command expands to
Simple examples:
:command W w " Alias :W to :w
:command Q q " Alias :Q to :q
:command Wq wq " Alias :Wq to :wq
Neovim (Lua):
vim.api.nvim_create_user_command('W', 'w', {})
vim.api.nvim_create_user_command('Q', 'q', {})Command Arguments
-nargs=0 " No arguments (default)
-nargs=1 " Exactly one argument
-nargs=* " Any number of arguments
-nargs=? " 0 or 1 argument
-nargs=+ " One or more arguments
<args> " Placeholder for arguments
<f-args> " Arguments as separate items (for functions)
<q-args> " Arguments as single quoted string
Examples:
:command -nargs=1 Search vimgrep /<args>/ **/*.txt
:command -nargs=* Tabopen tabe <args>
:command -nargs=? Backup w! <args>.bak
Usage:
:Search pattern " Searches for 'pattern'
:Tabopen file1.txt file2.txt " Opens both in tabs
:Backup " Saves as current_file.bak
:Backup myfile " Saves as myfile.bak
Command with Range
-range " Command accepts range (defaults to current line)
-range=% " Defaults to whole file
-range=5 " Defaults to 5 lines
<line1> " Start of range
<line2> " End of range
Examples:
:command -range CommentLines <line1>,<line2>s/^/# /
:command -range=% SortFile <line1>,<line2>sort
Usage:
:5,10CommentLines " Comment lines 5-10
:CommentLines " Comment current line
:%SortFile " Sort entire file
Command with Count
-count={default} " Accept count (with default)
<count> " The count value
Example:
:command -count=1 Dup <line1>t<line2>+<count>
Usage:
:3Dup " Duplicate line 3 times
:Dup " Duplicate once (default)
Command Completion
-complete=file " File completion
-complete=buffer " Buffer completion
-complete=command " Command completion
-complete=custom,{function} " Custom completion
-complete=customlist,{function} " Custom list
Examples:
:command -nargs=1 -complete=file Edit e <args>
:command -nargs=1 -complete=buffer Buf b <args>
:command -nargs=* -complete=command Exec <args>
Custom completion (Neovim Lua):
vim.api.nvim_create_user_command('MyCmd', function(opts)
print(opts.args)
end, {
nargs = 1,
complete = function(arg_lead, cmd_line, cursor_pos)
return {'option1', 'option2', 'option3'}
end,
})Practical Custom Commands
Save as root:
:command W w !sudo tee % > /dev/null
Format JSON:
:command FormatJSON %!python -m json.tool
Open config:
:command Config e ~/.config/nvim/init.lua
Reload config:
:command Reload source ~/.config/nvim/init.lua
Delete trailing whitespace:
:command Trim %s/\s\+$//e
Toggle relative numbers:
:command ToggleRelative set relativenumber!
Git commands:
:command Gstatus !git status
:command Gdiff !git diff %
:command Gblame !git blame %
Neovim (Lua) - More complex:
-- Open file in vertical split
vim.api.nvim_create_user_command('Vs', function(opts)
vim.cmd('vsplit ' .. opts.args)
end, {
nargs = 1,
complete = 'file',
})
-- Search and replace in project
vim.api.nvim_create_user_command('Replace', function(opts)
local args = vim.split(opts.args, ' ')
if #args ~= 2 then
print('Usage: Replace <old> <new>')
return
end
vim.cmd(string.format('args **/*.lua | argdo %%s/%s/%s/ge | update', args[1], args[2]))
end, {
nargs = '+',
})
-- Create scratch buffer
vim.api.nvim_create_user_command('Scratch', function()
vim.cmd('enew')
vim.bo.buftype = 'nofile'
vim.bo.bufhidden = 'hide'
vim.bo.swapfile = false
end, {})Deleting Custom Commands
:delcommand {name} " Delete command
:comclear " Delete all user-defined commands
5.8 Command-Line Expressions
Literal Insert
<C-v>{char} " Insert character literally
<C-v><Tab> " Insert literal tab
<C-v><CR> " Insert literal newline (in some contexts)
String Concatenation
:echo "Hello " . "World"
:echo "File: " . expand('%')
Conditionals in Commands
:if {condition} | {command} | endif
Example:
:if line('$') > 100 | echo "Large file" | else | echo "Small file" | endif
Multiple lines:
:if &modified
: echo "File is modified"
:else
: echo "File is unchanged"
:endif
Command with Ternary
:echo &modified ? "Modified" : "Unchanged"
:execute &modified ? 'write' : 'echo "No changes"'
Execute Command
:execute builds and executes a command string:
:execute "normal! " . v:count1 . "j"
:execute "edit " . expand('%:h') . '/new.txt'
Examples:
:let n = 5
:execute n . "," . (n+5) . "delete" " Delete lines 5-10
:let filename = "config.lua"
:execute "edit " . filename " Open file
:execute "normal! gg=G" " Format file
Command Chaining
Chain multiple commands with |:
:w | source % " Save and source
:e file.txt | vsplit other.txt " Open two files
:%s/old/new/g | w | q " Replace, save, quit
With conditionals:
:if &modified | write | endif | quit
5.9 Command-Line Functions
Expand Function
:echo expand('%') " Current filename
:echo expand('%:p') " Full path
:echo expand('%:h') " Directory
:echo expand('%:t') " Filename only
:echo expand('%:r') " Root (without extension)
:echo expand('%:e') " Extension only
Modifiers:
%:p " Full path
%:p:h " Directory of full path
%:p:t " Filename from full path
%:r " Remove extension
%:e " Extension only
%:t:r " Filename without extension
%:s/old/new/ " Substitute in filename
%:gs/old/new/ " Global substitute
Examples:
" Current file: /home/user/project/src/main.lua
:echo expand('%') " src/main.lua
:echo expand('%:p') " /home/user/project/src/main.lua
:echo expand('%:h') " src
:echo expand('%:t') " main.lua
:echo expand('%:r') " src/main
:echo expand('%:e') " lua
:echo expand('%:t:r') " main
Line Functions
line('.') " Current line number
line('$') " Last line number
line('w0') " First line in window
line('w$') " Last line in window
line("'a") " Line of mark a
Column Functions
col('.') " Current column
col('$') " Last column in line
virtcol('.') " Virtual column (accounts for tabs)
File Functions
filereadable({file}) " Check if file is readable
isdirectory({dir}) " Check if directory exists
executable({prog}) " Check if program exists
glob({pattern}) " Get matching files
globpath({path}, {pattern}) " Search in path
Examples:
:if filereadable('config.lua')
: source config.lua
:endif
:if executable('rg')
: set grepprg=rg\ --vimgrep
:endif
Buffer/Window Functions
bufnr('%') " Current buffer number
bufname('%') " Current buffer name
winnr() " Current window number
bufexists({buf}) " Check if buffer exists
buflisted({buf}) " Check if buffer is listed
5.10 Command-Line Abbreviations
Defining Abbreviations
:cabbrev {lhs} {rhs} " Command-line abbreviation
:cnoreabbrev {lhs} {rhs} " Non-recursive abbreviation
:cunabbrev {lhs} " Remove abbreviation
:cabclear " Clear all abbreviations
Examples:
:cabbrev W w
:cabbrev Q q
:cabbrev Wq wq
:cabbrev Set set
With condition (only expand at start of line):
:cnoreabbrev <expr> W getcmdtype() == ':' && getcmdline() == 'W' ? 'w' : 'W'
Neovim (Lua):
vim.cmd('cabbrev W w')
vim.cmd('cabbrev Q q')
-- Or with function:
local function cabbrev(lhs, rhs)
vim.cmd(string.format('cabbrev %s %s', lhs, rhs))
end
cabbrev('W', 'w')
cabbrev('Wq', 'wq')Practical Abbreviations
:cabbrev h vert help " Vertical help
:cabbrev vh vert help " Same
:cabbrev Trim %s/\s\+$//e " Trailing whitespace
:cabbrev Json %!python -m json.tool " Format JSON
5.11 Command-Line Best Practices
1. Use Command Completion
Always use <Tab> for completion:
:colorsch<Tab> " Complete to :colorscheme
:e ~/.con<Tab> " Complete file path
2. Leverage History
:<Up> " Navigate history
:his : " View command history
q: " Edit history in window
3. Create Useful Aliases
:command W w
:command Q q
:command Wq wq
:command WQ wq
4. Use Ranges Effectively
:'<,'> " Visual selection
:.,$ " Current to end
:% " Entire file
5. Chain Commands
:w | source % " Save and reload
:%s/old/new/g | w " Replace and save
6. Use Verbose for Debugging
:verbose set number? " Where was option set?
:verbose map <leader> " Where was mapping defined?
7. Silent for Cleaner Output
:silent !make " Compile without spam
:silent write " Save without message
8. Custom Commands for Workflows
:command Config edit ~/.config/nvim/init.lua
:command Reload source ~/.config/nvim/init.lua
:command Todo vimgrep /TODO/ **/*.lua | copen
9. Use Expression Register
<C-r>=2+2<CR> " Insert 4
<C-r>=strftime('%Y-%m-%d')<CR> " Insert date
10. Learn Key Combinations
<C-r><C-w> " Insert word under cursor
<C-r><C-f> " Insert filename under cursor
<C-r>% " Insert current filename
5.12 Command-Line Mappings
Map keys specifically for Command-line mode:
:cnoremap <C-a> <Home> " Ctrl-A to start
:cnoremap <C-e> <End> " Ctrl-E to end (already default)
:cnoremap <C-p> <Up> " Ctrl-P for history (already default)
:cnoremap <C-n> <Down> " Ctrl-N for history
Neovim (Lua):
vim.keymap.set('c', '<C-a>', '<Home>', { desc = 'Beginning of line' })
vim.keymap.set('c', '<C-e>', '<End>', { desc = 'End of line' })Practical mappings:
-- Sudo save
vim.keymap.set('c', 'w!!', 'w !sudo tee % >/dev/null', { desc = 'Save with sudo' })
-- Quick help in vertical split
vim.keymap.set('c', 'vh', 'vert help', { desc = 'Vertical help' })
-- Expand current directory
vim.keymap.set('c', '%%', "getcmdtype() == ':' ? expand('%:h').'/' : '%%'",
{ expr = true, desc = 'Expand directory' })Usage:
:e %% " Expands to :e current/directory/
5.13 Advanced Command Patterns
Conditional Execution
:if {condition}
: {command}
:else
: {command}
:endif
Example:
:if &modified
: write
: echo "Saved"
:else
: echo "No changes"
:endif
One-liner:
:if &modified | write | endif
Loops
:for i in range(1, 10)
: execute "echo " . i
:endfor
Example - Comment multiple lines:
:for i in range(5, 10)
: execute i . "s/^/# /"
:endfor
Neovim (Lua):
:lua for i = 1, 10 do print(i) endTry-Catch
:try
: source risky_config.vim
:catch
: echo "Failed to load config"
:endtry
Functions in Commands
:function! MyFunction()
: echo "Hello from function"
:endfunction
:call MyFunction()
With arguments:
:function! Greet(name)
: echo "Hello, " . a:name
:endfunction
:call Greet("World")
Neovim (Lua) - Preferred:
:lua function greet(name) print("Hello, " .. name) end
:lua greet("World")5.14 Command-Line Recipes
Open File Under Cursor
:e <C-r><C-f> " Insert filename, then edit
Or map it:
gf " Built-in: go to file
Search and Replace Across Project
:args **/*.lua
:argdo %s/old/new/ge | update
Delete All Marks
:delmarks! " Delete all lowercase marks
:delmarks A-Z " Delete all uppercase marks
Save Session with Custom Command
:command SaveSession mksession! ~/.vim/sessions/session.vim
:command LoadSession source ~/.vim/sessions/session.vim
Format Current Paragraph
:normal vipgq " Visual select paragraph, format
Or as custom command:
:command FormatPara normal vipgq
Open Terminal in Split
:split | terminal " Horizontal split with terminal
:vsplit | terminal " Vertical split with terminal
Neovim:
:split term://bash
:vsplit term://zsh
Quick Calculator
:echo 2 + 2
:echo 100 / 3
:echo sqrt(144)
:echo pow(2, 10)
In Insert mode:
<C-r>=2+2<CR> " Inserts 4
Convert Tabs/Spaces
:set expandtab " Use spaces
:retab " Convert tabs to spaces
:set noexpandtab " Use tabs
:retab! " Convert spaces to tabs
Show Full File Path
:echo expand('%:p')
:echo @% " Current filename (relative)
Or map it:
:nnoremap <leader>fp :echo expand('%:p')<CR>
Count Pattern Matches
:%s/pattern//gn " Count without replacing
:vimgrep /pattern/ % | copen " List all matches
Chapter Summary
In this chapter, you mastered Command-Line Mode:
Basics: Entering (
:,/,?), exiting (<CR>,<Esc>), command-line window (q:)Editing: Cursor movement (
<C-b>,<C-e>), deletion (<C-u>,<C-w>)Register insertion:
<C-r>{register},<C-r><C-w>,<C-r><C-f>History:
<Up>/<Down>, filtered history,:historyCompletion:
<Tab>,<C-d>, wildmenu settingsExpression register:
<C-r>=for calculationsRanges:
.,$,%,'<,'>, patterns, marks, arithmeticFile operations:
:e,:w,:sav,:up,:wq,:xBuffer management:
:ls,:b,:bn,:bp,:bdWindow/tab commands:
:sp,:vs,:only,:tabe,:tabnDisplay:
:echo,:echom,:messagesMarks/jumps:
:marks,:jumps,:changesOptions:
:set,:setlocal,:setglobalSubstitute: Full syntax, ranges, flags, expressions
Global:
:g,:v, nested patternsNormal:
:[range]normfor batch operationsSort:
:sort,:sort!,:sort u, numeric/pattern sortingExternal:
:!,:r !,:%!for filtersCopy/move:
:t,:mwith rangesJoin:
:j,:j!Undo/redo:
:u,:earlier,:later, time-based undoRuntime:
:source,:runtime,:luafile(Neovim)Help:
:h,:helpgrep, tag navigationModifiers:
:verbose,:silent,:noautocmd,:vertical,:tabCustom commands:
:command, arguments, ranges, completionFunctions:
expand(),line(),col(), file checksAbbreviations:
:cabbrevfor typo correctionMappings:
:cnoremapfor command-line keysAdvanced patterns: Conditionals, loops, try-catch
Best practices: Completion, history, chaining, custom workflows
Command-Line Mode transforms Vim from a text editor into a programmable text manipulation environment. Combined with ranges, patterns, and Ex commands, you can perform complex operations across files that would require custom scripts in other editors.
In the next chapter, we’ll explore Registers and Macros in depth, covering register types, macro recording and editing, recursive macros, and building powerful automation workflows.
Chapter 6: Buffers, Windows, and Tabs
Vim’s workspace architecture differs fundamentally from traditional editors. Instead of a simple “file-in-window” model, Vim uses a three-layer hierarchy: buffers (in-memory file copies), windows (viewports into buffers), and tabs (collections of window layouts). Mastering this system unlocks powerful workflows for managing multiple files, comparing documents side-by-side, and organizing complex projects. This chapter explores each layer in depth with practical Neovim enhancements.
6.1 Understanding the Architecture
The Three-Layer Model
┌│─────────────────────────────────────┐
││ Tab Pages │
││ ┌─────────────┬─────────────────┐ │
││ │ Window 1 │ Window 2 │ │
││ │ (Buffer A)│ (Buffer B) │ │
││ ├─────────────┴─────────────────┤ │
││ │ Window 3 │ │
││ │ (Buffer C) │ │
││ └───────────────────────────────┘ │ └─────────────────────────────────────┘
Buffer List: [A, B, C, D, E, F] ← Buffers in memory └─ D, E, F are hidden (not in any window)
Key Concepts
Buffer
In-memory representation of a file
Holds file content, undo history, marks, options
Can be displayed in multiple windows simultaneously
Persists even when not visible (hidden)
Each buffer has a unique number
Window
Viewport displaying a buffer
Can be split horizontally or vertically
Multiple windows can display the same buffer
Each window has its own cursor position in the buffer
Closing a window doesn’t delete the buffer
Tab Page
Collection of windows arranged in a layout
Tabs are NOT file containers (unlike most editors)
Switching tabs shows a different window arrangement
All tabs share the same buffer list
Think of tabs as workspace layouts
Common Misconception
Traditional Editor Model: Tab 1: file1.txt Tab
2: file2.txt
Tab 3: file3.txt
Vim Model: Tab 1: [Window: file1.txt | Window: file2.txt] Tab 2: [Window: file3.txt] └─ file1.txt and file2.txt still in buffer list!
6.2 Buffer Management
Listing Buffers
:ls " List all buffers
:buffers " Same as :ls
:files " Same as :ls
:ls! " Show unlisted buffers too
Buffer Status Indicators: % - Current buffer (in current window) # - Alternate buffer (previous buffer) a - Active buffer (loaded and displayed in a window) h - Hidden buffer (loaded but not displayed)
- Unmodifiable buffer = - Readonly buffer R - Readonly and modified (unusual state)
- Modified buffer (unsaved changes) x - Buffer with read errors u - Unlisted buffer (not in buffer list)
Example output: 1 %a “init.lua” line 42 2 h “plugin.lua” line 15 3 #h “config.lua” line 1 4 h + “notes.txt” line 8
Interpretation:
Buffer 1: Current, active (displayed), line 42
Buffer 2: Hidden (loaded but not shown)
Buffer 3: Alternate buffer, hidden
Buffer 4: Hidden, modified (unsaved changes)
Creating and Opening Buffers
:e {file} " Edit file (create new buffer or switch)
:enew " Create new unnamed buffer
:badd {file} " Add file to buffer list without opening
:find {file} " Find file in 'path' and edit
Examples:
:e ~/.bashrc " Edit bashrc (creates buffer)
:enew " New scratch buffer
:badd TODO.md " Add to buffer list, don't open
:find init.lua " Search 'path' for init.lua
Neovim (Lua):
vim.cmd('edit ~/.bashrc')
vim.cmd('enew')
-- Or using API:
vim.api.nvim_command('edit file.txt')Navigating Buffers
:b {n} " Go to buffer number n
:b {name} " Go to buffer by name (partial match)
:bn " Next buffer
:bp " Previous buffer
:bf " First buffer
:bl " Last buffer
:b# " Alternate buffer (previous)
Smart buffer switching:
:b init " Switches to any buffer matching 'init'
:b .lua " Switches to buffer ending with '.lua'
:b config<Tab> " Tab completion
Cycle through buffers:
:bn " Next
:bp " Previous
5:bn " Go 5 buffers forward
Recommended mappings (Neovim):
vim.keymap.set('n', '<leader>bn', ':bn<CR>', { desc = 'Next buffer' })
vim.keymap.set('n', '<leader>bp', ':bp<CR>', { desc = 'Previous buffer' })
vim.keymap.set('n', '<leader>bd', ':bd<CR>', { desc = 'Delete buffer' })
vim.keymap.set('n', '<leader>bb', ':b#<CR>', { desc = 'Alternate buffer' })Deleting Buffers
:bd " Delete current buffer
:bd {n} " Delete buffer n
:bd {name} " Delete buffer by name
:bd! " Force delete (discard changes)
:bd {n} {m} {o} " Delete multiple buffers
:%bd " Delete all buffers
:bufdo bd " Delete all (alternative)
Delete all except current:
:%bd|e#|bd#
Explanation:
%bd- Delete all bufferse#- Edit alternate buffer (reopens current)bd#- Delete the duplicate alternate buffer
Delete hidden buffers:
:bufdo if !buflisted(bufnr('%')) | bd | endif
Neovim (Lua) - Better approach:
-- Delete all buffers except current
vim.api.nvim_create_user_command('BufOnly', function()
local current = vim.api.nvim_get_current_buf()
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if buf ~= current and vim.api.nvim_buf_is_loaded(buf) then
vim.api.nvim_buf_delete(buf, { force = false })
end
end
end, {})Buffer Operations
:bufdo {cmd} " Execute command on all buffers
:bufdo! {cmd} " Execute on all, including hidden
Examples:
:bufdo %s/TODO/DONE/ge | update " Replace in all, save
:bufdo set number " Enable line numbers in all
:bufdo normal gg=G " Format all buffers
Conditional buffer operations:
:bufdo if expand('%:e') == 'lua' | set ts=2 | endif
Buffer-Local Options
Set options for current buffer only:
:setlocal number " Line numbers for this buffer
:setlocal spell " Spellcheck for this buffer
:setlocal ts=2 " Tab width for this buffer
Neovim (Lua):
vim.opt_local.number = true
vim.opt_local.spell = true
vim.opt_local.tabstop = 2Or using buffer-specific options:
vim.bo.tabstop = 2
vim.bo.shiftwidth = 2
vim.bo.expandtab = trueScratch Buffers
Create temporary buffers for notes:
:enew " New buffer
:setlocal buftype=nofile " Not associated with file
:setlocal bufhidden=hide " Hide when abandoned
:setlocal noswapfile " Don't create swap file
Complete scratch buffer command:
vim.api.nvim_create_user_command('Scratch', function()
vim.cmd('enew')
vim.bo.buftype = 'nofile'
vim.bo.bufhidden = 'hide'
vim.bo.swapfile = false
end, {})Usage:
:Scratch " Creates disposable buffer
Buffer Information
:file " Show current buffer info
:file {name} " Rename current buffer
:buffers " List all buffers
:ls + " List modified buffers
:ls a " List active buffers
:ls h " List hidden buffers
Get buffer details programmatically (Neovim):
local buf = vim.api.nvim_get_current_buf()
local name = vim.api.nvim_buf_get_name(buf)
local lines = vim.api.nvim_buf_line_count(buf)
local modified = vim.bo.modified
print(string.format("Buffer: %s, Lines: %d, Modified: %s",
name, lines, modified))Unlisted Buffers
Some buffers are unlisted (help buffers, plugins, etc.):
:ls! " Show all, including unlisted
:set buflisted " Make current buffer listed
:set nobuflisted " Make current buffer unlisted
Buffer Variables
Store data specific to a buffer:
:let b:my_var = 'value' " Buffer-local variable
:echo b:my_var " Access variable
:unlet b:my_var " Delete variable
Neovim (Lua):
vim.b.my_var = 'value'
print(vim.b.my_var)
vim.b.my_var = nil -- DeleteUse case - Track buffer state:
-- Mark buffer as configured
vim.b.lsp_configured = true
-- Check in autocommand
if not vim.b.lsp_configured then
-- Setup LSP
vim.b.lsp_configured = true
end6.3 Window Management
Creating Windows
:split {file} " Horizontal split (or :sp)
:vsplit {file} " Vertical split (or :vs)
:new " New horizontal split, empty buffer
:vnew " New vertical split, empty buffer
:split " Split current buffer horizontally
:vsplit " Split current buffer vertically
With size:
:10split " 10 lines high
:vertical 50split " 50 columns wide
:30vnew " 30 columns wide, new buffer
Examples:
:sp config.lua " Open config.lua in horizontal split
:vs " Vertical split, same buffer
:20split " Horizontal split, 20 lines high
:vertical 80split " Vertical split, 80 columns wide
Neovim mappings:
vim.keymap.set('n', '<leader>sh', ':split<CR>', { desc = 'Horizontal split' })
vim.keymap.set('n', '<leader>sv', ':vsplit<CR>', { desc = 'Vertical split' })Navigating Windows
Motion commands:
<C-w>h " Move to window on left
<C-w>j " Move to window below
<C-w>k " Move to window above
<C-w>l " Move to window on right
<C-w>w " Cycle to next window
<C-w>W " Cycle to previous window
<C-w>p " Previous window (last accessed)
<C-w>t " Top-left window
<C-w>b " Bottom-right window
Count prefix:
3<C-w>w " Jump 3 windows forward
2<C-w>l " Jump 2 windows right
Better navigation mappings:
-- Easier window navigation
vim.keymap.set('n', '<C-h>', '<C-w>h', { desc = 'Go to left window' })
vim.keymap.set('n', '<C-j>', '<C-w>j', { desc = 'Go to window below' })
vim.keymap.set('n', '<C-k>', '<C-w>k', { desc = 'Go to window above' })
vim.keymap.set('n', '<C-l>', '<C-w>l', { desc = 'Go to right window' })Resizing Windows
<C-w>= " Make all windows equal size
<C-w>_ " Maximize height
<C-w>| " Maximize width
<C-w>+ " Increase height by 1
<C-w>- " Decrease height by 1
<C-w>> " Increase width by 1
<C-w>< " Decrease width by 1
With count:
10<C-w>+ " Increase height by 10
5<C-w>> " Increase width by 5
Ex commands:
:resize 20 " Set height to 20 lines
:resize +5 " Increase height by 5
:resize -5 " Decrease height by 5
:vertical resize 80 " Set width to 80 columns
:vertical resize +10 " Increase width by 10
Better resize mappings:
vim.keymap.set('n', '<M-h>', ':vertical resize -2<CR>', { desc = 'Decrease width' })
vim.keymap.set('n', '<M-l>', ':vertical resize +2<CR>', { desc = 'Increase width' })
vim.keymap.set('n', '<M-j>', ':resize -2<CR>', { desc = 'Decrease height' })
vim.keymap.set('n', '<M-k>', ':resize +2<CR>', { desc = 'Increase height' })
vim.keymap.set('n', '<M-=>', '<C-w>=', { desc = 'Equal windows' })Moving Windows
<C-w>r " Rotate windows downward/rightward
<C-w>R " Rotate windows upward/leftward
<C-w>x " Exchange with next window
<C-w>H " Move window to far left (full height)
<C-w>J " Move window to bottom (full width)
<C-w>K " Move window to top (full width)
<C-w>L " Move window to far right (full height)
<C-w>T " Move window to new tab
Examples:
<C-w>H " Convert vertical split to horizontal
<C-w>J " Convert horizontal split to vertical
<C-w>T " Break window out into new tab
Visualizing rotation: Before
┌│──────┬──────┐ ┌──────┬──────┐
││ A │ B │ │ B │ A │
├│──────┴──────┤ → ├──────┴──────┤
││ C │ │ C │ └─────────────┘ └─────────────┘
Closing Windows
:q " Quit window (close window)
:close " Close current window
:only " Close all windows except current
:hide " Hide current window (keep buffer loaded)
<C-w>q " Quit window
<C-w>c " Close window
<C-w>o " Only (close all others)
Close all but current:
:only " Or <C-w>o
Force close:
:q! " Discard changes and close
:close! " Force close
Window Position Modifiers
Open windows at specific positions:
:topleft split " Split at top
:botright vsplit " Vertical split at right
:leftabove split " Split above current
:rightbelow vsplit " Vertical split to right
Examples:
:topleft vsplit " Vertical split at far left
:botright split " Horizontal split at bottom
:vertical botright split " Far right, vertical
Neovim configuration:
vim.opt.splitbelow = true -- Horizontal splits go below
vim.opt.splitright = true -- Vertical splits go rightWindow-Local Options
:setlocal number " Line numbers in this window only
:setlocal cursorline " Cursor line in this window
Neovim (Lua):
vim.wo.number = true -- Window option
vim.wo.cursorline = true
vim.wo.wrap = falseDifference between bo (buffer) and
wo (window):
vim.bo.filetype = 'lua' -- Buffer-specific (shared across windows)
vim.wo.number = true -- Window-specific (each window can differ)Scrolling Multiple Windows
Bind scrolling in multiple windows:
:set scrollbind " Enable scroll binding in window
:set noscrollbind " Disable scroll binding
Use case - Compare files side by side:
:vsplit other.txt " Open second file
:set scrollbind " In first window
<C-w>l " Move to second window
:set scrollbind " In second window
" Now both windows scroll together!
Diff mode (automatic scrollbind):
:diffsplit file2.txt " Open in diff mode
:diffthis " Enable diff for current window
Window Focus Configuration
Automatic resize on focus:
-- Make focused window larger
vim.opt.winwidth = 84
vim.opt.winheight = 20
vim.opt.winminwidth = 15
vim.opt.winminheight = 5
-- Auto-resize on focus (requires autocmd)
local resize_group = vim.api.nvim_create_augroup('WindowResize', { clear = true })
vim.api.nvim_create_autocmd('WinEnter', {
group = resize_group,
pattern = '*',
command = 'resize 30 | vertical resize 100'
})Golden Ratio Windows
Create a plugin-like golden ratio effect:
local function golden_ratio()
local wincount = vim.fn.winnr('$')
if wincount == 1 then return end
local width = math.floor(vim.o.columns * 0.618)
local height = math.floor(vim.o.lines * 0.618)
vim.cmd('resize ' .. height)
vim.cmd('vertical resize ' .. width)
end
vim.keymap.set('n', '<leader>gr', golden_ratio, { desc = 'Golden ratio resize' })6.4 Tab Pages
Creating Tabs
:tabnew " New tab with empty buffer
:tabe {file} " Open file in new tab
:tabedit {file} " Same as :tabe
:tab {command} " Execute command in new tab
:tab split " Open current buffer in new tab
:tab help {topic} " Open help in new tab
Examples:
:tabnew " Empty tab
:tabe config.lua " Open config.lua in new tab
:tab split " Current buffer in new tab
:tab help buffers " Help in new tab
Create tab with specific layout:
:tabnew | vsplit | split
Creates: New Tab
┌│──────────┬──────────┐
││ │ │
││ A ├──────────┤
││ │ B │ └──────────┴──────────┘
Navigating Tabs
:tabn[ext] " Next tab
:tabp[revious] " Previous tab
:tabfirst " First tab
:tablast " Last tab
:tabn {n} " Go to tab n
Normal mode shortcuts:
gt " Next tab
gT " Previous tab
{n}gt " Go to tab n (e.g., 3gt)
Neovim mappings:
vim.keymap.set('n', '<leader>tn', ':tabnext<CR>', { desc = 'Next tab' })
vim.keymap.set('n', '<leader>tp', ':tabprevious<CR>', { desc = 'Previous tab' })
vim.keymap.set('n', '<leader>tf', ':tabfirst<CR>', { desc = 'First tab' })
vim.keymap.set('n', '<leader>tl', ':tablast<CR>', { desc = 'Last tab' })
-- Or override defaults:
vim.keymap.set('n', '<Tab>', ':tabnext<CR>', { desc = 'Next tab' })
vim.keymap.set('n', '<S-Tab>', ':tabprevious<CR>', { desc = 'Previous tab' })Closing Tabs
:tabc[lose] " Close current tab
:tabc {n} " Close tab n
:tabo[nly] " Close all tabs except current
:tabclose! " Force close current tab
Examples:
:tabc " Close current tab
:tabc 3 " Close tab 3
:tabo " Close all other tabs
Moving Tabs
:tabm[ove] {n} " Move tab to position n (0 = first)
:tabm[ove] +{n} " Move tab n positions right
:tabm[ove] -{n} " Move tab n positions left
:tabm[ove] " Move to last position
Examples:
:tabm 0 " Move to first position
:tabm " Move to last position
:tabm +1 " Move one position right
:tabm -1 " Move one position left
Tab Operations
:tabs " List all tabs
:tabdo {cmd} " Execute command on all tabs
Examples:
:tabs " Show tab list with windows
:tabdo %s/TODO/DONE/ge | update " Replace in all tabs
Tab list output: Tab page 1 [1] “init.lua” > [2] “plugin.lua” Tab page 2 [3] “README.md”
Tab-Scoped Variables
:let t:my_var = 'value' " Tab-local variable
:echo t:my_var " Access variable
:unlet t:my_var " Delete variable
Neovim (Lua):
vim.t.my_var = 'value'
print(vim.t.my_var)
vim.t.my_var = nil -- DeleteUse case:
-- Track tab purpose
vim.t.project_root = '/path/to/project'
vim.t.tab_label = 'Frontend'Tab-Specific CWD
Each tab can have its own working directory:
:tcd {path} " Set tab-local working directory
:tcd %:h " Set to current file's directory
:pwd " Show current directory
Example workflow:
:tabnew " New tab
:tcd ~/project1 " Set directory for this tab
:e src/main.lua " Edit file relative to project1
:tabnew " Another tab
:tcd ~/project2 " Different directory
:e src/main.lua " Edit file from project2
Tab Line Configuration
Basic tab line:
vim.opt.showtabline = 2 -- Always show tab line (0=never, 1=auto, 2=always)Custom tab line (Neovim):
function _G.custom_tabline()
local s = ''
for i = 1, vim.fn.tabpagenr('$') do
local winnr = vim.fn.tabpagewinnr(i)
local bufnr = vim.fn.tabpagebuflist(i)[winnr]
local filename = vim.fn.bufname(bufnr)
local name = vim.fn.fnamemodify(filename, ':t')
if i == vim.fn.tabpagenr() then
s = s .. '%#TabLineSel# ' .. i .. ': ' .. name .. ' '
else
s = s .. '%#TabLine# ' .. i .. ': ' .. name .. ' '
end
end
return s .. '%#TabLineFill#'
end
vim.opt.tabline = '%!v:lua.custom_tabline()'Or use a plugin:
-- Using bufferline.nvim (popular choice)
require('bufferline').setup{
options = {
mode = "tabs", -- Show tabs instead of buffers
numbers = "ordinal",
close_command = "tabclose %d",
diagnostics = "nvim_lsp",
}
}6.5 Advanced Workflows
Working with Multiple Files
Open multiple files at once:
:args *.lua " Load all .lua files into argument list
:args **/*.lua " Recursive search
:args src/**/*.lua test/**/*.lua " Multiple patterns
Navigate argument list:
:next " Next file in args
:prev " Previous file in args
:first " First file in args
:last " Last file in args
:args " Show argument list
Operations on argument list:
:argdo %s/old/new/ge | update " Replace in all args
:argdo normal @a " Run macro on all args
Split Windows for Files
Horizontal split for each argument:
:args *.lua
:sall " Split all arguments horizontally
Vertical split:
:vertical sall " Split all arguments vertically
With limit:
:4sall " Split only first 4 files
Diff Mode
Compare two files:
:diffsplit other.txt " Open in diff mode
:diffthis " Enable diff in current window
:diffoff " Disable diff
:diffoff! " Disable diff in all windows
From command line:
nvim -d file1.txt file2.txtDiff navigation:
]c " Next difference
[c " Previous difference
do " Diff obtain (get changes from other window)
dp " Diff put (put changes to other window)
:diffupdate " Refresh diff
:diffget " Get changes from specific buffer
:diffput " Put changes to specific buffer
Three-way diff:
:diffsplit file2.txt
:vert diffsplit file3.txt
Buffer Browsing
Browse buffers interactively:
:ls " List buffers
:b <partial-name> " Type partial name, use Tab
Or use telescope (Neovim plugin):
require('telescope.builtin').buffers()Manual buffer menu:
vim.api.nvim_create_user_command('Buffers', function()
local buffers = vim.api.nvim_list_bufs()
local items = {}
for _, buf in ipairs(buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
local name = vim.api.nvim_buf_get_name(buf)
table.insert(items, string.format("%d: %s", buf, name))
end
end
vim.ui.select(items, {
prompt = 'Select buffer:',
}, function(choice)
if choice then
local bufnr = tonumber(choice:match("^(%d+):"))
vim.api.nvim_set_current_buf(bufnr)
end
end)
end, {})Session Management
Save session:
:mksession session.vim " Save session
:mksession! session.vim " Overwrite existing
Load session:
:source session.vim " Load session
From command line:
nvim -S session.vimWhat’s saved:
Window layouts
Tab pages
Buffer list
Working directory
Folds
Options
Mappings (optional)
Session options:
:set sessionoptions=blank,buffers,curdir,folds,help,tabpages,winsize
Neovim (Lua) - Auto-save session:
local session_dir = vim.fn.stdpath('data') .. '/sessions'
vim.fn.mkdir(session_dir, 'p')
local function save_session()
local session_file = session_dir .. '/' .. vim.fn.getcwd():gsub('/', '_') .. '.vim'
vim.cmd('mksession! ' .. session_file)
end
local function load_session()
local session_file = session_dir .. '/' .. vim.fn.getcwd():gsub('/', '_') .. '.vim'
if vim.fn.filereadable(session_file) == 1 then
vim.cmd('source ' .. session_file)
end
end
vim.api.nvim_create_autocmd('VimLeave', {
callback = save_session,
desc = 'Auto-save session on exit'
})
vim.api.nvim_create_user_command('LoadSession', load_session, {})Project-Based Tabs
Organize work by projects:
" Tab 1: Frontend
:tcd ~/projects/frontend
:e src/App.jsx
:vs src/components/Header.jsx
" Tab 2: Backend
:tabnew
:tcd ~/projects/backend
:e src/main.py
:vs src/api.py
" Tab 3: Documentation
:tabnew
:tcd ~/projects/docs
:e README.md
Quick project switcher (Lua):
local projects = {
frontend = '~/projects/frontend',
backend = '~/projects/backend',
docs = '~/projects/docs',
}
vim.api.nvim_create_user_command('Project', function(opts)
local project = opts.args
if projects[project] then
vim.cmd('tabnew')
vim.cmd('tcd ' .. vim.fn.expand(projects[project]))
vim.cmd('edit .') -- Open netrw in project root
else
print('Unknown project: ' .. project)
end
end, {
nargs = 1,
complete = function()
return vim.tbl_keys(projects)
end,
})Usage:
:Project frontend " Opens new tab with frontend project
Window Layouts
Common layouts:
Two-column split:
:vsplit " Two equal vertical panes
Three-column:
:vsplit | vsplit
<C-w>= " Equalize
Main + sidebar:
:vsplit
:vertical resize 30 " Sidebar 30 columns wide
Top bar + main + bottom:
:split
:resize 5 " Top bar 5 lines
<C-w>j
:split
:resize 10 " Bottom panel 10 lines
Save/restore layouts with sessions:
:mksession layout.vim
:source layout.vim
Window Zoom (Maximize Toggle)
-- Zoom current window (toggle)
vim.keymap.set('n', '<leader>z', function()
if vim.t.zoomed then
vim.cmd('tabclose')
vim.t.zoomed = false
else
vim.cmd('tab split')
vim.t.zoomed = true
end
end, { desc = 'Toggle window zoom' })Usage: <leader>z maximizes current window in
new tab, press again to restore.
6.6 Buffer/Window/Tab Tips
Buffer Tips
1. Alternate file (#):
<C-^> " Toggle between current and alternate buffer
:b# " Same as <C-^>
2. Hidden buffers:
vim.opt.hidden = true -- Allow switching buffers without saving3. Buffer grep:
:bufdo vimgrep /pattern/ % | copen
4. Wipeout vs delete:
:bd " Delete buffer (keeps in memory)
:bw " Wipeout buffer (completely remove)
Window Tips
1. Quick peek at file:
<C-w>f " Open file under cursor in split
<C-w>gf " Open in new tab
2. Preview window:
:pedit file.txt " Open in preview window
<C-w>z " Close preview window
3. Equal window sizes:
<C-w>= " Make all windows equal
4. Split to new buffer:
:new " Horizontal
:vnew " Vertical
Tab Tips
1. Tab-scoped working directory:
:tcd %:h " Set tab CWD to current file's directory
2. Drop file in tab:
:tab drop file.txt " Open in existing tab if already open
3. Tab-specific settings:
vim.api.nvim_create_autocmd('TabEnter', {
callback = function()
-- Apply settings when entering tab
if vim.t.special_tab then
vim.opt_local.number = false
end
end
})6.7 Neovim-Specific Features
Floating Windows
Neovim supports floating windows (overlays):
-- Create floating window
local buf = vim.api.nvim_create_buf(false, true) -- No file, scratch
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {'Hello', 'Floating', 'Window'})
local opts = {
relative = 'editor',
width = 40,
height = 10,
col = 10,
row = 5,
style = 'minimal',
border = 'rounded',
}
local win = vim.api.nvim_open_win(buf, true, opts)Center floating window:
local function create_centered_float()
local width = 80
local height = 20
local buf = vim.api.nvim_create_buf(false, true)
local opts = {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
}
vim.api.nvim_open_win(buf, true, opts)
return buf
end
vim.keymap.set('n', '<leader>fl', create_centered_float, { desc = 'Floating window' })Buffer API
List all buffers:
local buffers = vim.api.nvim_list_bufs()
for _, buf in ipairs(buffers) do
if vim.api.nvim_buf_is_loaded(buf) then
print(vim.api.nvim_buf_get_name(buf))
end
endCreate and modify buffer:
local buf = vim.api.nvim_create_buf(true, false) -- Listed, not scratch
vim.api.nvim_buf_set_name(buf, 'my-buffer.txt')
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {'Line 1', 'Line 2'})
vim.api.nvim_set_current_buf(buf)Get buffer info:
local buf = vim.api.nvim_get_current_buf()
local name = vim.api.nvim_buf_get_name(buf)
local line_count = vim.api.nvim_buf_line_count(buf)
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local modified = vim.bo[buf].modifiedWindow API
Get/set window info:
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win)
local cursor = vim.api.nvim_win_get_cursor(win) -- {line, col}
local width = vim.api.nvim_win_get_width(win)
local height = vim.api.nvim_win_get_height(win)
-- Set cursor
vim.api.nvim_win_set_cursor(win, {10, 5}) -- Line 10, column 5
-- Set dimensions
vim.api.nvim_win_set_width(win, 80)
vim.api.nvim_win_set_height(win, 30)List all windows:
local windows = vim.api.nvim_list_wins()
for _, win in ipairs(windows) do
print(vim.api.nvim_win_get_buf(win))
endTabpage API
-- List tabs
local tabs = vim.api.nvim_list_tabpages()
-- Get current tab
local current_tab = vim.api.nvim_get_current_tabpage()
-- Get windows in tab
local wins = vim.api.nvim_tabpage_list_wins(current_tab)
-- Set current tab
vim.api.nvim_set_current_tabpage(tabs[1])6.8 Plugin Recommendations
Buffer Management
bufferline.nvim - Beautiful buffer/tab line
require('bufferline').setup{
options = {
numbers = "buffer_id",
diagnostics = "nvim_lsp",
separator_style = "slant",
show_buffer_close_icons = true,
show_close_icon = false,
}
}harpoon - Quick buffer navigation
local mark = require("harpoon.mark")
local ui = require("harpoon.ui")
vim.keymap.set("n", "<leader>a", mark.add_file)
vim.keymap.set("n", "<C-e>", ui.toggle_quick_menu)
vim.keymap.set("n", "<C-h>", function() ui.nav_file(1) end)
vim.keymap.set("n", "<C-t>", function() ui.nav_file(2) end)Window Management
windows.nvim - Window picker and animator
require('windows').setup()
vim.keymap.set('n', '<leader>w', ':WindowsMaximize<CR>')smart-splits.nvim - Better window navigation and resizing
require('smart-splits').setup()
vim.keymap.set('n', '<C-h>', require('smart-splits').move_cursor_left)
vim.keymap.set('n', '<C-j>', require('smart-splits').move_cursor_down)
vim.keymap.set('n', '<C-k>', require('smart-splits').move_cursor_up)
vim.keymap.set('n', '<C-l>', require('smart-splits').move_cursor_right)Session Management
persistence.nvim - Auto-save sessions
require("persistence").setup()
vim.keymap.set("n", "<leader>qs", [[<cmd>lua require("persistence").load()<cr>]])
vim.keymap.set("n", "<leader>ql", [[<cmd>lua require("persistence").load({ last = true })<cr>]])Chapter Summary
In this chapter, you mastered Vim/Neovim’s workspace architecture:
Buffers:
Three-layer model: buffers → windows → tabs
Buffer lifecycle: create, navigate, delete
:ls,:b,:bd,:bn,:bpBuffer-local options (
setlocal,vim.bo)Scratch buffers for temporary work
Buffer operations:
:bufdoHidden buffers and alternate file (
<C-^>,:b#)
Windows:
Creating:
:split,:vsplit,:new,:vnewNavigation:
<C-w>hjkl,<C-w>w,<C-w>pResizing:
<C-w>=,:resize,:vertical resizeMoving:
<C-w>HJKL,<C-w>r,<C-w>xClosing:
:q,:close,:onlyWindow-local options (
vim.wo)Scroll binding and diff mode
Floating windows (Neovim)
Tabs:
Tab pages as layout containers
Creating:
:tabnew,:tabe,:tab {command}Navigation:
gt,gT,{n}gtManaging:
:tabc,:tabo,:tabmTab-local CWD (
:tcd)Tab-scoped variables (
vim.t)
Advanced Workflows:
Argument list (
:args,:argdo)Multi-file operations
Diff mode (
:diffsplit,]c,[c,do,dp)Session management (
:mksession)Project-based organization
Window layouts and zoom toggle
Neovim Features:
Floating windows API
Buffer/Window/Tabpage API
Lua-based automation
Plugin ecosystem
Key Insights:
Tabs are NOT file containers
Buffers persist independently of windows
One buffer can appear in multiple windows
Windows show viewports into buffers
Tabs organize window layouts
:bddeletes buffer,:qcloses window
Master this architecture to unlock powerful multi-file workflows, efficient navigation, and optimal workspace organization for complex projects.
In the next chapter, we’ll explore Registers and Macros, covering register types, yank/delete registers, named registers, macro recording and editing, recursive macros, and building sophisticated automation sequences.
Chapter 7: Macros and Registers
Vim’s register system transforms the editor into a programmable text manipulation engine. While most editors provide simple copy-paste functionality, Vim offers 48+ named storage locations for text, commands, and automation sequences. Combined with macros—recordable command sequences—registers become the foundation for eliminating repetitive editing tasks. This chapter explores the complete register taxonomy, macro recording and editing techniques, and advanced automation patterns that exponentially multiply your editing efficiency.
7.1 Understanding Registers
What Are Registers?
Registers are named storage containers that hold:
Yanked (copied) text
Deleted text
Recorded macro sequences
Search patterns
File paths
Expression results
Key concept: Every yank, delete, and paste operation interacts with registers. Understanding this system unlocks precise control over text manipulation.
The Register Namespace
Vim provides multiple register categories:
┌│─────────────────────────────────────────┐
││ Vim Register System │
├│─────────────────────────────────────────┤
││ ” - Unnamed (default) │
││ 0 - Yank register │
││ 1-9 - Delete history (1=recent) │
││ - - Small delete (<1 line) │
││ a-z - Named (lowercase = replace) │
││ A-Z - Named (uppercase = append) │
││ : - Last Ex command │
││ / - Last search pattern │
││ % - Current filename │
││ # - Alternate filename │
││ * - System clipboard (X11 PRIMARY) │
││ + - System clipboard (CLIPBOARD) │
││ _ - Black hole (null register) │
││ = - Expression register │
││ . - Last inserted text │ └─────────────────────────────────────────┘
Viewing Register Contents
:registers " Show all registers
:reg " Short form
:reg a b c " Show specific registers
:reg "0-9 " Show unnamed and numbered
Output example: Type Name Content c “” last deleted text c “0 last yanked text c”1 previous delete l “a Some text I saved c”+ system clipboard content c “: last command c”/ last search pattern
Neovim (Lua):
-- View specific register
print(vim.fn.getreg('a'))
print(vim.fn.getreg('+')) -- Clipboard
-- View all registers
vim.cmd('registers')Register Access Syntax
In Normal mode:
"{register} " Prefix for register access
"ayy " Yank line into register a
"ap " Paste from register a
"bdd " Delete line into register b
In Insert mode:
<C-r>{register} " Paste from register
<C-r>a " Paste register a
<C-r>+ " Paste clipboard
<C-r>= " Expression register
In Command mode:
<C-r>{register} " Insert register content
:%s/<C-r>a/new/g " Replace with content from register a
7.2 Register Categories
7.2.1 The Unnamed Register
(")
The default register for yank and delete operations.
yy " Yank line to unnamed register
dd " Delete line to unnamed register
p " Paste from unnamed register
""p " Explicitly paste from unnamed (same as p)
Important: The unnamed register is overwritten by most operations.
7.2.2 The Yank Register
(0)
Stores the last yanked text (not affected by deletions).
yiw " Yank word to both " and 0
dd " Delete line (updates " but not 0)
"0p " Paste the yanked word (not the deleted line!)
Use case:
" Copy a word to paste multiple times
yiw " Yank word
dd " Delete some line (overwrites ")
"0p " Still pastes the yanked word!
dd
"0p " Still works!
This is crucial: 0 preserves yanks
across deletions.
7.2.3 Numbered Registers
(1-9)
Circular delete history (automatic undo-like buffer).
"1 " Most recent deletion (>1 line)
"2 " Second most recent
"9 " Oldest in history
How it works:
dd " Delete line → goes to "1
dd " Delete another → previous moves to "2, new to "1
dd " Delete another → "3, "2, "1 shift
Cycling through delete history:
"1p " Paste most recent deletion
u " Undo
"2p " Paste second most recent
u
"3p " Paste third most recent
Better technique:
"1p " Paste from "1
. " Repeats last command: "2p (auto-increments!)
. " Pastes from "3
. " Pastes from "4
Magic insight: After "1p, the
. command automatically increments to "2p,
then "3p, etc!
7.2.4 Small Delete Register
(-)
Stores deletions smaller than one line.
diw " Delete word → goes to "-
x " Delete char → goes to "-
dd " Delete line → goes to "1 (not "-)
Use case:
diw " Delete word (goes to "-)
dd " Delete line (goes to "1)
"-p " Paste the deleted word (not the line)
7.2.5 Named Registers
(a-z, A-Z)
Manual storage for specific text snippets.
Lowercase = Replace:
"ayy " Yank line into register a
"byy " Yank line into register b
"ap " Paste from a
Uppercase = Append:
"ayy " Yank line into a
"Ayy " Append another line to a
"ap " Paste both lines!
Practical example:
" Collect function names from a file
/function " Search for function
"ayw " Yank word to register a
n " Next match
"Ayw " Append to register a (note uppercase A)
n
"Ayw " Append again
"ap " Paste all collected names
Building a list incrementally:
" Collect TODO items across file
/TODO
"aY " Yank line to register a
n
"AY " Append
n
"AY " Append
:new " New buffer
"ap " Paste all TODOs
7.2.6 Read-Only Registers
Filename registers:
"% " Current filename
"# " Alternate filename
Usage:
<C-r>% " Insert current filename (Insert mode)
:echo @% " Display current filename
:!gcc <C-r>% " Compile current file
Examples:
" In file: /home/user/project/main.lua
:echo @% " → project/main.lua (relative path)
:echo expand('%:p') " → /home/user/project/main.lua (absolute)
:echo expand('%:t') " → main.lua (tail/filename only)
:echo expand('%:r') " → project/main (remove extension)
Last command register (:):
@: " Repeat last Ex command
@@ " Repeat last @{register} command
Example:
:s/old/new/g " Substitute command
@: " Repeat substitution
@@ " Repeat again
Last search register (/):
echo @/ " Display last search pattern
let @/ = 'newpattern' " Set search pattern
:%s/<C-r>///new/g " Replace last searched pattern
Last inserted text (.):
".p " Paste last inserted text
<C-r>. " In Insert mode, repeat last insert
Example:
" In Insert mode, type: Hello World
<Esc> " Exit Insert mode
".p " Pastes "Hello World"
7.2.7 System Clipboard Registers
Primary selection (*):
"*yy " Yank to PRIMARY (middle-click paste)
"*p " Paste from PRIMARY
Clipboard (+):
"+yy " Yank to system clipboard
"+p " Paste from clipboard
<C-r>+ " Paste in Insert/Command mode
Platform differences:
Linux (X11): Both
*and+available*= PRIMARY selection (mouse selection)+= CLIPBOARD (Ctrl+C/Ctrl+V)
macOS/Windows: Usually only
+(CLIPBOARD)
Configuration (Neovim):
vim.opt.clipboard = 'unnamedplus' -- Use system clipboard by defaultNow y and p automatically use
+ register.
Check clipboard support:
:echo has('clipboard') " 1 if supported
:version " Look for +clipboard
7.2.8 Black Hole Register
(_)
Deletes without affecting any register (true deletion).
"_dd " Delete line, don't store anywhere
"_x " Delete char, don't store
"_d{motion} " Delete without polluting registers
Use case - Delete without clobbering:
yiw " Yank word to paste later
/garbage
"_dd " Delete garbage line (doesn't overwrite yank)
p " Paste original yanked word ✓
Common mapping:
-- Delete to black hole by default
vim.keymap.set('n', 'd', '"_d', { noremap = true })
vim.keymap.set('n', 'D', '"_D', { noremap = true })
vim.keymap.set('n', 'c', '"_c', { noremap = true })
vim.keymap.set('n', 'C', '"_C', { noremap = true })
-- Use leader for "cut" operations
vim.keymap.set('n', '<leader>d', 'd', { noremap = true, desc = 'Cut' })
vim.keymap.set('n', '<leader>D', 'D', { noremap = true, desc = 'Cut to EOL' })7.2.9 Expression Register
(=)
Evaluates Vimscript/Lua expressions and inserts the result.
In Insert mode:
<C-r>=2+2<CR> " Inserts: 4
<C-r>=system('date')<CR> " Inserts current date
In Command mode:
:put =range(1,10) " Insert numbers 1-10
:put =system('ls') " Insert directory listing
Examples:
" Insert current date
<C-r>=strftime('%Y-%m-%d')<CR> " 2025-10-19
" Math calculation
<C-r>=15*8<CR> " 120
" Environment variable
<C-r>=$USER<CR> " Current username
" System command output
<C-r>=system('whoami')<CR>
Neovim (Lua in expression register):
<C-r>=luaeval('vim.loop.cwd()')<CR> " Current directory
<C-r>=luaeval('math.random(1,100)')<CR> " Random number
Advanced - Multi-line expression:
:put =range(1, 5)
Inserts: 1 2 3 4 5
7.3 Macro Basics
What Are Macros?
Macros record a sequence of keystrokes into a register, which can be replayed to automate repetitive tasks.
Fundamental workflow:
Record keystrokes into a register
Replay the register as a macro
Repeat as needed (with counts)
Recording Macros
Syntax:
q{register} " Start recording into register
... perform edits ...
q " Stop recording
@{register} " Execute macro
@@ " Repeat last executed macro
Example - Add semicolons to lines:
let x = 10
let y = 20
let z = 30Record macro:
qa " Start recording into register a
A;<Esc> " Append semicolon and return to Normal mode
j " Move down
q " Stop recording
Execute:
@a " Run macro once (processes one line)
2@a " Run macro twice (processes two more lines)
Result:
let x = 10;
let y = 20;
let z = 30;Macro Execution
Basic execution:
@a " Execute macro in register a
@@ " Repeat last executed macro
With count:
5@a " Execute macro 5 times
100@a " Execute 100 times
Execute on visual selection:
:normal @a " Execute on current line
:'<,'>normal @a " Execute on visual selection
:%normal @a " Execute on all lines
Example - Macro on range:
:10,20normal @a " Execute macro on lines 10-20
Viewing Macro Contents
Since macros are stored in registers:
:reg a " View macro in register a
:echo @a " Display macro keystrokes
Output example: “a A;
Shows raw keystrokes: A (append), ;,
<Esc>, j (down).
7.4 Advanced Macro Techniques
7.4.1 Editing Macros
Macros are just text in registers—you can edit them!
Method 1: Paste, edit, yank back
:let @a=' " Display register a in command line
"ap " Paste macro into buffer
... edit the text ...
"ayy " Yank modified macro back to register a
Method 2: Direct register editing
:let @a='A;<Esc>j^' " Directly set register a
Method 3: Append to existing macro
qa " Start recording in a
... perform edits ...
q " Stop recording
qA " APPEND to register a (uppercase A)
... more edits ...
q " Stop recording
Example - Build macro incrementally:
" First, record basic formatting
qa
:s/\s\+$// " Remove trailing whitespace
j
q
" Later, add line numbering
qA " Append to register a
0i# <Esc> " Add "# " at start
j
q
" Now @a does both operations!
7.4.2 Parallel Registers
Use multiple registers for different tasks.
qa " Record macro in register a
... format code ...
q
qb " Record different macro in register b
... add comments ...
q
@a " Format current section
@b " Add comments
Macro library example:
" Register a: Convert to uppercase
qa
viwU
j
q
" Register b: Surround with quotes
qb
ciw"<C-r>""<Esc>
j
q
" Register c: Delete trailing spaces
qc
:s/\s\+$//
j
q
" Use as needed:
@a " Uppercase
@b " Quote
@c " Clean
7.4.3 Recursive Macros
Macros can call themselves to process entire files.
Pattern:
qa " Start recording
... edit current item ...
@a " Call macro recursively
q " Stop recording
Example - Process all lines:
" Add line numbers to all lines
qa " Start recording
I1. <Esc> " Insert "1. " at line start
j " Move down
@a " Recursive call
q " Stop recording
Execute:
@a " Runs until end of file (or error)
Important: The macro stops when it encounters an
error (e.g., j at last line fails).
Safe recursive macro:
" Better version with error handling
qa
I1. <Esc>
j
:if line('.') <= line('$') | @a | endif
q
Practical recursive example - Format JSON-like structure:
{name: John, age: 30, city: NYC}
{name: Jane, age: 25, city: LA}
{name: Bob, age: 35, city: SF}Macro to format:
qa
:s/, /,\r /g " Replace commas with newlines
j
@a
q
Result:
{name: John,
age: 30,
city: NYC}
{name: Jane,
age: 25,
city: LA}
...7.4.4 Conditional Macros
Use Ex commands with conditions.
qa
:if getline('.') =~ 'TODO' | s/TODO/DONE/ | endif
j
q
Only modifies lines containing “TODO”.
More complex example:
" Add semicolon only if line doesn't end with one
qa
:if getline('.') !~ ';$' | s/$/ ;/ | endif
j
q
7.4.5 Macro Patterns
Pattern 1: Position-then-action
qa
0 " Go to start of line
f( " Find opening parenthesis
% " Jump to closing parenthesis
a; " Append semicolon
j
q
Pattern 2: Search-and-modify
qa
/pattern<CR> " Search for pattern
cw replacement<Esc> " Replace word
n " Next match
q
Repeat with @@ or 100@a.
Pattern 3: Multi-step transformation
qa
I## <Esc> " Add markdown header
A {#id}<Esc> " Add anchor ID
j
q
Converts: Introduction
To: ## Introduction {#id}
7.4.6 Aborting Macros Safely
If a macro runs amok:
<C-c> " Interrupt execution
:messages " Check for error messages
Design macros to fail gracefully:
" Good: Fails at EOF without damage
qa
j " Moves down (fails at end)
dd
q
" Bad: Deletes incorrectly if j fails
qa
dd " Deletes current line
j " Might fail, but damage done
q
Best practice: Put navigation first, operations second.
7.5 Register and Macro Workflows
7.5.1 Copy-Paste Workflow
Basic workflow:
yy " Yank line to "
p " Paste from "
Multi-snippet workflow:
"ayy " Yank line to register a
"byy " Yank different line to register b
"cyy " Yank another to register c
"ap " Paste from a
"bp " Paste from b
"cp " Paste from c
Clipboard integration:
"+yy " Yank to system clipboard
"+p " Paste from system clipboard
Cross-file workflow:
" In file1.txt
"ayy " Yank line to register a
:e file2.txt " Open different file
"ap " Paste (registers persist across files!)
7.5.2 Template Expansion
Store boilerplate in registers.
Setup:
:let @h='<!DOCTYPE html>
\<html>
\ <head>
\ <title></title>
\ </head>
\ <body>
\
\ </body>
\</html>'
Usage:
"hp " Paste HTML template
Neovim (Lua) - Better approach:
vim.api.nvim_create_user_command('HTMLTemplate', function()
local template = {
'<!DOCTYPE html>',
'<html>',
' <head>',
' <title></title>',
' </head>',
' <body>',
' ',
' </body>',
'</html>'
}
vim.api.nvim_put(template, 'l', true, true)
end, {})Usage: :HTMLTemplate
7.5.3 Register as Scratch Space
Use named registers as temporary storage during complex edits.
" Save original line
"ayy
" Experiment with changes
ciwtest<Esc>
" Oops, want original back
"ap " Restore from register a
7.5.4 Macro Chains
Execute multiple macros in sequence.
qa " Macro a: Format
...
q
qb " Macro b: Add comment
...
q
@a@b " Run both in sequence
Or combine into one macro:
qc
@a@b
q
@c " Runs both a and b
7.5.5 Building Complex Macros
Incremental development:
- Start simple:
qa
I// <Esc> " Add comment
j
q
- Test on one line:
@a
- Verify result, then enhance:
qA " Append to register a
A<Esc> " Add space at end
k
q
- Test again:
@a
- Execute on range:
:5,10normal @a
Debugging macros:
:reg a " View macro content
"ap " Paste into buffer to inspect
Look for issues:
Missing
<Esc>to return to Normal modeWrong motion commands
Incorrect register usage
7.6 Advanced Register Manipulation
7.6.1 Programmatic Register Access
Vimscript:
:let @a = 'new content' " Set register a
:echo @a " Read register a
:let @a = @a . ' appended' " Append to register a
:let @a = substitute(@a, 'old', 'new', 'g') " Transform
Neovim (Lua):
vim.fn.setreg('a', 'new content') -- Set register a
print(vim.fn.getreg('a')) -- Read register a
vim.fn.setreg('a', vim.fn.getreg('a') .. ' appended') -- Append7.6.2 Register Types
Registers store type information:
Character-wise (c):
yiw " Yank word (character-wise)
"ap " Pastes inline
Line-wise (l):
yy " Yank line (line-wise)
"ap " Pastes as new line
Block-wise (b):
<C-v> " Visual block mode
y " Yank block
"ap " Pastes as block
Force register type:
:call setreg('a', "text", 'l') " Force line-wise
:call setreg('b', "text", 'c') " Force character-wise
Neovim (Lua):
vim.fn.setreg('a', 'text', 'l') -- Line-wise
vim.fn.setreg('b', 'text', 'c') -- Character-wise
vim.fn.setreg('c', 'text', 'b') -- Block-wise7.6.3 Swapping Register Contents
:let tmp = @a
:let @a = @b
:let @b = tmp
Neovim (Lua):
local tmp = vim.fn.getreg('a')
vim.fn.setreg('a', vim.fn.getreg('b'))
vim.fn.setreg('b', tmp)7.6.4 Clearing Registers
:let @a = '' " Clear register a
:let @/ = '' " Clear search register (removes highlight)
Neovim (Lua):
vim.fn.setreg('a', '')
vim.fn.setreg('/', '') -- Clear search highlightClear all named registers:
:for i in range(char2nr('a'), char2nr('z'))
: let @{nr2char(i)} = ''
:endfor
Neovim (Lua):
for i = string.byte('a'), string.byte('z') do
vim.fn.setreg(string.char(i), '')
end7.7 Practical Examples
Example 1: Format Function Calls
Before:
print(x)
print(y)
print(z)After:
console.log(x);
console.log(y);
console.log(z);Macro:
qa
^ " Go to line start
cw console.log<Esc> " Change "print" to "console.log"
A;<Esc> " Add semicolon
j
q
3@a " Execute 3 times
Example 2: Convert CSV to Markdown Table
Before: Name,Age,City John,30,NYC Jane,25,LA
After: | Name | Age | City | |——|—–|——| | John | 30 | NYC | | Jane | 25 | LA |
Macro:
qa
:s/,/ | /g " Replace commas with pipe separators
I| <Esc> " Add opening pipe
A |<Esc> " Add closing pipe
j
q
" Create separator line manually, then:
@a " Format data rows
Example 3: Add Line Numbers
Before: First line Second line Third line
After:
First line
Second line
Third line
Macro (simple, fixed numbering):
let @a = 'I1. ^j' " Hard-coded "1"
Better - Using expression register:
qa
:let @b = line('.') . '. ' " Get current line number
I<C-r>=@b<CR><Esc> " Insert it
j
q
Best - Using global command:
:let counter = 1
:g/^/s//\=counter . '. '/ | let counter += 1
Example 4: Extract URLs
Text with URLs: Check out https://example.com for more info. Visit https://github.com for code.
Extract to register:
qa
/https\?:\/\/[^ ]*<CR> " Search for URL
"Ayw " Append yank to register A
n
q
100@a " Collect all URLs
:new " New buffer
"Ap " Paste collected URLs
Example 5: Increment Numbers
Before: item 1 item 1 item 1
After: item 1 item 2 item 3
Macro using Ctrl-A (increment):
qa
j " Move down
<C-a> " Increment number under cursor
q
2@a " Increment next 2 lines
Example 6: Surround Words with Tags
Before: title subtitle content
After:title
subtitle
content
Macros:
" Macro a: h1 tag
:let @a = 'I<h1>^A</h1>^j'
" Macro b: h2 tag
:let @b = 'I<h2>^A</h2>^j'
" Macro c: p tag
:let @c = 'I<p>^A</p>^j'
@a " Apply h1 to first line
@b " Apply h2 to second line
@c " Apply p to third line
Or use visual selection + surround plugin:
" With vim-surround plugin
viw " Select word
S " Surround command
<h1> " Type opening tag (closing auto-added)
Example 7: Create Getters/Setters
From:
private String name;
private int age;To:
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
private int age;
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }Macro:
qa
yiw " Yank field name
A<CR>public <Esc> " Add newline, "public"
pa get<Esc> " Paste name with "get" prefix
~ " Uppercase first letter (or use built-in function)
A() { return <Esc>p " Add method body
A; }<Esc> " Close method
" ... similar for setter ...
j
q
This is complex—better to use a snippet plugin!
7.8 Register and Macro Tips
Tip 1: Preserve Yank Across Deletes
yiw " Yank word
dd " Delete line (overwrites ")
"0p " Paste from yank register (preserved!)
Tip 2: Quick Access to Numbered Registers
"1p " Paste most recent delete
. " Automatically pastes "2
. " Pastes "3
...
Tip 3: Clipboard Mappings
-- Map leader-y/p for clipboard operations
vim.keymap.set('n', '<leader>y', '"+y', { desc = 'Yank to clipboard' })
vim.keymap.set('v', '<leader>y', '"+y', { desc = 'Yank to clipboard' })
vim.keymap.set('n', '<leader>p', '"+p', { desc = 'Paste from clipboard' })
vim.keymap.set('n', '<leader>P', '"+P', { desc = 'Paste before from clipboard' })Tip 4: Macro Debugging
:reg a " View macro
"ap " Paste into buffer
" Inspect and edit
"ayy " Yank back to register
Tip 5: Safe Macro Execution
Always test on one line first:
qa " Record macro
... edits ...
q
@a " Test on current line
u " Undo if wrong
Then execute on range:
:5,50normal @a " Apply to lines 5-50
Tip 6: Macro Libraries
Create reusable macro collections:
" ~/.vim/macros.vim
let @f = 'I// ^j' " Comment with //
let @h = 'I# ^j' " Comment with #
let @q = 'ciw"^r""^j' " Quote word
Load in vimrc:
source ~/.vim/macros.vim
Neovim (Lua):
-- ~/.config/nvim/lua/macros.lua
vim.fn.setreg('f', 'I// \x1bj')
vim.fn.setreg('h', 'I# \x1bj')
-- Note: \x1b represents <Esc>Tip 7: Visual Block + Registers
Combine visual block mode with registers:
<C-v> " Visual block
jjj " Select multiple lines
"ay " Yank to register a
...
"ap " Paste as block
Tip 8: Expression Register Calculations
" Insert result of calculation
<C-r>=15*8<CR> " Inserts: 120
" Insert date
<C-r>=strftime('%Y-%m-%d')<CR>
" Insert line count
<C-r>=line('$')<CR>
Tip 9: Record Over Existing Macro
qa " Records new macro (overwrites old @a)
To preserve, use different register or append with
qA.
Tip 10: Macro Error Handling
Graceful failure:
qa
:try
j
dd
:catch
" Silently fail at EOF
:endtry
@a
q
Or rely on Vim’s natural error stopping:
qa
j " Fails at EOF, stops macro
dd
q
7.9 Neovim-Specific Features
Register API
-- Get register content
local content = vim.fn.getreg('a')
local regtype = vim.fn.getregtype('a') -- 'c', 'l', or 'b'
-- Set register content
vim.fn.setreg('a', 'new content', 'l') -- Line-wise
-- Append to register
vim.fn.setreg('a', vim.fn.getreg('a') .. '\nappended', 'l')Execute Macro from Lua
-- Execute macro in register a
vim.cmd('normal @a')
-- With count
vim.cmd('normal 10@a')
-- On range
vim.cmd('5,10normal @a')Dynamic Macro Generation
-- Generate macro based on conditions
local function create_macro()
if vim.bo.filetype == 'python' then
vim.fn.setreg('a', 'I# \x1bj') -- Python comment
elseif vim.bo.filetype == 'lua' then
vim.fn.setreg('a', 'I-- \x1bj') -- Lua comment
else
vim.fn.setreg('a', 'I// \x1bj') -- Default comment
end
end
vim.keymap.set('n', '<leader>mc', function()
create_macro()
vim.cmd('normal @a')
end, { desc = 'Comment with context-aware macro' })Register Inspection
-- List all non-empty registers
for i = string.byte('a'), string.byte('z') do
local reg = string.char(i)
local content = vim.fn.getreg(reg)
if content ~= '' then
print(reg .. ': ' .. content)
end
endPersistent Macros
Save macros across sessions:
-- Save macros on exit
vim.api.nvim_create_autocmd('VimLeave', {
callback = function()
local macros = {}
for i = string.byte('a'), string.byte('z') do
local reg = string.char(i)
local content = vim.fn.getreg(reg)
if content ~= '' then
macros[reg] = content
end
end
local file = io.open(vim.fn.stdpath('data') .. '/macros.json', 'w')
if file then
file:write(vim.fn.json_encode(macros))
file:close()
end
end
})
-- Load macros on startup
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
local file = io.open(vim.fn.stdpath('data') .. '/macros.json', 'r')
if file then
local content = file:read('*all')
file:close()
local macros = vim.fn.json_decode(content)
for reg, val in pairs(macros) do
vim.fn.setreg(reg, val)
end
end
end
})Chapter Summary
In this chapter, you mastered Vim’s register and macro systems:
Register System:
48+ registers for different purposes
Unnamed (
"): Default yank/deleteYank (
0): Preserves yanks across deletesNumbered (
1-9): Delete history with auto-incrementSmall delete (
-): Sub-line deletionsNamed (
a-z): Manual storage; uppercase appendsRead-only (
%,#,:,/,.): System informationClipboard (
*,+): System integrationBlack hole (
_): True deletion without storageExpression (
=): Evaluate and insert results
Macro Fundamentals:
q{register}to record,qto stop@{register}to execute,@@to repeatCount prefix:
10@aexecutes 10 timesExecute on range:
:'<,'>normal @a
Advanced Techniques:
Editing macros: Paste, modify, yank back
Appending:
qAappends to register ARecursive macros: Call
@awithin recording ofaConditional macros: Use
:ifwithin macroParallel registers: Multiple macros for different tasks
Macro chains:
@a@bexecutes both sequentially
Practical Workflows:
Preserve yanks with register
0Build snippet libraries in named registers
Use expression register for calculations/dates
Delete to black hole to avoid register pollution
Cycle through delete history with
.after"1pStore templates in registers for quick insertion
Best Practices:
Test macros on single line before batch execution
Put navigation before operations for safe failure
Use descriptive register names (mental mapping)
Clear search register (
let @/ = '') to remove highlightsLeverage clipboard registers for cross-application workflows
Build macros incrementally with testing at each step
Neovim Enhancements:
Lua API for register manipulation
Programmatic macro generation
Persistent macro storage
Dynamic register content based on context
Key Insights:
Registers are the foundation of Vim’s text manipulation
Every operation interacts with registers (explicitly or implicitly)
Macros are executable register contents
Understanding register types (character/line/block) enables precise operations
The yank register (
0) is crucial for copy-paste workflowsBlack hole register (
_) prevents register pollutionExpression register (
=) brings computational power to text editing
Master registers and macros to eliminate repetitive tasks, build automation sequences, and achieve editing speeds impossible in traditional editors. These tools transform Vim from a text editor into a programmable text manipulation engine.
In the next chapter, we’ll explore Text Objects and Motions, covering Vim’s composable grammar for selecting and operating on semantic text units—words, sentences, paragraphs, tags, and custom delimiters.
Chapter 8: Text Formatting and Layout
Vim excels at transforming text structure—reformatting paragraphs, aligning columns, managing indentation, and controlling whitespace. While most editors provide basic formatting through menus and buttons, Vim’s command-line interface and operator grammar enable surgical precision and batch operations. This chapter covers Vim’s comprehensive formatting toolkit: indentation control, text wrapping, joining and splitting lines, case transformation, alignment techniques, whitespace management, and specialized formatting for code and prose.
8.1 Indentation
8.1.1 Manual Indentation
Basic operators:
>> " Indent line (shift right)
<< " De-indent line (shift left)
== " Auto-indent line
>{motion} " Indent motion target
<{motion} " De-indent motion target
={motion} " Auto-indent motion target
Examples:
>> " Indent current line
3>> " Indent 3 lines (current + 2 below)
>} " Indent to end of paragraph
>G " Indent to end of file
>'a " Indent to mark a
Visual mode indentation:
V " Select line
> " Indent selection
< " De-indent selection
= " Auto-indent selection
Repeat indentation:
>> " Indent once
. " Indent again (repeat)
. " And again...
Visual block indentation:
<C-v> " Visual block mode
jjj " Select multiple lines
> " Indent block
Indent in Insert mode:
<C-t> " Indent current line (in Insert mode)
<C-d> " De-indent current line (in Insert mode)
Example:
# In Insert mode, typing:
def hello():<CR>
<C-t>print("Hello") # Indents the print statement8.1.2 Indentation Settings
Key options:
:set tabstop=4 " Tab character displays as 4 spaces
:set shiftwidth=4 " Indent/de-indent uses 4 spaces
:set expandtab " Convert tabs to spaces
:set softtabstop=4 " Tab key inserts 4 spaces (with expandtab)
:set autoindent " Copy indent from current line
:set smartindent " Smart auto-indenting for C-like files
Neovim (Lua):
vim.opt.tabstop = 4 -- Tab width
vim.opt.shiftwidth = 4 -- Indent width
vim.opt.expandtab = true -- Use spaces instead of tabs
vim.opt.softtabstop = 4 -- Tab key behavior
vim.opt.autoindent = true -- Maintain indent on new lines
vim.opt.smartindent = true -- Smart indentingCommon configurations:
Spaces (Python, Lua):
vim.opt.expandtab = true
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4Tabs (Go, Makefiles):
vim.opt.expandtab = false
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4Per-filetype settings:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.expandtab = true
end
})
vim.api.nvim_create_autocmd('FileType', {
pattern = 'javascript',
callback = function()
vim.opt_local.tabstop = 2
vim.opt_local.shiftwidth = 2
vim.opt_local.expandtab = true
end
})EditorConfig support:
-- Install editorconfig-vim plugin or use Neovim 0.9+ built-in
vim.g.editorconfig = true -- Neovim 0.9+8.1.3 Auto-Indenting Entire File
Indent whole file:
gg=G " Go to top, auto-indent to bottom
Breakdown:
gg- Jump to first line=- Auto-indent operatorG- Motion to last line
Alternative with range:
:1,$= " Indent lines 1 to end
:%= " Same (% means all lines)
Visual selection:
ggVG " Select entire file
= " Auto-indent
Maintain cursor position:
" Save position, indent, restore
let save_pos = getpos('.')
normal! gg=G
call setpos('.', save_pos)
Neovim (Lua function):
vim.keymap.set('n', '<leader>fi', function()
local save_pos = vim.api.nvim_win_get_cursor(0)
vim.cmd('normal! gg=G')
vim.api.nvim_win_set_cursor(0, save_pos)
end, { desc = 'Format indentation' })8.1.4 Reindent Specific Ranges
:10,20> " Indent lines 10-20
:10,20>> " Indent twice (two levels)
:10,20< " De-indent lines 10-20
:'<,'> " Visual selection range (automatic)
:'<,'>> " Indent visual selection
Using marks:
ma " Set mark a
... move cursor ...
mb " Set mark b
:'a,'b> " Indent between marks
8.1.5 Indent-Preserving Operations
Problem: Pasting code loses indentation.
Solutions:
Method 1: Adjust indentation after paste:
p " Paste
V " Select pasted lines
= " Auto-indent
Method 2: Use ]p (paste and
adjust):
]p " Paste and match indent to current line
[p " Paste above and match indent
Method 3: Bracket paste mode:
:set paste " Enter paste mode (disables auto-indent)
" Paste from system clipboard (Ctrl+Shift+V)
:set nopaste " Exit paste mode
Better - Toggle with key:
:set pastetoggle=<F2>
Neovim - Auto-detect paste:
vim.opt.paste = false -- Neovim handles this automaticallyNeovim detects bracketed paste automatically (no
:set paste needed).
8.1.6 Indent Text Objects
Select by indentation level:
" Requires vim-indent-object plugin or similar
vii " Select inner indent block (same level)
vai " Select indent block including line above
vii " Select including lines above and below
Manual equivalent:
" Select paragraph with same indent
V " Line select
} " To end of paragraph
8.2 Line Joining and Splitting
8.2.1 Joining Lines
Basic join:
J " Join current line with next (adds space)
gJ " Join without adding space
Examples:
Before: Hello World
After J: Hello World
After gJ: HelloWorld
Join with count:
3J " Join next 3 lines
Before: Line 1 Line 2 Line 3 Line 4
After 3J: Line 1 Line 2 Line 3 Line 4
Join in Visual mode:
V " Line select
jj " Select 3 lines
J " Join selected lines
Join specific range:
:10,15j " Join lines 10-15
:10,15j! " Join without spaces (like gJ)
8.2.2 Splitting Lines
Vim doesn’t have a built-in “split line” command, but various techniques exist.
Method 1: Replace with newline:
:s/ /\r/g " Replace spaces with newlines
Before: one two three four
After: one two three four
Method 2: Insert mode split:
" Position cursor where you want to split
i<CR><Esc> " Enter Insert mode, press Enter, exit
Method 3: Substitute at specific position:
:s/,/,\r/g " Split at commas
Before: apple,banana,cherry
After: apple, banana, cherry
Method 4: Visual selection + substitute:
" Select text
:'<,'>s/, /,\r /g " Split at commas with indent
Smart split function:
-- Neovim: Split line at cursor
vim.keymap.set('n', '<leader>sl', function()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local before = line:sub(1, col)
local after = line:sub(col + 1)
vim.api.nvim_set_current_line(before)
local row = vim.api.nvim_win_get_cursor(0)[1]
vim.api.nvim_buf_set_lines(0, row, row, false, { after })
end, { desc = 'Split line at cursor' })8.2.3 Join with Separator
Add separator while joining:
:5,10s/$/,/ " Add comma to end of lines 5-10
:5,10j " Join the lines
Result: line1,line2,line3,...
One-step approach:
:5,10j | s/ /, /g " Join and replace spaces with comma-space
8.3 Text Wrapping and Formatting
8.3.1 Hard Wrapping (Text Reflow)
Format paragraph:
gq} " Format to end of paragraph
gqap " Format around paragraph (text object)
gqG " Format to end of file
How it works:
Reads
textwidthoptionReflows text to fit within width
Preserves paragraph structure
Set text width:
:set textwidth=80 " Wrap at 80 columns
Neovim (Lua):
vim.opt.textwidth = 80Format specific range:
:10,20gq " Format lines 10-20
:'<,'>gq " Format visual selection
Examples:
Before (textwidth=40): This is a very long line that exceeds the text width limit and needs to be reformatted.
After gqq (format current line): This is a very long
line that exceeds the text width limit and needs to be
reformatted.
Auto-format while typing:
:set formatoptions+=t " Auto-wrap text using textwidth
:set formatoptions+=c " Auto-wrap comments
:set formatoptions+=r " Auto-insert comment leader on <Enter>
Neovim (Lua):
vim.opt.formatoptions:append('t') -- Auto-wrap text
vim.opt.formatoptions:append('c') -- Auto-wrap comments
vim.opt.formatoptions:append('r') -- Continue commentsDisable auto-wrap:
vim.opt.formatoptions:remove('t')
vim.opt.formatoptions:remove('c')8.3.2 Soft Wrapping (Visual Only)
Soft wrap doesn’t modify the file—just how lines display.
:set wrap " Enable soft wrap (default)
:set nowrap " Disable soft wrap
Wrap options:
:set linebreak " Break at word boundaries (not mid-word)
:set breakindent " Preserve indent on wrapped lines
:set showbreak=↪\ " Show symbol at wrap point
Neovim (Lua):
vim.opt.wrap = true -- Soft wrap
vim.opt.linebreak = true -- Break at words
vim.opt.breakindent = true -- Indent wrapped lines
vim.opt.showbreak = '↪ ' -- Wrap indicatorNavigate wrapped lines:
gj " Move down one display line
gk " Move up one display line
g0 " Go to start of display line
g$ " Go to end of display line
Mapping for natural movement:
-- Make j/k move by display lines in wrapped text
vim.keymap.set('n', 'j', 'gj', { noremap = true })
vim.keymap.set('n', 'k', 'gk', { noremap = true })
vim.keymap.set('n', 'gj', 'j', { noremap = true })
vim.keymap.set('n', 'gk', 'k', { noremap = true })8.3.3 Format Options
The formatoptions setting controls auto-formatting
behavior.
:set formatoptions? " Show current settings
Common format options:
| Flag | Description |
|---|---|
t |
Auto-wrap text using textwidth |
c |
Auto-wrap comments |
r |
Auto-insert comment leader after <Enter> in
Insert mode |
o |
Auto-insert comment leader after o or
O in Normal mode |
q |
Allow formatting comments with gq |
a |
Auto-format paragraphs (aggressive) |
n |
Recognize numbered lists when formatting |
2 |
Use second line’s indent for paragraph |
j |
Remove comment leader when joining lines |
l |
Don’t break long lines in Insert mode |
Recommended settings:
vim.opt.formatoptions = 'jcroqlnt'
-- j: Remove comment leader when joining
-- c: Auto-wrap comments
-- r: Continue comments on Enter
-- o: Continue comments on o/O
-- q: Allow gq to format comments
-- l: Don't break long lines
-- n: Recognize numbered lists
-- t: Auto-wrap textPer-filetype:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'markdown',
callback = function()
vim.opt_local.formatoptions = 'jcroqln2t'
vim.opt_local.textwidth = 80
end
})
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.opt_local.formatoptions = 'jcroql'
vim.opt_local.textwidth = 88 -- Black formatter default
end
})8.3.4 External Formatting Programs
Use external formatter with =
operator:
:set formatprg=prettier\ --stdin-filepath\ %
Now = uses Prettier instead of Vim’s built-in
formatting.
Format with external command:
:%!prettier --stdin-filepath %
:%!black - " Python formatter
:%!rustfmt " Rust formatter
:%!gofmt " Go formatter
Format range:
:'<,'>!prettier --stdin-filepath %
Neovim (Lua) - LSP formatting:
vim.keymap.set('n', '<leader>f', function()
vim.lsp.buf.format({ async = true })
end, { desc = 'Format buffer' })Using formatters via LSP:
-- In LSP attach function
vim.keymap.set('n', '<leader>f', function()
vim.lsp.buf.format({
async = true,
filter = function(client)
-- Use specific formatter
return client.name == 'null-ls' or client.name == 'efm'
end
})
end, { buffer = bufnr, desc = 'Format buffer' })8.4 Case Transformation
8.4.1 Basic Case Operations
Toggle case:
~ " Toggle case of character under cursor
g~{motion} " Toggle case of motion target
g~~ " Toggle case of line
g~iw " Toggle case of word
Uppercase:
gU{motion} " Uppercase motion target
gUU " Uppercase line
gUiw " Uppercase word
gUG " Uppercase to end of file
Lowercase:
gu{motion} " Lowercase motion target
guu " Lowercase line
guiw " Lowercase word
Examples:
Before: Hello World
After g~~: hELLO wORLD
After gUU: HELLO WORLD
After guu: hello world
8.4.2 Visual Mode Case Changes
v " Character select
~ " Toggle case
U " Uppercase
u " Lowercase
Example workflow:
viw " Select word
U " Uppercase → WORD
8.4.3 Title Case and Sentence Case
Vim doesn’t have built-in title case, but you can use substitution:
Capitalize first letter:
:s/\<\(\w\)/\u\1/g " Capitalize first letter of each word
Before: the quick brown fox
After: The Quick Brown Fox
Capitalize only first letter of sentence:
:s/\.\s*\(\w\)/\.\r\u\1/g
Smart title case function:
-- Neovim: Title case with exceptions
vim.keymap.set('n', '<leader>tc', function()
local line = vim.api.nvim_get_current_line()
local small_words = { 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'up' }
-- Split into words, capitalize appropriately
local words = {}
for word in line:gmatch('%S+') do
local lower = word:lower()
if vim.tbl_contains(small_words, lower) and #words > 0 then
table.insert(words, lower)
else
table.insert(words, word:sub(1,1):upper() .. word:sub(2):lower())
end
end
vim.api.nvim_set_current_line(table.concat(words, ' '))
end, { desc = 'Title case line' })8.4.4 Case in Substitutions
Preserve case:
:%s/old/new/g " Normal replacement
:%s/old/new/gi " Case-insensitive search
Force case in replacement:
:%s/old/\Lnew/g " Lowercase replacement
:%s/old/\Unew/g " Uppercase replacement
Special replacement atoms:
\u- Uppercase next character\U- Uppercase until\Eor\e\l- Lowercase next character\L- Lowercase until\Eor\e\eor\E- End case conversion
Examples:
:%s/\<\(\w\)/\u\1/g " Capitalize first letter
:%s/\<\(\w\+\)/\U\1/g " All caps
:%s/\<\(\w\)\(\w*\)/\u\1\L\2/g " Title case each word
8.5 Alignment and Columns
8.5.1 Visual Block Alignment
Insert at column position:
<C-v> " Visual block
jjj " Select lines
I " Insert at start of block
" Type text
<Esc> " Apply to all lines
Example - Add comments:
Before: int x = 10 int y = 20 int z = 30
After <C-v>jjI// <Esc>: // int x = 10 //
int y = 20 // int z = 30
Append at end:
<C-v> " Visual block
jjj
$ " Extend to end of lines
A " Append
; " Type semicolon
<Esc>
8.5.2 Align at Character
Manual alignment using substitute:
Before: name = “John” age = 30 city = “NYC”
Align at =:
:5,7s/\s*=\s*/ = /
Better - align to column:
" Find longest line before =, then pad
Using external tools:
:%!column -t " Align columns (Unix tool)
Before: name John 30 age 25 50 city NYC 100
After :%!column -t: name John 30 age 25 50 city NYC
100
Tabular plugin (popular):
" Install junegunn/vim-easy-align or godlygeek/tabular
:Tabularize /= " Align at =
:Tabularize /, " Align at ,
8.5.3 Aligning Comments
Example - Align end-of-line comments:
Before:
x = 10 # Variable x
y = 200 # Variable y
z = 3 # Variable zUsing Tabular:
:Tabularize /#
After:
x = 10 # Variable x
y = 200 # Variable y
z = 3 # Variable z8.5.4 Number Alignment
Right-align numbers:
" Select numbers
<C-v>
" Right-align (manually space or use plugin)
Using external tool:
:%!awk '{printf "%10s\n", $1}'
8.6 Whitespace Management
8.6.1 Trailing Whitespace
Remove trailing whitespace:
:%s/\s\+$// " Remove from all lines
:5,10s/\s\+$// " Remove from lines 5-10
Visual feedback:
:set list " Show invisible characters
:set listchars=trail:·,tab:→\ ,nbsp:␣
Neovim (Lua):
vim.opt.list = true
vim.opt.listchars = {
trail = '·',
tab = '→ ',
nbsp = '␣',
extends = '⟩',
precedes = '⟨',
}Auto-remove on save:
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
callback = function()
-- Save cursor position
local save_pos = vim.api.nvim_win_get_cursor(0)
-- Remove trailing whitespace
vim.cmd([[%s/\s\+$//e]])
-- Restore cursor
vim.api.nvim_win_set_cursor(0, save_pos)
end
})Selective removal (avoid breaking Markdown):
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = { '*.lua', '*.py', '*.js', '*.ts', '*.rs' },
callback = function()
local save_pos = vim.api.nvim_win_get_cursor(0)
vim.cmd([[%s/\s\+$//e]])
vim.api.nvim_win_set_cursor(0, save_pos)
end
})8.6.2 Leading Whitespace
Remove leading spaces:
:%s/^\s\+// " Remove all leading whitespace
Normalize to tabs:
:%s/^\s\+/\=repeat("\t", len(submatch(0))/&tabstop)/
8.6.3 Multiple Blank Lines
Collapse multiple blank lines to one:
:%s/\n\n\+/\r\r/ " Reduce to max 2 newlines (1 blank line)
Remove all blank lines:
:g/^$/d " Global delete empty lines
Remove blank lines in range:
:10,20g/^$/d
8.6.4 Tab/Space Conversion
Convert tabs to spaces:
:set expandtab " Enable space mode
:retab " Convert existing tabs
Convert spaces to tabs:
:set noexpandtab " Enable tab mode
:retab! " Force conversion
Convert leading spaces only:
:%s/^\s\+/\=repeat("\t", len(submatch(0))/&shiftwidth)/
8.7 Line Manipulation
8.7.1 Sorting Lines
Sort entire file:
:%sort " Alphabetical ascending
:%sort! " Descending
Sort range:
:10,20sort " Sort lines 10-20
:'<,'>sort " Sort visual selection
Sort options:
:sort i " Case-insensitive
:sort n " Numerical sort
:sort u " Remove duplicates
:sort! n " Numerical descending
Examples:
Before: 30 10 20
After :sort n: 10 20 30
Sort by column:
:sort /.*\%5c/ " Sort by character at column 5
Sort by pattern:
:sort /\d\+/ " Sort by first number in line
8.7.2 Removing Duplicates
Remove duplicate lines:
:sort u " Sort and unique
Remove duplicates without sorting:
" Requires external tool
:%!uniq
Or use Vimscript:
:let seen = {}
:g/^/if has_key(seen, getline('.')) | d | else | let seen[getline('.')] = 1 | endif
Neovim (Lua) - Remove duplicates:
vim.keymap.set('n', '<leader>du', function()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local seen = {}
local unique = {}
for _, line in ipairs(lines) do
if not seen[line] then
seen[line] = true
table.insert(unique, line)
end
end
vim.api.nvim_buf_set_lines(0, 0, -1, false, unique)
end, { desc = 'Remove duplicate lines' })8.7.3 Reversing Lines
Reverse entire file:
:g/^/m0 " Global move to top (reverses order)
Reverse range:
:10,20g/^/m9 " Reverse lines 10-20
Visual selection:
:'<,'>g/^/m'<
Using external tool:
:%!tac " Unix tac command (reverse of cat)
8.7.4 Moving Lines
Move line up/down:
:m +1 " Move current line down
:m -2 " Move current line up
Move range:
:5,10m 20 " Move lines 5-10 to after line 20
Common mappings:
-- Move line up/down
vim.keymap.set('n', '<A-j>', ':m .+1<CR>==', { noremap = true, silent = true })
vim.keymap.set('n', '<A-k>', ':m .-2<CR>==', { noremap = true, silent = true })
-- Move visual selection up/down
vim.keymap.set('v', '<A-j>', ":m '>+1<CR>gv=gv", { noremap = true, silent = true })
vim.keymap.set('v', '<A-k>', ":m '<-2<CR>gv=gv", { noremap = true, silent = true })8.8 Text Width and Columns
8.8.1 Column Ruler
Show vertical line at column:
:set colorcolumn=80 " Show line at column 80
:set colorcolumn=80,120 " Multiple columns
:set colorcolumn= " Disable
Neovim (Lua):
vim.opt.colorcolumn = '80'
-- Or relative to textwidth:
vim.opt.colorcolumn = '+1' -- One column after textwidthHighlight over-length lines:
:match ErrorMsg /\%>80v.\+/
8.8.2 Virtual Edit Mode
Allow cursor past end of line:
:set virtualedit=all " Cursor anywhere
:set virtualedit=block " Only in Visual Block mode
:set virtualedit=insert " Only in Insert mode
:set virtualedit= " Disable
Useful for aligning text in Visual Block mode.
8.8.3 Cursor Column Display
:set cursorcolumn " Highlight current column
:set nocursorcolumn " Disable
Combined with cursor line:
vim.opt.cursorline = true
vim.opt.cursorcolumn = true8.9 Code Formatting
8.9.1 Comment Toggling
Manual commenting:
I// <Esc> " Add // comment
:s/^/\/\/ / " Substitute-based
Visual block comments:
<C-v>jjI// <Esc> " Add // to multiple lines
Using commentary.vim plugin:
gcc " Toggle comment on line
gc{motion} " Comment motion target
gcap " Comment paragraph
Neovim - Comment.nvim:
-- Install numToStr/Comment.nvim
require('Comment').setup()
-- Usage:
-- gcc - Line comment
-- gbc - Block comment
-- gc{motion} - Comment motion8.9.2 Code Blocks
Surround with braces:
" Select lines
V
jjj
S} " vim-surround plugin
Manual method:
O{<Esc> " Add opening brace above
jo}<Esc> " Add closing brace below
=iB " Re-indent inside braces
8.9.3 Function/Method Formatting
Reformat function arguments:
Before:
function test(arg1, arg2, arg3, arg4, arg5) {After (multi-line):
function test(
arg1,
arg2,
arg3,
arg4,
arg5
) {Macro for argument splitting:
qa
f( " Find opening paren
a<CR><Esc> " Add newline after
f, " Find comma
a<CR> <Esc> " Add newline with indent
@a " Recursive call
q
Better - use LSP formatter or Prettier.
8.10 Specialized Formatting
8.10.1 Table Formatting
ASCII table creation:
" Using external tool
:%!column -t -s '|'
Markdown table:
Before: Name|Age|City John|30|NYC Jane|25|LA
After formatting: | Name | Age | City | |——|—–|——| | John | 30 | NYC | | Jane | 25 | LA |
Using vim-table-mode plugin:
:TableModeEnable
" Type: | Name | Age | City
" Automatically formats on ||
8.10.2 List Formatting
Bullet lists:
:set formatoptions+=n " Recognize numbered lists
Auto-numbering:
" Select lines
:'<,'>!nl -s '. '
Or macro:
" Add sequential numbers
let counter = 1
qa
I<C-r>=counter<CR>. <Esc>
:let counter += 1
j
q
8.10.3 JSON/XML Formatting
JSON:
:%!jq . " Format JSON (requires jq)
:%!python -m json.tool
XML:
:%!xmllint --format -
Minify JSON:
:%!jq -c .
8.11 Practical Formatting Workflows
Workflow 1: Clean Up Pasted Code
" Paste code
p
" Fix indentation
=ip
" Remove trailing whitespace
:s/\s\+$//
" Convert tabs to spaces
:retab
Or create mapping:
vim.keymap.set('n', '<leader>cp', function()
vim.cmd('normal! =ip') -- Reindent paragraph
local line = vim.fn.line('.')
vim.cmd(line .. 's/\\s\\+$//e') -- Remove trailing space
vim.cmd('retab') -- Convert tabs
end, { desc = 'Clean pasted code' })Workflow 2: Format Entire File
vim.keymap.set('n', '<leader>ff', function()
-- Save position
local save_pos = vim.api.nvim_win_get_cursor(0)
-- Format
vim.cmd('normal! gg=G') -- Reindent
vim.cmd('%s/\\s\\+$//e') -- Remove trailing whitespace
vim.cmd('%s/\\n\\n\\n\\+/\\r\\r/e') -- Collapse blank lines
-- Restore position
vim.api.nvim_win_set_cursor(0, save_pos)
print('File formatted')
end, { desc = 'Format entire file' })Workflow 3: Format Selection
vim.keymap.set('v', '<leader>f', function()
vim.cmd("'<,'>normal! =") -- Reindent
vim.cmd("'<,'>s/\\s\\+$//e") -- Clean trailing space
end, { desc = 'Format selection' })Workflow 4: Smart Auto-Format on Save
-- Auto-format specific filetypes on save
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = { '*.lua', '*.py', '*.js', '*.ts', '*.rs', '*.go' },
callback = function()
-- Try LSP formatting first
if vim.lsp.buf.format then
vim.lsp.buf.format({ timeout_ms = 2000 })
else
-- Fallback to built-in formatting
local save_pos = vim.api.nvim_win_get_cursor(0)
vim.cmd('normal! gg=G')
vim.api.nvim_win_set_cursor(0, save_pos)
end
-- Remove trailing whitespace
vim.cmd('%s/\\s\\+$//e')
end
})Chapter Summary
This chapter covered Vim’s comprehensive text formatting toolkit:
Indentation:
>>,<<,==for manual indentinggg=Gto auto-indent entire file>},<ap,=Gfor motion-based indenting<C-t>,<C-d>for Insert mode indentingConfigure with
tabstop,shiftwidth,expandtab,autoindentPer-filetype settings via autocmds
Joining and Splitting:
Jjoins lines with spacegJjoins without space:s/pattern/\r/gsplits at patternRange joining:
:10,15j
Text Wrapping:
Hard wrap with
gq{motion},gqap,gqGConfigure with
textwidthandformatoptionsSoft wrap with
wrap,linebreak,breakindentNavigate with
gj,gk,g0,g$
Case Transformation:
~,g~{motion}toggle casegU{motion}uppercasegu{motion}lowercaseSubstitution modifiers:
\u,\U,\l,\L
Alignment:
Visual block:
<C-v>+IorAExternal tools:
column -t, Tabular pluginLSP formatters for code alignment
Whitespace Management:
Remove trailing:
:%s/\s\+$//Show invisibles:
set list,listcharsAuto-remove on save via autocmd
Tab/space conversion:
:retab,:retab!
Line Manipulation:
Sort:
:sort,:sort n,:sort uReverse:
:g/^/m0Move:
:m +1,:5,10m 20Remove duplicates:
:sort uor custom function
Code Formatting:
Comment toggling via plugins (Comment.nvim, commentary.vim)
Visual block commenting
LSP integration:
vim.lsp.buf.format()External formatters:
:%!prettier,:%!black -
Specialized Formatting:
JSON:
:%!jq .XML:
:%!xmllint --format -Tables: vim-table-mode, column tool
Lists: auto-numbering, format options
Best Practices:
Set
textwidthfor prose, disable for codeUse
formatoptionsto control auto-formattingLeverage LSP formatters over manual indenting
Create filetype-specific formatting rules
Map frequently used formatting to leader keys
Auto-format on save for consistent code style
Use soft wrap for prose, hard wrap sparingly
Combine operations in macros for complex formatting
Key Insights:
Formatting is composable: combine operators with motions
External tools (jq, prettier, black) integrate seamlessly
LSP provides language-aware formatting
Visual block mode is powerful for column editing
Format options control automatic behavior
Whitespace visibility aids manual cleanup
Auto-formatting on save ensures consistency
Master these formatting techniques to maintain clean, consistent code and prose. Vim’s formatting commands combine with its operator grammar to enable surgical precision and bulk operations—from indenting a single line to reformatting an entire project.
In the next chapter, we’ll explore Advanced Navigation, covering marks, jumps, tags, fuzzy finding, and file navigation systems that enable lightning-fast movement across your codebase.
Chapter 9: File Operations and Sessions
Vim’s file operations extend far beyond simple save and open commands. This chapter explores Vim’s sophisticated file management system: reading and writing files, working with multiple files simultaneously, managing sessions and views, leveraging the argument list, and integrating with external file operations. We’ll cover both traditional Vim workflows and modern Neovim enhancements that transform Vim into a powerful workspace manager.
9.1 Basic File Operations
9.1.1 Opening Files
Single file:
:e filename.txt " Edit file (relative to current directory)
:e! filename.txt " Reload file, discard changes
:e " Reload current file from disk
:e! " Reload current file, discard changes
Absolute paths:
:e /path/to/file.txt
:e ~/documents/file.txt
:e $HOME/.config/nvim/init.lua
Create new file:
:e newfile.txt " Creates buffer, saved on :w
:enew " Create unnamed buffer
:enew! " Create buffer, discard changes
Open with line number:
:e +25 file.txt " Open at line 25
:e +/pattern file.txt " Open at first pattern match
:e +$ file.txt " Open at last line
From command line:
vim file.txt
vim +25 file.txt # Open at line 25
vim "+normal 10gg" file.txt # Execute Normal mode command
vim -c "set number" file.txt # Execute Ex command
vim -o file1.txt file2.txt # Open in horizontal splits
vim -O file1.txt file2.txt # Open in vertical splits
vim -p file1.txt file2.txt # Open in tabsNeovim additions:
nvim --headless -c "cmd" file.txt # Headless mode (scripting)
nvim -d file1.txt file2.txt # Diff mode9.1.2 Writing Files
Basic save:
:w " Write current buffer
:w! " Force write (override protections)
:w filename.txt " Write to new filename (doesn't change buffer name)
:w >> file.txt " Append to file
Save specific range:
:5,10w partial.txt " Write lines 5-10 to new file
:'<,'>w selection.txt " Write visual selection
:.w line.txt " Write current line
Save and quit:
:wq " Write and quit
:x " Write (if changes) and quit
ZZ " Same as :x (Normal mode)
:wqa " Write all buffers and quit all
:xa " Write changed buffers and quit all
Save with sudo:
:w !sudo tee % " Write with elevated permissions
Neovim (Lua) - Create mapping:
vim.keymap.set('n', '<leader>W', ':w !sudo tee %<CR>',
{ desc = 'Save with sudo' })Write to command output:
:w !wc -l " Pipe buffer to word count
:5,10w !sort " Pipe lines 5-10 to sort
9.1.3 Reading Files
Insert file contents:
:r filename.txt " Read file below current line
:0r filename.txt " Read at top of file
:$r filename.txt " Read at end of file
:5r filename.txt " Read after line 5
Read command output:
:r !ls " Insert ls output
:r !date " Insert current date
:r !curl https://api.example.com/data
Neovim example - Insert template:
vim.keymap.set('n', '<leader>it', function()
local template = vim.fn.expand('~/.config/nvim/templates/python.txt')
vim.cmd('0r ' .. template)
end, { desc = 'Insert Python template' })9.1.4 File Information
Show file info:
:f " Show filename, position, status
:file " Same as :f
<C-g> " Quick file info (Normal mode)
g<C-g> " Word count, character count
Output example: “init.lua” line 42 of 150 –28%– col 15
Detailed statistics:
g<C-g> " Shows:
" Column, Line, Word, Char, Byte position
File type detection:
:set filetype? " Show detected filetype
:set filetype=python " Manually set filetype
Neovim (Lua) - Custom status info:
vim.keymap.set('n', '<leader>fi', function()
local bufnr = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_line_count(bufnr)
local filename = vim.api.nvim_buf_get_name(bufnr)
local filetype = vim.bo.filetype
local cursor = vim.api.nvim_win_get_cursor(0)
print(string.format(
"File: %s | Type: %s | Lines: %d | Cursor: %d,%d",
filename, filetype, lines, cursor[1], cursor[2]
))
end, { desc = 'Show file info' })9.1.5 File Existence and Type Checks
Check if file exists:
:if filereadable('file.txt')
: echo "File exists"
:endif
Neovim (Lua):
if vim.fn.filereadable('file.txt') == 1 then
print('File exists and is readable')
end
-- Or using Lua filesystem
local uv = vim.loop
local stat = uv.fs_stat('file.txt')
if stat and stat.type == 'file' then
print('File exists')
endCheck if directory exists:
:if isdirectory('path/to/dir')
: echo "Directory exists"
:endif
Get file extension:
:echo expand('%:e') " Extension
:echo expand('%:t') " Filename without path
:echo expand('%:p') " Full path
:echo expand('%:h') " Directory
9.2 Buffer Management
9.2.1 Buffer Basics
Buffers are in-memory representations of files. Understanding buffer states is essential.
Buffer states:
Active: Displayed in a window
Hidden: Loaded but not displayed
Inactive: Not loaded
List buffers:
:ls " List all buffers
:buffers " Same as :ls
:files " Same as :ls
Buffer list flags: % - Current buffer # - Alternate buffer a - Active (loaded and visible) h - Hidden (loaded but not visible)
- Not modifiable = - Readonly
- Modified x - Read errors
Example output: 1 %a “init.lua” line 42 2 h “plugins.lua” line 15 3 a “keymaps.lua” line 1
Navigate buffers:
:b2 " Switch to buffer 2
:b filename " Switch by name (tab-completion works)
:bn " Next buffer
:bp " Previous buffer
:bf " First buffer
:bl " Last buffer
<C-^> " Toggle between current and alternate buffer
Delete buffers:
:bd " Delete current buffer
:bd! " Force delete (discard changes)
:bd 2 " Delete buffer 2
:2,5bd " Delete buffers 2-5
:bd filename " Delete by name
:%bd " Delete all buffers
:%bd|e# " Delete all except current
Unload vs Delete:
:bunload " Unload buffer (keep in list)
:bdelete " Delete buffer (remove from list)
:bwipeout " Wipe buffer (remove completely)
9.2.2 Hidden Buffers
By default, Vim prevents switching buffers with unsaved changes.
:set hidden " Allow hidden buffers with unsaved changes
:set nohidden " Require save before switching (default)
Neovim (Lua):
vim.opt.hidden = true -- Allow hidden buffersWhy use hidden?
Switch between files without saving
Maintain undo history across switches
Work with multiple files simultaneously
List modified buffers:
:ls + " Show only modified buffers
9.2.3 Alternate Buffer
The alternate buffer (#) is the previously viewed
buffer.
<C-^> " Toggle to alternate buffer
:b# " Switch to alternate buffer
:e# " Edit alternate buffer
Use cases:
Quick toggle between two files
Compare implementations
Reference switching
Neovim mapping:
-- Enhanced buffer toggle
vim.keymap.set('n', '<leader><Tab>', '<C-^>',
{ desc = 'Toggle alternate buffer' })9.2.4 Buffer Operations
Execute command in all buffers:
:bufdo %s/old/new/ge " Substitute in all buffers
:bufdo set number " Set option in all buffers
:bufdo w " Save all buffers
Close other buffers:
:only " Close all windows except current
:bo[tright] new " Open new buffer in bottom window
Buffer-local options:
-- Set option for current buffer only
vim.bo.tabstop = 2
vim.bo.expandtab = true
-- Or using opt_local
vim.opt_local.tabstop = 29.2.5 Modern Buffer Navigation
Using bufferline.nvim (popular plugin):
-- Install akinsho/bufferline.nvim
require('bufferline').setup({
options = {
mode = 'buffers',
numbers = 'ordinal',
diagnostics = 'nvim_lsp',
show_buffer_close_icons = true,
show_close_icon = false,
}
})
-- Mappings
vim.keymap.set('n', '<Tab>', ':BufferLineCycleNext<CR>')
vim.keymap.set('n', '<S-Tab>', ':BufferLineCyclePrev<CR>')
vim.keymap.set('n', '<leader>1', ':BufferLineGoToBuffer 1<CR>')
vim.keymap.set('n', '<leader>2', ':BufferLineGoToBuffer 2<CR>')Telescope buffer picker:
vim.keymap.set('n', '<leader>fb', ':Telescope buffers<CR>',
{ desc = 'Find buffers' })9.3 Window and Split Management
9.3.1 Creating Splits
Horizontal splits:
:split filename " Horizontal split, edit file
:sp filename " Short form
<C-w>s " Split current window
:new " Split with empty buffer
Vertical splits:
:vsplit filename " Vertical split, edit file
:vs filename " Short form
<C-w>v " Vertical split current window
:vnew " Vertical split with empty buffer
Split with size:
:10split file.txt " 10-line horizontal split
:vertical 80split " 80-column vertical split
Open in splits from command line:
vim -o file1 file2 # Horizontal splits
vim -O file1 file2 # Vertical splits
vim -o5 *.txt # 5 horizontal splits max9.3.2 Navigating Windows
Basic navigation:
<C-w>h " Move to left window
<C-w>j " Move down
<C-w>k " Move up
<C-w>l " Move right
<C-w>w " Cycle through windows
<C-w>p " Previous window
Direct window access:
<C-w>t " Top-left window
<C-w>b " Bottom-right window
Neovim - Easier navigation:
-- Map Ctrl+hjkl to window navigation
vim.keymap.set('n', '<C-h>', '<C-w>h')
vim.keymap.set('n', '<C-j>', '<C-w>j')
vim.keymap.set('n', '<C-k>', '<C-w>k')
vim.keymap.set('n', '<C-l>', '<C-w>l')9.3.3 Resizing Windows
Manual resize:
<C-w>+ " Increase height
<C-w>- " Decrease height
<C-w>> " Increase width
<C-w>< " Decrease width
<C-w>= " Equalize all windows
<C-w>| " Maximize width
<C-w>_ " Maximize height
Precise resize:
:resize 20 " Set height to 20 lines
:vertical resize 80 " Set width to 80 columns
:resize +5 " Increase by 5 lines
:vertical resize -10 " Decrease by 10 columns
Neovim - Smart resize mappings:
-- Resize with arrow keys
vim.keymap.set('n', '<C-Up>', ':resize +2<CR>')
vim.keymap.set('n', '<C-Down>', ':resize -2<CR>')
vim.keymap.set('n', '<C-Left>', ':vertical resize -2<CR>')
vim.keymap.set('n', '<C-Right>', ':vertical resize +2<CR>')9.3.4 Moving and Arranging Windows
Move window to position:
<C-w>H " Move window to far left
<C-w>J " Move window to bottom
<C-w>K " Move window to top
<C-w>L " Move window to far right
<C-w>r " Rotate windows downward/rightward
<C-w>R " Rotate windows upward/leftward
<C-w>x " Exchange with next window
Convert split orientation:
<C-w>H " Vertical → horizontal (move left)
<C-w>K " Horizontal → vertical (move up)
9.3.5 Closing Windows
:q " Quit current window
:q! " Quit, discard changes
:qa " Quit all windows
:qa! " Quit all, discard all changes
<C-w>q " Quit current window
<C-w>c " Close current window
<C-w>o " Close all windows except current
:only " Same as <C-w>o
9.3.6 Floating Windows (Neovim)
Neovim supports floating windows—overlays not bound to the split grid.
Create floating window:
local buf = vim.api.nvim_create_buf(false, true) -- No file, scratch buffer
local width = 60
local height = 20
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = (vim.o.columns - width) / 2, -- Center horizontally
row = (vim.o.lines - height) / 2, -- Center vertically
style = 'minimal',
border = 'rounded',
})
-- Set content
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
'Hello from floating window!',
'Press q to close'
})
-- Close on 'q'
vim.keymap.set('n', 'q', ':close<CR>', { buffer = buf })Floating terminal:
vim.keymap.set('n', '<leader>tt', function()
local buf = vim.api.nvim_create_buf(false, true)
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.8)
vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.cmd('terminal')
end, { desc = 'Floating terminal' })9.4 Tab Pages
Tab pages are collections of windows—separate layouts within one Vim instance.
9.4.1 Creating and Navigating Tabs
Create tabs:
:tabnew " New tab with empty buffer
:tabe filename " Open file in new tab
:tabedit filename " Same as :tabe
Navigate tabs:
gt " Next tab
gT " Previous tab
:tabn " Next tab
:tabp " Previous tab
:tabfirst " First tab
:tablast " Last tab
Direct tab access:
1gt " Go to tab 1
2gt " Go to tab 2
:tabn 3 " Go to tab 3
Command line:
vim -p file1 file2 file3 # Open in separate tabs9.4.2 Managing Tabs
Close tabs:
:tabc " Close current tab
:tabclose " Same
:tabo " Close all tabs except current
:tabonly " Same
Move tabs:
:tabm 0 " Move tab to position 0 (first)
:tabm " Move tab to last position
:tabm +1 " Move tab one position right
:tabm -1 " Move tab one position left
Execute in all tabs:
:tabdo %s/old/new/ge " Substitute in all tabs
:tabdo set number " Set option in all tabs
9.4.3 Tab-Scoped Settings
Tab-local directory:
:tcd ~/project " Change directory for current tab only
:tcd - " Return to previous directory
:pwd " Show current directory
Compare:
:cd- Changes global directory:lcd- Changes window-local directory:tcd- Changes tab-local directory
Tab-scoped variables:
:let t:project_name = 'MyProject'
:echo t:project_name
9.4.4 Tab Layouts
Multiple windows per tab:
:tabnew " New tab
:vsplit file1.txt " Vertical split in this tab
:split file2.txt " Horizontal split
Copy window to new tab:
<C-w>T " Move current window to new tab
Neovim - Tab mappings:
vim.keymap.set('n', '<leader>tn', ':tabnew<CR>', { desc = 'New tab' })
vim.keymap.set('n', '<leader>tc', ':tabclose<CR>', { desc = 'Close tab' })
vim.keymap.set('n', '<leader>to', ':tabonly<CR>', { desc = 'Only tab' })
vim.keymap.set('n', '<A-1>', '1gt', { desc = 'Tab 1' })
vim.keymap.set('n', '<A-2>', '2gt', { desc = 'Tab 2' })
vim.keymap.set('n', '<A-3>', '3gt', { desc = 'Tab 3' })9.5 Argument List
The argument list is a subset of buffers—files passed at startup or added explicitly.
9.5.1 Argument List Basics
View argument list:
:args " Show argument list
:args file1 file2 " Set argument list
:args *.txt " Set to all .txt files
:args **/*.lua " Set to all .lua files recursively
Navigate arguments:
:next " Next argument
:n " Same
:previous " Previous argument
:prev " Same
:first " First argument
:last " Last argument
:argument 3 " Go to argument 3
From command line:
vim file1.txt file2.txt file3.txt
# Inside Vim: :args shows these three files9.5.2 Adding and Removing Arguments
Add to argument list:
:argadd file.txt " Add file
:argadd *.py " Add all .py files
Remove from argument list:
:argdelete file.txt " Remove file
:argdelete * " Remove all
9.5.3 Operations on Argument List
Execute command on all arguments:
:argdo %s/old/new/ge " Substitute in all args
:argdo w " Save all args
:argdo normal @q " Execute macro q in all args
Useful workflow - Refactor across files:
:args **/*.py " Select all Python files
:argdo %s/old_func/new_func/ge | update
The | update saves only if modified.
9.5.4 Local Argument List
Each tab can have its own argument list.
:arglocal *.txt " Set local argument list for current tab
9.6 Sessions
Sessions save the entire Vim workspace: open files, window layouts, tabs, options, and more.
9.6.1 Creating Sessions
Save session:
:mksession session.vim " Save to session.vim
:mksession! session.vim " Overwrite existing
:mks ~/sessions/project.vim " Save to specific path
What’s saved:
Open buffers and windows
Window sizes and positions
Tab pages
Current directory
Options and mappings
Marks and registers (optionally)
Restore session:
:source session.vim " Load session
From command line:
vim -S session.vim
nvim -S session.vim9.6.2 Session Options
Control what’s saved:
:set sessionoptions? " View current settings
Common options:
:set sessionoptions=blank,buffers,curdir,folds,help,tabpages,winsize
Neovim (Lua):
vim.opt.sessionoptions = {
'buffers', -- All buffers
'curdir', -- Current directory
'tabpages', -- Tab pages
'winsize', -- Window sizes
'help', -- Help windows
'globals', -- Global variables
'skiprtp', -- Exclude runtime path
}Minimal session (current tab only):
vim.opt.sessionoptions = { 'curdir', 'tabpages', 'winsize' }9.6.3 Auto-Session Management
Manual save/restore workflow:
-- Quick session save/load
vim.keymap.set('n', '<leader>ss', ':mksession! .session.vim<CR>',
{ desc = 'Save session' })
vim.keymap.set('n', '<leader>sl', ':source .session.vim<CR>',
{ desc = 'Load session' })Using persistence.nvim (plugin):
-- Install folke/persistence.nvim
require('persistence').setup({
dir = vim.fn.expand(vim.fn.stdpath('state') .. '/sessions/'),
options = { 'buffers', 'curdir', 'tabpages', 'winsize' }
})
-- Restore last session for current directory
vim.keymap.set('n', '<leader>qs', [[<cmd>lua require('persistence').load()<cr>]],
{ desc = 'Restore session' })
-- Restore last session
vim.keymap.set('n', '<leader>ql', [[<cmd>lua require('persistence').load({ last = true })<cr>]],
{ desc = 'Restore last session' })
-- Stop session recording
vim.keymap.set('n', '<leader>qd', [[<cmd>lua require('persistence').stop()<cr>]],
{ desc = 'Stop session' })Auto-save on exit:
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
vim.cmd('mksession! .session.vim')
end
})9.6.4 Project-Specific Sessions
Directory-based sessions:
-- Save session named after current directory
vim.keymap.set('n', '<leader>ss', function()
local session_dir = vim.fn.stdpath('data') .. '/sessions'
vim.fn.mkdir(session_dir, 'p')
local cwd = vim.fn.getcwd()
local session_name = cwd:gsub('/', '_'):gsub('^_', '')
local session_file = session_dir .. '/' .. session_name .. '.vim'
vim.cmd('mksession! ' .. session_file)
print('Session saved: ' .. session_file)
end, { desc = 'Save project session' })
-- Load session for current directory
vim.keymap.set('n', '<leader>sl', function()
local session_dir = vim.fn.stdpath('data') .. '/sessions'
local cwd = vim.fn.getcwd()
local session_name = cwd:gsub('/', '_'):gsub('^_', '')
local session_file = session_dir .. '/' .. session_name .. '.vim'
if vim.fn.filereadable(session_file) == 1 then
vim.cmd('source ' .. session_file)
print('Session loaded: ' .. session_file)
else
print('No session found for this directory')
end
end, { desc = 'Load project session' })9.7 Views
Views save window-specific settings: cursor position, folds, options. Unlike sessions (which save the entire workspace), views save individual window states.
9.7.1 Creating and Loading Views
Save view:
:mkview " Save view (index 1 by default)
:mkview 2 " Save to slot 2 (up to 10 slots)
:mkview ~/.vim/views/myview.vim " Save to file
Load view:
:loadview " Load view (index 1)
:loadview 2 " Load from slot 2
:source ~/.vim/views/myview.vim " Load from file
What’s saved in views:
Cursor position
Folds
Scroll position
Local options
Local mappings
9.7.2 Auto-Save Views
Save/restore views automatically:
-- Auto-save view on buffer leave
vim.api.nvim_create_autocmd('BufWinLeave', {
pattern = '*',
callback = function()
if vim.bo.buftype == '' then -- Only for regular files
vim.cmd('silent! mkview')
end
end
})
-- Auto-load view on buffer enter
vim.api.nvim_create_autocmd('BufWinEnter', {
pattern = '*',
callback = function()
if vim.bo.buftype == '' then
vim.cmd('silent! loadview')
end
end
})View options:
:set viewoptions? " View what's saved
vim.opt.viewoptions = { 'cursor', 'folds', 'curdir' }9.8 File Browsing
9.8.1 Netrw (Built-in File Browser)
Netrw is Vim’s built-in file explorer.
Open Netrw:
:Explore " Open in current window
:Sexplore " Open in horizontal split
:Vexplore " Open in vertical split
:Texplore " Open in new tab
:e . " Browse current directory
:e .. " Browse parent directory
Navigate in Netrw:
<Enter> " Open file/directory
- " Go to parent directory
i " Cycle through view modes
s " Cycle sort order
r " Reverse sort
Netrw operations:
d " Create directory
% " Create file
D " Delete file/directory
R " Rename file
Configure Netrw:
vim.g.netrw_banner = 0 -- Disable banner
vim.g.netrw_liststyle = 3 -- Tree view
vim.g.netrw_winsize = 25 -- Window size (percentage)
vim.g.netrw_browse_split = 4 -- Open in previous window9.8.2 Modern File Explorers
nvim-tree.lua (popular):
-- Install nvim-tree/nvim-tree.lua
require('nvim-tree').setup({
sort_by = 'case_sensitive',
view = {
width = 30,
},
filters = {
dotfiles = false,
},
})
-- Toggle tree
vim.keymap.set('n', '<leader>e', ':NvimTreeToggle<CR>',
{ desc = 'Toggle file explorer' })neo-tree.nvim (alternative):
-- Install nvim-neo-tree/neo-tree.nvim
require('neo-tree').setup({
window = {
width = 30,
},
filesystem = {
follow_current_file = true,
},
})
vim.keymap.set('n', '<leader>e', ':Neotree toggle<CR>',
{ desc = 'Toggle Neo-tree' })9.8.3 Fuzzy Finding (Telescope)
Telescope.nvim (essential plugin):
-- Install nvim-telescope/telescope.nvim
local builtin = require('telescope.builtin')
vim.keymap.set('n', '<leader>ff', builtin.find_files, { desc = 'Find files' })
vim.keymap.set('n', '<leader>fg', builtin.live_grep, { desc = 'Live grep' })
vim.keymap.set('n', '<leader>fb', builtin.buffers, { desc = 'Find buffers' })
vim.keymap.set('n', '<leader>fh', builtin.help_tags, { desc = 'Help tags' })
vim.keymap.set('n', '<leader>fr', builtin.oldfiles, { desc = 'Recent files' })
vim.keymap.set('n', '<leader>fw', builtin.grep_string, { desc = 'Find word' })Advanced Telescope usage:
-- Find files including hidden
vim.keymap.set('n', '<leader>fa', function()
builtin.find_files({ hidden = true, no_ignore = true })
end, { desc = 'Find all files' })
-- Grep in specific directory
vim.keymap.set('n', '<leader>fd', function()
builtin.live_grep({ cwd = vim.fn.input('Directory: ') })
end, { desc = 'Grep in directory' })
-- Find in current buffer
vim.keymap.set('n', '<leader>/', builtin.current_buffer_fuzzy_find,
{ desc = 'Fuzzy find in buffer' })9.9 File Path Manipulation
9.9.1 Path Expansion
Vim provides modifiers to extract path components:
:echo expand('%') " Relative path: src/main.lua
:echo expand('%:p') " Absolute path: /home/user/project/src/main.lua
:echo expand('%:t') " Filename: main.lua
:echo expand('%:r') " Without extension: src/main
:echo expand('%:e') " Extension: lua
:echo expand('%:h') " Directory: src
:echo expand('%:p:h') " Absolute directory: /home/user/project/src
Multiple modifiers:
:echo expand('%:p:h:h') " Parent of absolute directory
:echo expand('%:t:r') " Filename without extension
Alternate buffer path:
:echo expand('#') " Alternate buffer path
:echo expand('#:p') " Alternate absolute path
9.9.2 Special File Names
% " Current file
# " Alternate file
#3 " Buffer 3
<cfile> " File under cursor
<cword> " Word under cursor
<sfile> " Sourced file (in scripts)
Using in commands:
:!cat % " Show current file content
:e %:h/new_file.txt " Open file in current directory
9.9.3 Working Directory
Change directory:
:cd /path/to/dir " Global change
:lcd /path/to/dir " Window-local
:tcd /path/to/dir " Tab-local
:pwd " Show current directory
Navigate directories:
:cd - " Return to previous directory
:cd %:p:h " Change to current file's directory
:cd .. " Parent directory
Neovim - Auto-cd to file directory:
vim.opt.autochdir = true -- Auto change to file's directory
-- Or do it manually per file:
vim.keymap.set('n', '<leader>cd', ':cd %:p:h<CR>:pwd<CR>',
{ desc = 'CD to file directory' })9.10 File Operations Workflows
Workflow 1: Multi-File Editing
Open related files:
:e src/main.lua
:vs src/config.lua
:sp test/main_test.lua
Or use argument list:
:args src/*.lua
:sall " Open all in splits
:vertical sall " Open all in vertical splits
Workflow 2: Search and Replace Across Files
:args **/*.py " Select all Python files
:argdo %s/old/new/ge | update " Replace and save
Using Telescope + Quickfix:
-- 1. Grep for pattern (Telescope)
-- 2. Send results to quickfix (<C-q>)
-- 3. Run substitution on quickfix
vim.keymap.set('n', '<leader>qr', function()
vim.ui.input({ prompt = 'Replace with: ' }, function(replacement)
if replacement then
vim.cmd('cfdo %s/' .. vim.fn.getreg('/') .. '/' .. replacement .. '/ge | update')
end
end)
end, { desc = 'Replace in quickfix' })Workflow 3: Project Session Management
-- Save session when leaving project directory
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
-- Only save if in a git repo
if vim.fn.isdirectory('.git') == 1 then
vim.cmd('mksession! .session.vim')
end
end
})
-- Auto-load session in project directory
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
if vim.fn.argc() == 0 and vim.fn.filereadable('.session.vim') == 1 then
vim.defer_fn(function()
vim.cmd('source .session.vim')
end, 10)
end
end
})Workflow 4: File Templates
-- Auto-insert template for new files
vim.api.nvim_create_autocmd('BufNewFile', {
pattern = '*.py',
callback = function()
local template = vim.fn.expand('~/.config/nvim/templates/python.txt')
if vim.fn.filereadable(template) == 1 then
vim.cmd('0r ' .. template)
-- Replace placeholders
vim.cmd('%s/{{FILENAME}}/' .. vim.fn.expand('%:t:r') .. '/ge')
vim.cmd('%s/{{DATE}}/' .. os.date('%Y-%m-%d') .. '/ge')
end
end
})Workflow 5: Backup and Undo Files
Enable persistent undo:
vim.opt.undofile = true
vim.opt.undodir = vim.fn.stdpath('data') .. '/undo'Backup files:
vim.opt.backup = true
vim.opt.backupdir = vim.fn.stdpath('data') .. '/backup'
vim.fn.mkdir(vim.opt.backupdir:get()[1], 'p')Swap files (disable for modern workflows):
vim.opt.swapfile = false -- Rely on version control insteadChapter Summary
This chapter covered Vim’s comprehensive file operation system:
Basic Operations:
:e,:w,:rfor opening, saving, reading files:w !sudo tee %for elevated savesFile info with
:f,<C-g>,g<C-g>Path expansion:
%:p,%:h,%:t,%:r,%:e
Buffer Management:
:ls,:bn,:bp,<C-^>for navigation:bd,:bw,:bunloadfor deletion:bufdofor batch operationsset hiddenfor unsaved buffer switchingModern plugins: bufferline.nvim
Windows and Splits:
:split,:vsplit,<C-w>s/vfor creation<C-w>hjklfor navigation:resize,<C-w>=for sizing<C-w>HJKLfor repositioningFloating windows in Neovim
Tab Pages:
:tabnew,:tabefor creationgt,gT,1gtfor navigation:tcdfor tab-local directories:tabdofor batch operations
Argument List:
:args **/*.luafor file selection:next,:prevfor navigation:argdofor batch processingMulti-file refactoring workflows
Sessions:
:mksessionto save workspacevim -S session.vimto restoresessionoptionsto control saved statepersistence.nvim for auto-session management
Directory-specific session patterns
Views:
:mkview,:loadviewfor window stateAuto-save/restore with autocmds
Cursor, folds, scroll preservation
File Browsing:
Netrw built-in browser
nvim-tree.lua, neo-tree.nvim file explorers
Telescope fuzzy finding
:find,:e .for quick access
Working Directory:
:cd,:lcd,:tcdfor scope control:cd %:p:hto follow fileautochdiroption
Advanced Workflows:
Multi-file search and replace
Template insertion
Session management automation
Backup and undo persistence
Best Practices:
Enable
hiddenfor flexible buffer switchingUse sessions for project restoration
Leverage Telescope for file discovery
Automate repetitive file operations
Organize sessions by project
Use LSP for file operations when available
Map frequent operations to leader keys
Disable swap files in favor of version control
Key Insights:
Buffers are in-memory, files are on-disk
Windows are viewports, tabs are layouts
Argument list is a curated subset of buffers
Sessions preserve entire workspace state
Views preserve window-specific state
Modern plugins enhance traditional workflows
Automation reduces manual file management
Path modifiers enable dynamic file operations
Master these file operations to efficiently manage complex projects spanning dozens or hundreds of files. Vim’s file system, combined with modern plugins, provides a powerful foundation for navigating and organizing large codebases.
In the next chapter, we’ll explore Visual Mode and Selection, covering character-wise, line-wise, and block-wise selection, visual operators, and advanced selection techniques that enable precise text manipulation.
Chapter 10: The Quickfix and Location Lists
The quickfix and location lists are among Vim’s most powerful yet underutilized features. Originally designed for compiler error navigation, these lists have evolved into versatile tools for managing any collection of file positions: search results, linter warnings, TODO markers, git conflicts, and more. This chapter explores how to harness these lists to dramatically accelerate multi-file workflows.
10.1 Understanding Quickfix and Location Lists
10.1.1 Conceptual Foundation
Quickfix list:
Global to the entire Vim instance
Shared across all windows and tabs
Typically used for compiler errors, grep results
One active quickfix list at a time (with history stack)
Location list:
Local to each window
Each window can have its own location list
Used for window-specific results (LSP diagnostics, local searches)
Multiple location lists can coexist simultaneously
Common structure: Both lists store entries with:
filename- File pathlnum- Line numbercol- Column numbertext- Description texttype- Entry type (E=error, W=warning, I=info)valid- Whether entry is valid
10.1.2 When to Use Which
Use Quickfix when:
Compiling code and reviewing errors
Performing project-wide searches
Working with test results
Managing TODO lists across files
Results need to persist across window switches
Use Location list when:
Viewing LSP diagnostics for specific file
Per-window search results
Isolating results to specific context
Multiple developers/workflows need separate lists
10.2 Basic Quickfix Operations
10.2.1 Populating the Quickfix List
From compiler output:
:make " Run make and populate quickfix
:make test " Run 'make test'
:make! " Run without jumping to first error
Configure make program:
:set makeprg=gcc\ % " Set compiler
:set makeprg=pytest " Set to pytest
:set makeprg=npm\ test
Neovim (Lua):
vim.opt.makeprg = 'cargo build' -- Rust
vim.opt.makeprg = 'go build' -- GoFrom grep:
:grep pattern files " Use external grep
:grep -r pattern . " Recursive grep
:grep! pattern files " Don't jump to first match
:lgrep pattern files " Populate location list instead
Configure grep program:
:set grepprg=rg\ --vimgrep " Use ripgrep
:set grepprg=ag\ --vimgrep " Use silver searcher
Neovim (Lua):
vim.opt.grepprg = 'rg --vimgrep --smart-case'
vim.opt.grepformat = '%f:%l:%c:%m'Internal vimgrep:
:vimgrep /pattern/ **/*.lua " Search all Lua files
:vimgrep /TODO/ src/**/* " Find all TODOs
:vimgrep /error/ % " Search current file
:lvimgrep /pattern/ files " Populate location list
Pattern modifiers:
:vimgrep /pattern/g **/*.py " All matches per line (g flag)
:vimgrep /pattern/j **/*.py " Don't jump to first (j flag)
10.2.2 Navigating the Quickfix List
Basic navigation:
:cnext " Next entry
:cn " Short form
:cprevious " Previous entry
:cp " Short form
:cfirst " First entry
:clast " Last entry
:cc 5 " Jump to entry 5
:cc " Jump to current entry (useful after :copen)
With count:
:5cnext " Jump 5 entries forward
:3cprevious " Jump 3 entries back
5]q " Jump 5 forward (if mapped)
Wrap around:
:set nowrapscan " Prevent wrap (stays at last)
:set wrapscan " Allow wrap to first (default)
Neovim - Quickfix navigation mappings:
vim.keymap.set('n', ']q', ':cnext<CR>', { desc = 'Next quickfix' })
vim.keymap.set('n', '[q', ':cprevious<CR>', { desc = 'Previous quickfix' })
vim.keymap.set('n', ']Q', ':clast<CR>', { desc = 'Last quickfix' })
vim.keymap.set('n', '[Q', ':cfirst<CR>', { desc = 'First quickfix' })10.2.3 Viewing the Quickfix List
Open quickfix window:
:copen " Open quickfix window
:copen 15 " Open with 15 lines height
:cwindow " Open only if entries exist
:cclose " Close quickfix window
Quickfix window features:
Press
<Enter>on entry to jump to locationWindow updates as you navigate with
:cnext/:cpreviousUse
ddto remove entries (in modifiable mode)Close with
:qor:cclose
Toggle quickfix:
vim.keymap.set('n', '<leader>q', function()
for _, win in pairs(vim.fn.getwininfo()) do
if win.quickfix == 1 then
vim.cmd('cclose')
return
end
end
vim.cmd('copen')
end, { desc = 'Toggle quickfix' })10.2.4 Quickfix History
Vim maintains a stack of quickfix lists.
Navigate history:
:colder " Go to older quickfix list
:cnewer " Go to newer quickfix list
:colder 3 " Go 3 lists back
Check history:
:chistory " Show quickfix history
Output example: error list 1 of 3; 45 errors > error list 2 of 3; 12 errors error list 3 of 3; 8 errors
Neovim - History navigation:
vim.keymap.set('n', '<leader>co', ':colder<CR>', { desc = 'Older quickfix' })
vim.keymap.set('n', '<leader>ci', ':cnewer<CR>', { desc = 'Newer quickfix' })10.3 Basic Location List Operations
Location lists work identically to quickfix, but with
l prefix commands.
10.3.1 Location List Commands
Navigation:
:lnext " Next entry
:ln
:lprevious " Previous entry
:lp
:lfirst " First entry
:llast " Last entry
:ll 5 " Jump to entry 5
Window:
:lopen " Open location window
:lopen 10 " Open with 10 lines
:lwindow " Open if entries exist
:lclose " Close location window
History:
:lolder " Older location list
:lnewer " Newer location list
:lhistory " Show history
Neovim - Location list mappings:
vim.keymap.set('n', ']l', ':lnext<CR>', { desc = 'Next location' })
vim.keymap.set('n', '[l', ':lprevious<CR>', { desc = 'Previous location' })
vim.keymap.set('n', '<leader>l', ':lopen<CR>', { desc = 'Open location list' })10.3.2 Location List Per Window
Each window has its own location list:
:vsplit " Create vertical split
:lvimgrep /TODO/ **/*.lua " Populate left window's location list
<C-w>l " Move to right window
:lvimgrep /FIXME/ **/*.lua " Populate right window's location list
Now each window has its own list, navigable independently.
10.4 Advanced Quickfix Population
10.4.1 Using :cexpr and :lexpr
Manually populate lists from strings:
:cexpr "file.txt:10:5:Error message"
:cexpr ["file1.txt:10:Error 1", "file2.txt:20:Error 2"]
Neovim (Lua) - Build custom quickfix:
local function populate_quickfix()
local entries = {
{ filename = 'file1.lua', lnum = 10, col = 5, text = 'TODO: Fix this' },
{ filename = 'file2.lua', lnum = 25, col = 1, text = 'FIXME: Refactor' },
}
vim.fn.setqflist(entries)
vim.cmd('copen')
end
vim.keymap.set('n', '<leader>qt', populate_quickfix,
{ desc = 'Populate TODO quickfix' })10.4.2 Using :cfile and :lfile
Load from file:
:cfile errors.txt " Load quickfix from file
:lfile warnings.txt " Load location list from file
File format: src/main.lua:42:5: error: undefined variable src/utils.lua:10:1: warning: unused import test/spec.lua:100:12: error: assertion failed
10.4.3 Using :cgetexpr and :lgetexpr
Add to existing list without jumping:
:cgetexpr "file.txt:50:New error" " Add without jumping
:caddexpr "file.txt:60:Another" " Append to list
Neovim - Add LSP diagnostics to quickfix:
vim.keymap.set('n', '<leader>qd', function()
vim.diagnostic.setqflist({ severity = vim.diagnostic.severity.ERROR })
vim.cmd('copen')
end, { desc = 'Errors to quickfix' })10.4.4 Filter and Transform
Get current quickfix list:
:let qflist = getqflist()
:echo qflist[0].text
Neovim (Lua) - Filter quickfix:
-- Keep only errors (remove warnings)
vim.keymap.set('n', '<leader>qe', function()
local qflist = vim.fn.getqflist()
local errors = vim.tbl_filter(function(item)
return item.type == 'E' or item.type == ''
end, qflist)
vim.fn.setqflist(errors)
end, { desc = 'Filter quickfix to errors' })Filter by filename pattern:
vim.keymap.set('n', '<leader>qf', function()
vim.ui.input({ prompt = 'Filter pattern: ' }, function(pattern)
if not pattern then return end
local qflist = vim.fn.getqflist()
local filtered = vim.tbl_filter(function(item)
local bufname = vim.fn.bufname(item.bufnr)
return bufname:match(pattern)
end, qflist)
vim.fn.setqflist(filtered)
print('Filtered to ' .. #filtered .. ' entries')
end)
end, { desc = 'Filter quickfix by pattern' })10.5 Operating on Quickfix Entries
10.5.1 Batch Operations with :cdo and :cfdo
:cdo - Execute on each quickfix entry:
:cdo s/old/new/g " Substitute in each entry
:cdo normal @q " Execute macro q on each entry
:cdo delete " Delete line at each entry
:cfdo - Execute on each file in quickfix:
:cfdo %s/old/new/ge " Substitute in each file
:cfdo w " Save each file
Combine for efficient refactoring:
:vimgrep /oldFunc/ **/*.lua " Find all occurrences
:cfdo %s/oldFunc/newFunc/ge | update " Replace and save
The e flag suppresses errors, update
saves only if modified.
Neovim - Quickfix refactor workflow:
vim.keymap.set('n', '<leader>qr', function()
-- Assume quickfix is already populated
vim.ui.input({ prompt = 'Replace with: ' }, function(replacement)
if not replacement then return end
local search = vim.fn.getreg('/') -- Get last search
vim.cmd('cfdo %s/' .. search .. '/' .. replacement .. '/ge | update')
end)
end, { desc = 'Replace in quickfix files' })10.5.2 Location List Equivalents
:ldo s/old/new/g " Execute on each location entry
:lfdo %s/old/new/ge " Execute on each file in location list
10.5.3 Quickfix Macros
Record macro, execute across quickfix:
Workflow:
Navigate to first quickfix entry:
:ccRecord macro:
qa(record to registera)Perform edits
Save and move to next:
:w | cnextStop recording:
qExecute on remaining entries:
:5@aor:999@a
Or use :cdo:
:cdo normal @a " Execute macro a on all entries
10.6 Advanced Workflows
10.6.1 Project-Wide TODO Management
Find all TODOs:
:vimgrep /TODO\|FIXME\|HACK/ **/*.lua
:copen
Neovim - TODO finder:
vim.keymap.set('n', '<leader>ft', function()
vim.cmd('vimgrep /TODO\\|FIXME\\|HACK/ **/*')
vim.cmd('copen')
end, { desc = 'Find TODOs' })Filter TODOs by priority:
vim.keymap.set('n', '<leader>fT', function()
vim.cmd('vimgrep /TODO(\\d)/ **/*') -- Matches TODO(1), TODO(2), etc.
local qflist = vim.fn.getqflist()
table.sort(qflist, function(a, b)
local a_priority = a.text:match('TODO%((%d)%)')
local b_priority = b.text:match('TODO%((%d)%)')
return (a_priority or 9) < (b_priority or 9)
end)
vim.fn.setqflist(qflist)
vim.cmd('copen')
end, { desc = 'Find prioritized TODOs' })10.6.2 Test Failure Navigation
Pytest integration:
:set makeprg=pytest\ --tb=short
:make
:copen
Neovim - Run tests and populate quickfix:
vim.keymap.set('n', '<leader>tt', function()
vim.cmd('make')
vim.cmd('copen')
end, { desc = 'Run tests' })
-- Run specific test file
vim.keymap.set('n', '<leader>tf', function()
vim.cmd('make %') -- % = current file
vim.cmd('copen')
end, { desc = 'Test current file' })10.6.3 Git Conflict Resolution
Find merge conflicts:
:vimgrep /^<<<<<<<\|^=======\|^>>>>>>>/ **/*
:copen
Neovim - Conflict finder:
vim.keymap.set('n', '<leader>gc', function()
vim.cmd('vimgrep /^<<<<<<<\\|^=======\\|^>>>>>>>/ **/*')
vim.cmd('copen')
end, { desc = 'Find git conflicts' })Navigate and resolve:
:cnextto go to next conflictResolve using visual mode or
dd:wto saveRepeat
10.6.4 Multi-File Search and Replace
Complete workflow:
" 1. Search across project
:vimgrep /oldFunction/ **/*.js
" 2. Review matches
:copen
" 3. Confirm pattern is correct
:cnext " Check a few matches
" 4. Replace in all files
:cfdo %s/oldFunction/newFunction/ge | update
" 5. Verify changes
:cnext " Check replacements
Neovim - Interactive replace:
vim.keymap.set('n', '<leader>sr', function()
vim.ui.input({ prompt = 'Search: ' }, function(search)
if not search then return end
vim.ui.input({ prompt = 'Replace: ' }, function(replace)
if not replace then return end
-- Search
vim.cmd('vimgrep /' .. search .. '/ **/*')
vim.cmd('copen')
-- Confirm
vim.ui.input({
prompt = 'Found ' .. #vim.fn.getqflist() .. ' matches. Replace all? (y/n): '
}, function(confirm)
if confirm == 'y' then
vim.cmd('cfdo %s/' .. search .. '/' .. replace .. '/ge | update')
print('Replaced in ' .. #vim.fn.getqflist() .. ' files')
end
end)
end)
end)
end, { desc = 'Search and replace' })10.6.5 LSP Diagnostics Integration
Neovim - Populate quickfix with diagnostics:
-- All diagnostics
vim.keymap.set('n', '<leader>da', function()
vim.diagnostic.setqflist()
vim.cmd('copen')
end, { desc = 'All diagnostics to quickfix' })
-- Only errors
vim.keymap.set('n', '<leader>de', function()
vim.diagnostic.setqflist({ severity = vim.diagnostic.severity.ERROR })
vim.cmd('copen')
end, { desc = 'Errors to quickfix' })
-- Only warnings
vim.keymap.set('n', '<leader>dw', function()
vim.diagnostic.setqflist({ severity = vim.diagnostic.severity.WARN })
vim.cmd('copen')
end, { desc = 'Warnings to quickfix' })
-- Current buffer diagnostics to location list
vim.keymap.set('n', '<leader>dl', function()
vim.diagnostic.setloclist()
vim.cmd('lopen')
end, { desc = 'Buffer diagnostics to location list' })10.7 Customizing Quickfix Display
10.7.1 Error Format
The errorformat option controls how Vim parses
compiler/grep output.
Default format:
:set errorformat?
Common patterns:
" GCC/Clang format: file.c:42:5: error: message
set errorformat=%f:%l:%c:\ %t%*[^:]:\ %m
" Python traceback
set errorformat=%C\ %.%#,%A\ \ File\ \"%f\"\\,\ line\ %l%.%#,%Z%[%^\ ]%\\@=%m
" JavaScript/Node.js
set errorformat=%f:%l:%c\ -\ %trror:\ %m,%f:%l:%c\ -\ %tarning:\ %m
Neovim (Lua):
vim.opt.errorformat = {
'%f:%l:%c: %t%*[^:]: %m', -- Standard format
'%f:%l: %t%*[^:]: %m', -- Without column
'%f(%l): %t%*[^:]: %m', -- Windows format
}10.7.2 Quickfix Highlighting
Neovim - Customize quickfix colors:
vim.api.nvim_set_hl(0, 'QuickFixLine', { bg = '#3a3a3a', fg = '#e0e0e0' })
vim.api.nvim_set_hl(0, 'qfFileName', { fg = '#61afef' })
vim.api.nvim_set_hl(0, 'qfLineNr', { fg = '#98c379' })10.7.3 Custom Quickfix Window
Neovim - Enhanced quickfix:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'qf',
callback = function()
-- Buffer-local mappings
vim.keymap.set('n', '<CR>', '<CR>:cclose<CR>', { buffer = true })
vim.keymap.set('n', 'dd', function()
local line = vim.fn.line('.')
local qflist = vim.fn.getqflist()
table.remove(qflist, line)
vim.fn.setqflist(qflist)
end, { buffer = true })
-- Set window height
vim.cmd('resize ' .. math.min(#vim.fn.getqflist(), 10))
end
})10.8 Quickfix Plugins
10.8.1 trouble.nvim
A modern diagnostics/quickfix UI:
-- Install folke/trouble.nvim
require('trouble').setup({
position = 'bottom',
height = 10,
icons = true,
mode = 'workspace_diagnostics',
})
vim.keymap.set('n', '<leader>xx', ':TroubleToggle<CR>',
{ desc = 'Toggle Trouble' })
vim.keymap.set('n', '<leader>xw', ':TroubleToggle workspace_diagnostics<CR>',
{ desc = 'Workspace diagnostics' })
vim.keymap.set('n', '<leader>xd', ':TroubleToggle document_diagnostics<CR>',
{ desc = 'Document diagnostics' })
vim.keymap.set('n', '<leader>xq', ':TroubleToggle quickfix<CR>',
{ desc = 'Quickfix in Trouble' })10.8.2 nvim-bqf
Better quickfix window:
-- Install kevinhwang91/nvim-bqf
require('bqf').setup({
preview = {
auto_preview = true,
win_height = 12,
},
func_map = {
vsplit = '<C-v>',
ptogglemode = 'z,',
stoggleup = '<',
},
})Features:
Fuzzy search in quickfix
Preview entries
Multi-select and batch operations
10.9 Practical Recipes
Recipe 1: Code Review Workflow
-- Mark lines for review
vim.keymap.set('n', '<leader>mr', function()
local file = vim.fn.expand('%')
local line = vim.fn.line('.')
local text = vim.fn.input('Review note: ')
-- Add to quickfix
local qflist = vim.fn.getqflist()
table.insert(qflist, {
filename = file,
lnum = line,
text = 'REVIEW: ' .. text,
})
vim.fn.setqflist(qflist)
print('Added review mark')
end, { desc = 'Mark for review' })
-- Review all marks
vim.keymap.set('n', '<leader>vr', function()
local qflist = vim.fn.getqflist()
local reviews = vim.tbl_filter(function(item)
return item.text:match('^REVIEW:')
end, qflist)
vim.fn.setqflist(reviews)
vim.cmd('copen')
end, { desc = 'View review marks' })Recipe 2: Find Function Definitions
:vimgrep /\<def\s\+\w\+/ **/*.py " Python functions
:vimgrep /\<function\s\+\w\+/ **/*.js " JavaScript functions
:vimgrep /\<func\s\+\w\+/ **/*.go " Go functions
Recipe 3: Dead Code Detection
-- Find unused imports (simple heuristic)
vim.keymap.set('n', '<leader>fu', function()
-- Find all imports
vim.cmd('vimgrep /^import\\s\\+/ **/*.py')
local imports = vim.fn.getqflist()
-- Check usage (simplified)
local unused = {}
for _, item in ipairs(imports) do
local module = item.text:match('import%s+(%S+)')
if module then
-- Search for usage
local usage = vim.fn.system('grep -r "' .. module .. '" .')
if usage == '' then
table.insert(unused, item)
end
end
end
vim.fn.setqflist(unused)
vim.cmd('copen')
print('Found ' .. #unused .. ' potentially unused imports')
end, { desc = 'Find unused imports' })Recipe 4: Documentation Coverage
-- Find functions without docstrings
vim.keymap.set('n', '<leader>fd', function()
vim.cmd('vimgrep /def\\s\\+\\w\\+.*:\\n\\s*"""/ **/*.py')
local documented = vim.fn.getqflist()
vim.cmd('vimgrep /def\\s\\+\\w\\+/ **/*.py')
local all_funcs = vim.fn.getqflist()
print('Documentation coverage: ' ..
math.floor(#documented / #all_funcs * 100) .. '%')
end, { desc = 'Check documentation coverage' })Recipe 5: Quickfix Stack Management
-- Save current quickfix to file
vim.keymap.set('n', '<leader>qs', function()
local filename = vim.fn.input('Save quickfix to: ', 'quickfix.txt')
vim.cmd('call writefile(map(getqflist(), "v:val.text"), "' .. filename .. '")')
print('Saved quickfix to ' .. filename)
end, { desc = 'Save quickfix' })
-- Load quickfix from file
vim.keymap.set('n', '<leader>ql', function()
local filename = vim.fn.input('Load quickfix from: ', 'quickfix.txt')
if vim.fn.filereadable(filename) == 1 then
vim.cmd('cfile ' .. filename)
vim.cmd('copen')
end
end, { desc = 'Load quickfix' })Chapter Summary
This chapter explored Vim’s powerful quickfix and location list systems:
Core Concepts:
Quickfix: Global list for project-wide results
Location list: Window-local list for contextual results
Both support navigation, filtering, batch operations
Population Methods:
:make- Compiler integration:grep/:vimgrep- Search results:cexpr/:cfile- Custom populationLSP diagnostics integration
Navigation:
:cnext/:cpreviousfor quickfix:lnext/:lpreviousfor location listHistory with
:colder/:cnewerWindow management with
:copen/:cclose
Batch Operations:
:cdo- Execute per entry:cfdo- Execute per fileCombine with
:updatefor safe savesMacro integration
Advanced Features:
Filter and transform with Lua
Custom error formats
Plugin enhancements (trouble.nvim, nvim-bqf)
Integration with LSP, testing, git workflows
Practical Workflows:
Project-wide refactoring
TODO management
Test failure navigation
Code review marking
Multi-file search/replace
Best Practices:
Use quickfix for global, location for local
Always review before
:cfdooperationsLeverage
eflag to suppress errorsUse
updateinstead ofwto avoid unnecessary writesMap frequently used commands to leader keys
Integrate with LSP for modern workflows
Customize display for better visibility
Key Insights:
Quickfix transforms multi-file editing
Location lists enable parallel workflows
Batch operations enable efficient refactoring
Integration with external tools amplifies power
Modern plugins enhance traditional workflows
Systematic navigation reduces context switching
Mastering quickfix and location lists transforms Vim from a single-file editor into a project-wide navigation and refactoring powerhouse. Combined with LSP, grep tools, and plugins, these lists become the central nervous system for managing complex codebases.
In the next chapter, we’ll explore Marks and Jumps, covering local and global marks, jump lists, change lists, and navigation patterns that enable rapid movement across files and history.
Chapter 11: Advanced Search Features
Vim’s search capabilities extend far beyond basic pattern matching. This chapter explores sophisticated search techniques, from multi-line patterns and look-arounds to search automation and integration with external tools. Mastering these features transforms search from a simple lookup operation into a precision navigation and refactoring tool.
11.1 Advanced Pattern Matching
11.1.1 Very Magic Mode
Standard Vim regex requires escaping special characters. Very
magic mode (\v) makes patterns more intuitive:
Standard vs. Very Magic:
" Standard - requires escaping
/\(foo\|bar\)\+
" Very magic - cleaner syntax
/\v(foo|bar)+
" Even more examples
/\v\d+\.\d+ " Match decimal numbers: 3.14
/\v<word> " Word boundaries
/\v[A-Z][a-z]+ " CamelCase words
Common conversions: | Standard | Very Magic |
Meaning | |———-|———–|———| | \+ | + | One
or more | | \? | ? | Zero or one | |
\{n,m} | {n,m} | Between n and m | |
\| | | | Alternation (OR) | |
\( ) | ( ) |
Grouping | | \< \> |
< > | Word boundaries |
Neovim - Set very magic as default:
-- Map search to automatically use very magic
vim.keymap.set('n', '/', '/\\v', { desc = 'Search (very magic)' })
vim.keymap.set('n', '?', '?\\v', { desc = 'Backward search (very magic)' })11.1.2 Very Nomagic Mode
For literal searches where special characters should be treated as regular characters:
" Very nomagic - only \ and terminator are special
/\V^literal$text.here
" Search for actual regex pattern
/\V(foo|bar)+ " Finds literal "(foo|bar)+"
When to use:
Searching for code containing regex
Searching for literal special characters
When you want minimal interpretation
11.1.3 Multi-Line Search Patterns
Match across line breaks:
" Search for function definition spanning multiple lines
/\vdef\s+\w+\_s*\(\_.\{-}\):
" Components:
" \_s - whitespace including newline
" \_. - any character including newline
" \{-} - non-greedy match
Common multi-line patterns:
" Function call with multiline arguments
/\vfunctionName\(\_.\{-}\)
" HTML/XML tag with content
/<div\_.\{-}<\/div>
" Python/C block
/if\s\+.*:\_.\{-}^end
" Comment block
/\/\*\_.\{-}\*\/
Find paragraph with specific word:
/\v(\_^\_s*\n){2}@<=\_.\{-}keyword\_.\{-}(\_^\_s*\n){2}@=
11.1.4 Zero-Width Assertions
Positive Lookahead (\@=): Match
position followed by pattern without consuming it.
" Find 'foo' followed by 'bar' (but don't include 'bar' in match)
/foo\(bar\)\@=
" Match numbers followed by % sign
/\d\+\(%\)\@=
Negative Lookahead (\@!): Match
position NOT followed by pattern.
" Find 'foo' not followed by 'bar'
/foo\(bar\)\@!
" Find function calls without arguments
/\w\+\(()\)\@!
Positive Lookbehind (\@<=):
Match position preceded by pattern.
" Find digits after '$' sign
/\(\$\)\@<=\d\+
" Find text after 'TODO: '
/\(TODO:\s\)\@<=.*
Negative Lookbehind (\@<!):
Match position NOT preceded by pattern.
" Find numbers not preceded by '$'
/\(\$\)\@<!\d\+
" Find 'bar' not preceded by 'foo'
/\(foo\)\@<!bar
Very magic equivalents:
/\vfoo(bar)@= " Positive lookahead
/\vfoo(bar)@! " Negative lookahead
/\v(\$)@<=\d+ " Positive lookbehind
/\v(\$)@<!\d+ " Negative lookbehind
11.1.5 Word Boundary Searches
Exact word matching:
/\<word\> " Match 'word' but not 'wordy' or 'sword'
/\v<word> " Very magic version
" Case variations
/\<\cword\> " Case insensitive word
/\<\Cword\> " Case sensitive word (override ignorecase)
Start/end of word:
/\<foo " Match 'foo' and 'foobar' but not 'barfoo'
/bar\> " Match 'bar' and 'foobar' but not 'barfoo'
11.1.6 Character Classes
Predefined classes:
\d " Digit [0-9]
\D " Non-digit
\w " Word character [0-9A-Za-z_]
\W " Non-word character
\s " Whitespace [ \t]
\S " Non-whitespace
\h " Head of word [A-Za-z_]
\a " Alphabetic [A-Za-z]
\l " Lowercase [a-z]
\u " Uppercase [A-Z]
Custom classes:
/[aeiou] " Any vowel
/[^aeiou] " Any non-vowel (^ negates)
/[0-9a-fA-F] " Hexadecimal digit
/[[:alpha:]] " POSIX character class
POSIX classes:
[[:alnum:]] " Alphanumeric
[[:alpha:]] " Alphabetic
[[:blank:]] " Space and tab
[[:digit:]] " Digits
[[:lower:]] " Lowercase
[[:upper:]] " Uppercase
[[:punct:]] " Punctuation
[[:space:]] " Whitespace
11.2 Search Modifiers and Flags
11.2.1 Inline Modifiers
Case sensitivity:
/\cpattern " Case insensitive (override settings)
/\Cpattern " Case sensitive (override settings)
/\vword\c " Can appear anywhere in pattern
Magic modes within search:
/\v pattern here " Very magic mode
/\V literal text " Very nomagic mode
/\m standard mode " Standard magic (default)
/\M nomagic mode " Nomagic mode
11.2.2 Search Offset
Move cursor after match:
" Move to end of match
/pattern/e
" Move to start of match (default)
/pattern/s
" Move N lines down after match
/pattern/+3
" Move N lines up after match
/pattern/-2
" Move to specific column
/pattern/e+2 " 2 chars after end
/pattern/s-1 " 1 char before start
Practical examples:
" Find function, cursor on opening brace
/\vdef\s+\w+.*:/e
" Find TODO, cursor on next line
/TODO:/+1
" Find closing brace, cursor on next statement
/^}/+1
11.2.3 Search Count
Show match position:
Neovim built-in (version 0.5+):
:set shortmess-=S " Show search count in command line
Shows: [1/5] indicating “match 1 of 5 total”
Manual count:
:%s/pattern//gn " Count matches without replacing
Neovim (Lua) - Enhanced search count:
vim.opt.shortmess:remove('S') -- Show count
-- Custom status line with search info
vim.api.nvim_create_autocmd('CmdlineLeave', {
pattern = '/',
callback = function()
vim.defer_fn(function()
local ok, result = pcall(vim.fn.searchcount, {recompute = true})
if ok and result.total > 0 then
print(string.format('Match %d of %d', result.current, result.total))
end
end, 100)
end
})11.3 Advanced Search Commands
11.3.1 Search in Range
Line range search:
:10,20/pattern " Search in lines 10-20
:'<,'>s/pat/rep/g " Search in visual selection
:.,$/pattern " Search from cursor to end
:?^function?+1,/^end/-1s/old/new/g " Between markers
11.3.2 Global Command Integration
The :global command executes commands on matching
lines:
Basic syntax:
:g/pattern/command " Execute command on matching lines
:g!/pattern/command " Execute on non-matching lines (inverse)
:v/pattern/command " Same as :g! (mnemonic: inVerse)
Common patterns:
" Delete all lines containing pattern
:g/pattern/d
" Copy matching lines to register
:g/pattern/y A " Append to register a
" Move matching lines to end
:g/pattern/m$
" Add comment to matching lines
:g/function/normal I#
" Execute macro on matching lines
:g/pattern/normal @q
" Complex: delete line after match
:g/pattern/+1d
Multi-line global:
" Delete function blocks
:g/^function/,/^end/d
" Comment out if-blocks
:g/^if/,/^endif/s/^/# /
11.3.3 Search and Execute
Normal mode commands after search:
" Search and delete matching lines
:g/TODO/d
" Search and change case
:g/^const/normal gUU
" Search and append text
:g/import/normal A # type: ignore
Neovim - Search and edit interactively:
vim.keymap.set('n', '<leader>se', function()
vim.ui.input({ prompt = 'Search: ' }, function(pattern)
if not pattern then return end
vim.ui.input({ prompt = 'Command: ' }, function(cmd)
if not cmd then return end
vim.cmd('g/' .. pattern .. '/' .. cmd)
end)
end)
end, { desc = 'Search and execute' })11.4 Incremental Search Features
11.4.1 Incremental Search
See matches as you type:
:set incsearch " Enable incremental search
:set hlsearch " Highlight all matches
Neovim enhanced:
vim.opt.incsearch = true
vim.opt.hlsearch = true
-- Clear highlight with Esc
vim.keymap.set('n', '<Esc>', ':nohlsearch<CR>', { silent = true })
-- Or clear on cursor move (more automatic)
vim.api.nvim_create_autocmd('CursorMoved', {
pattern = '*',
callback = function()
vim.opt.hlsearch = false
end
})
-- Re-enable on search
vim.api.nvim_create_autocmd('CmdlineEnter', {
pattern = '/',
callback = function()
vim.opt.hlsearch = true
end
})11.4.2 Live Substitution Preview
Neovim only - see replacements before confirming:
:set inccommand=split " Show preview in split
:set inccommand=nosplit " Show inline preview
vim.opt.inccommand = 'split' -- or 'nosplit'Now when typing :s/old/new/g, you see live
preview.
11.5 Pattern Memory and Reuse
11.5.1 Search History
Access search history:
q/ " Open search history window
/<Up> " Previous search
/<Down> " Next search
Neovim - Enhanced history navigation:
-- Better history navigation in command mode
vim.keymap.set('c', '<C-p>', '<Up>', { silent = true })
vim.keymap.set('c', '<C-n>', '<Down>', { silent = true })11.5.2 Named Patterns
Store patterns in variables:
Vimscript:
" Define pattern
let email_pattern = '\v[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
" Use pattern
exe '/'.email_pattern
Neovim (Lua):
local patterns = {
email = [[\v[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}]],
url = [[\vhttps?://[^\s]+]],
ipv4 = [[\v(\d{1,3}\.){3}\d{1,3}]],
hex_color = [[\v#[0-9a-fA-F]{6}]],
phone_us = [[\v\d{3}-\d{3}-\d{4}]],
}
-- Search for email
vim.keymap.set('n', '<leader>fe', function()
vim.fn.search(patterns.email)
end, { desc = 'Find email' })
-- Search for URL
vim.keymap.set('n', '<leader>fu', function()
vim.fn.search(patterns.url)
end, { desc = 'Find URL' })11.5.3 Last Search Register
The last search is stored in / register:
:echo @/ " Show last search
:let @/ = 'new' " Set search pattern
Neovim - Manipulate search programmatically:
-- Get last search
local last_search = vim.fn.getreg('/')
-- Set search pattern
vim.fn.setreg('/', 'pattern')
-- Clear search
vim.fn.setreg('/', '')
-- Highlight without moving cursor
vim.keymap.set('n', '<leader>*', function()
local word = vim.fn.expand('<cword>')
vim.fn.setreg('/', '\\<' .. word .. '\\>')
vim.opt.hlsearch = true
end, { desc = 'Highlight word under cursor' })11.6 Search Automation
11.6.1 Automated Search Highlighting
Highlight multiple patterns simultaneously:
Neovim - Multi-pattern highlighting:
local M = {}
M.highlights = {}
function M.add_highlight(pattern, group)
local id = vim.fn.matchadd(group or 'Search', pattern)
table.insert(M.highlights, id)
return id
end
function M.clear_highlights()
for _, id in ipairs(M.highlights) do
vim.fn.matchdelete(id)
end
M.highlights = {}
end
-- Usage
vim.keymap.set('n', '<leader>ha', function()
vim.ui.input({ prompt = 'Pattern: ' }, function(pattern)
if not pattern then return end
M.add_highlight(pattern, 'DiffAdd')
print('Added highlight: ' .. pattern)
end)
end, { desc = 'Add highlight' })
vim.keymap.set('n', '<leader>hc', function()
M.clear_highlights()
print('Cleared all highlights')
end, { desc = 'Clear highlights' })Highlight TODO, FIXME, etc.:
vim.api.nvim_create_autocmd({'BufEnter', 'InsertLeave'}, {
pattern = '*',
callback = function()
vim.fn.matchadd('Todo', [[\v<(TODO|FIXME|NOTE|HACK|XXX)>]])
vim.fn.matchadd('Error', [[\v<(ERROR|BUG|DEPRECATED)>]])
vim.fn.matchadd('WarningMsg', [[\v<(WARNING|WARN)>]])
end
})11.6.2 Smart Case Search
Configuration:
:set ignorecase " Ignore case when searching
:set smartcase " Override ignorecase if search has capitals
Behavior:
/foomatchesfoo,Foo,FOO/Foomatches onlyFoo/FOOmatches onlyFOO
vim.opt.ignorecase = true
vim.opt.smartcase = true11.6.3 Search Under Cursor Enhancement
Improved * and #:
-- Search word under cursor without jumping
vim.keymap.set('n', '*', function()
local word = vim.fn.expand('<cword>')
vim.fn.setreg('/', '\\<' .. word .. '\\>')
vim.opt.hlsearch = true
vim.fn.searchcount({recompute = true}) -- Update count
end, { desc = 'Highlight word (no jump)' })
-- Visual mode search
vim.keymap.set('v', '*', function()
local old_reg = vim.fn.getreg('v')
vim.cmd('normal! "vy')
local selection = vim.fn.getreg('v')
vim.fn.setreg('v', old_reg)
vim.fn.setreg('/', '\\V' .. vim.fn.escape(selection, '\\/'))
vim.opt.hlsearch = true
end, { desc = 'Search visual selection' })11.7 External Tool Integration
11.7.1 Ripgrep Integration
Configure Vim to use ripgrep:
:set grepprg=rg\ --vimgrep\ --smart-case
:set grepformat=%f:%l:%c:%m
vim.opt.grepprg = 'rg --vimgrep --smart-case'
vim.opt.grepformat = '%f:%l:%c:%m'Enhanced ripgrep search:
vim.keymap.set('n', '<leader>rg', function()
vim.ui.input({ prompt = 'Ripgrep: ' }, function(pattern)
if not pattern then return end
vim.cmd('silent grep! ' .. vim.fn.shellescape(pattern))
vim.cmd('copen')
end)
end, { desc = 'Ripgrep search' })
-- Search word under cursor with ripgrep
vim.keymap.set('n', '<leader>rw', function()
local word = vim.fn.expand('<cword>')
vim.cmd('silent grep! ' .. vim.fn.shellescape(word))
vim.cmd('copen')
end, { desc = 'Ripgrep word' })
-- Search in specific filetype
vim.keymap.set('n', '<leader>rt', function()
vim.ui.input({ prompt = 'File type: ' }, function(ft)
if not ft then return end
vim.ui.input({ prompt = 'Pattern: ' }, function(pattern)
if not pattern then return end
vim.cmd('silent grep! -t ' .. ft .. ' ' .. vim.fn.shellescape(pattern))
vim.cmd('copen')
end)
end)
end, { desc = 'Ripgrep by type' })11.7.2 FZF Integration
Install and configure fzf.vim:
-- With lazy.nvim
{
'junegunn/fzf.vim',
dependencies = { 'junegunn/fzf' },
config = function()
-- Search with ripgrep
vim.keymap.set('n', '<leader>fg', ':Rg<CR>', { desc = 'FZF ripgrep' })
-- Search in files
vim.keymap.set('n', '<leader>ff', ':Files<CR>', { desc = 'FZF files' })
-- Search in buffers
vim.keymap.set('n', '<leader>fb', ':Buffers<CR>', { desc = 'FZF buffers' })
-- Search command history
vim.keymap.set('n', '<leader>fh', ':History:<CR>', { desc = 'FZF command history' })
-- Search in lines
vim.keymap.set('n', '<leader>fl', ':Lines<CR>', { desc = 'FZF lines' })
end
}11.7.3 Telescope Integration (Neovim)
Modern fuzzy finder:
-- With lazy.nvim
{
'nvim-telescope/telescope.nvim',
dependencies = { 'nvim-lua/plenary.nvim' },
config = function()
local telescope = require('telescope.builtin')
-- Live grep
vim.keymap.set('n', '<leader>sg', telescope.live_grep,
{ desc = 'Search grep' })
-- Grep word under cursor
vim.keymap.set('n', '<leader>sw', telescope.grep_string,
{ desc = 'Search word' })
-- Fuzzy find in current buffer
vim.keymap.set('n', '<leader>/', telescope.current_buffer_fuzzy_find,
{ desc = 'Search buffer' })
-- Find files
vim.keymap.set('n', '<leader>sf', telescope.find_files,
{ desc = 'Search files' })
-- Search in old files
vim.keymap.set('n', '<leader>so', telescope.oldfiles,
{ desc = 'Search old files' })
-- Search help tags
vim.keymap.set('n', '<leader>sh', telescope.help_tags,
{ desc = 'Search help' })
end
}11.8 Search Patterns Library
11.8.1 Common Patterns
Email addresses:
/\v[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}
URLs:
/\vhttps?://[^\s]+
/\vhttps?://[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_\+.~#?&/=]*
IP addresses (IPv4):
/\v(\d{1,3}\.){3}\d{1,3}
/\v((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)
Hexadecimal colors:
/\v#[0-9a-fA-F]{6}
/\v#([0-9a-fA-F]{3}){1,2}
Phone numbers (US format):
/\v\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}
Dates:
" YYYY-MM-DD
/\v\d{4}-\d{2}-\d{2}
" MM/DD/YYYY
/\v\d{2}/\d{2}/\d{4}
" DD-MMM-YYYY
/\v\d{2}-[A-Z][a-z]{2}-\d{4}
Credit card numbers:
/\v\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}
Social Security Numbers:
/\v\d{3}-\d{2}-\d{4}
11.8.2 Code-Specific Patterns
Function definitions:
" Python
/\vdef\s+\w+\s*\(
" JavaScript/TypeScript
/\vfunction\s+\w+\s*\(
/\v(const|let|var)\s+\w+\s*\=\s*\(
" C/C++/Java
/\v(public|private|protected)?\s*(static)?\s*\w+\s+\w+\s*\(
" Go
/\vfunc\s+(\w+\s*)?\w+\s*\(
Class definitions:
" Python
/\vclass\s+\w+
" JavaScript/TypeScript
/\vclass\s+\w+
" Java
/\v(public|private|protected)?\s*class\s+\w+
Import statements:
" Python
/\v^(import|from)\s+
" JavaScript/TypeScript
/\v^import\s+
" Java
/\v^import\s+
Comments:
" Single-line comments
/\v(//|#|;).*$
" Multi-line C-style
/\v\/\*\_.\{-}\*\/
" Python docstrings
/\v"""\_.\{-}"""
TODO markers:
/\v<(TODO|FIXME|HACK|NOTE|XXX|BUG|OPTIMIZE)>
/\v(TODO|FIXME)\(\w+\): " With author: TODO(username):
11.8.3 Data Format Patterns
JSON:
" JSON string
/\v"[^"]*"\s*:\s*"[^"]*"
" JSON number
/\v"[^"]*"\s*:\s*-?\d+\.?\d*
" JSON boolean
/\v"[^"]*"\s*:\s*(true|false)
XML/HTML:
" Opening tag
/\v\<\w+[^>]*\>
" Closing tag
/\v\<\/\w+\>
" Self-closing tag
/\v\<\w+[^>]*\/\>
" Tag with specific attribute
/\v\<\w+[^>]*class\="[^"]*"[^>]*\>
CSV:
" Quoted field
/\v"[^"]*"
" Line with N fields
/\v^([^,]*,){5}[^,]*$ " 6 fields
Markdown:
" Headers
/\v^#{1,6}\s+
" Links
/\v\[[^\]]+\]\([^\)]+\)
" Code blocks
/\v```\_.\{-}```
11.9 Search Performance Optimization
11.9.1 Lazy Redraw
Disable screen updates during macros and complex operations:
:set lazyredraw
vim.opt.lazyredraw = true11.9.2 Search Timeout
Prevent hanging on complex regex:
:set maxmempattern=2000 " Max memory for pattern (default 1000)
vim.opt.maxmempattern = 200011.9.3 Efficient Pattern Design
Tips:
Use word boundaries (
\<\>) when possibleAvoid nested quantifiers:
\(.\{-}\)\+is slowUse atomic grouping:
\@>for no backtrackingPrefer character classes over alternation:
[abc]vs\(a\|b\|c\)
Optimized patterns:
" Slow
/\v(foo|bar|baz)+
" Faster
/\v[fbraoaz]+
" Slow (nested quantifiers)
/\v(.{-})+
" Faster (atomic group)
/\v(.{-})@>
11.10 Search Recipes
Recipe 1: Find Duplicate Lines
" Find duplicate consecutive lines
/\v^(.+)$\n\1$
" Find duplicate anywhere (more complex)
:g/^\(.*\)$/mo$ | g/^$$\n\1$/d
Neovim (Lua):
vim.keymap.set('n', '<leader>fd', function()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local seen = {}
local duplicates = {}
for i, line in ipairs(lines) do
if seen[line] then
table.insert(duplicates, { lnum = i, text = line })
else
seen[line] = true
end
end
vim.fn.setqflist(duplicates)
vim.cmd('copen')
print('Found ' .. #duplicates .. ' duplicate lines')
end, { desc = 'Find duplicate lines' })Recipe 2: Find Long Lines
" Find lines longer than 80 characters
/\v^.{80,}$
Neovim - Add to quickfix:
vim.keymap.set('n', '<leader>fL', function()
vim.ui.input({ prompt = 'Max length: ', default = '80' }, function(len)
if not len then return end
local pattern = '^.\\{' .. len .. ',\\}$'
vim.cmd('vimgrep /' .. pattern .. '/ %')
vim.cmd('copen')
end)
end, { desc = 'Find long lines' })Recipe 3: Find Trailing Whitespace
/\s\+$
Neovim - Highlight and remove:
-- Highlight trailing whitespace
vim.api.nvim_create_autocmd({'BufEnter', 'InsertLeave'}, {
pattern = '*',
callback = function()
vim.fn.matchadd('Error', [[\s\+$]])
end
})
-- Remove trailing whitespace
vim.keymap.set('n', '<leader>dw', function()
local view = vim.fn.winsaveview()
vim.cmd([[%s/\s\+$//e]])
vim.fn.winrestview(view)
print('Removed trailing whitespace')
end, { desc = 'Delete trailing whitespace' })Recipe 4: Find Unmatched Brackets
" Simple - find lines with unmatched ( or )
/\v^[^()]*\([^()]*$|^[^()]*\)[^()]*$
Neovim - Bracket balance checker:
vim.keymap.set('n', '<leader>fb', function()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local unmatched = {}
for i, line in ipairs(lines) do
local open = 0
for char in line:gmatch('.') do
if char == '(' then open = open + 1
elseif char == ')' then open = open - 1 end
end
if open ~= 0 then
table.insert(unmatched, { lnum = i, text = line })
end
end
vim.fn.setqflist(unmatched)
vim.cmd('copen')
end, { desc = 'Find unmatched brackets' })Recipe 5: Context-Aware Search
Show context around matches:
" Show 2 lines before and after match
:vimgrep /pattern/ % | copen
:set previewheight=5
Neovim - Enhanced context:
vim.keymap.set('n', '<leader>sc', function()
vim.ui.input({ prompt = 'Pattern: ' }, function(pattern)
if not pattern then return end
vim.ui.input({ prompt = 'Context lines: ', default = '2' }, function(ctx)
if not ctx then return end
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local matches = {}
local context = tonumber(ctx)
for i, line in ipairs(lines) do
if line:match(pattern) then
-- Add context
local start = math.max(1, i - context)
local finish = math.min(#lines, i + context)
table.insert(matches, {
lnum = i,
text = table.concat(
vim.list_slice(lines, start, finish), '\n'
)
})
end
end
vim.fn.setqflist(matches)
vim.cmd('copen')
end)
end)
end, { desc = 'Search with context' })Recipe 6: Incremental Pattern Builder
Build complex patterns interactively:
Neovim:
local PatternBuilder = {}
PatternBuilder.parts = {}
function PatternBuilder:add(part)
table.insert(self.parts, part)
return self
end
function PatternBuilder:build()
return table.concat(self.parts, '')
end
function PatternBuilder:search()
local pattern = self:build()
vim.fn.setreg('/', pattern)
vim.opt.hlsearch = true
return self
end
-- Usage
vim.keymap.set('n', '<leader>pb', function()
local builder = setmetatable({parts = {}}, {__index = PatternBuilder})
builder:add([[\v]]) -- Very magic
vim.ui.input({ prompt = 'Start pattern: ' }, function(part)
if part then builder:add(part) end
vim.ui.input({ prompt = 'Middle (optional): ' }, function(mid)
if mid then builder:add(mid) end
vim.ui.input({ prompt = 'End (optional): ' }, function(finish)
if finish then builder:add(finish) end
builder:search()
print('Pattern: ' .. builder:build())
end)
end)
end)
end, { desc = 'Pattern builder' })Chapter Summary
This chapter explored Vim’s advanced search capabilities:
Pattern Matching:
Very magic mode (
\v) for cleaner regex syntaxMulti-line patterns using
\_sand\_.Zero-width assertions: lookahead/lookbehind (
\@=,\@!,\@<=,\@<!)Word boundaries and character classes
Case sensitivity modifiers (
\c,\C)
Search Modifiers:
Search offsets (
/e,/s,/+n,/-n)Inline pattern modifiers
Search count display (
:set shortmess-=S)Smart case search
Advanced Commands:
Range-specific search
Global command (
:g) for batch operationsSearch history and pattern reuse
Last search register (
@/)
External Integration:
Ripgrep configuration for fast project search
FZF for fuzzy finding
Telescope for modern Neovim experience
Custom grep programs
Automation:
Multi-pattern highlighting
Automated TODO/FIXME highlighting
Enhanced
*and#operatorsVisual selection search
Pattern Library:
Email, URL, IP address patterns
Code-specific patterns (functions, classes, imports)
Data format patterns (JSON, XML, CSV, Markdown)
Common validation patterns
Performance:
Lazy redraw during complex operations
Search timeout configuration
Efficient pattern design strategies
Avoiding slow regex constructs
Practical Recipes:
Finding duplicates, long lines, trailing whitespace
Bracket matching validation
Context-aware searching
Interactive pattern building
Best Practices:
Use very magic mode for readable patterns
Leverage word boundaries for precision
Integrate external tools for performance
Store common patterns in named variables
Combine search with quickfix for multi-file workflows
Use lookarounds for complex matching
Test patterns on small datasets first
Key Insights:
Search is more than finding—it’s a navigation and refactoring tool
Pattern composition enables complex transformations
External tools complement Vim’s built-in capabilities
Automation reduces repetitive search tasks
Well-designed patterns significantly improve performance
Integration with quickfix multiplies search power
Mastering advanced search transforms Vim from a text editor into a sophisticated pattern-matching and code analysis tool. Combined with the quickfix list, macros, and external tools, these techniques enable systematic codebase exploration, validation, and transformation at scale.
In the next chapter, we’ll explore Marks and Jumps, covering position tracking, cross-file navigation, and movement history management.
Chapter 12: Completion and Omni-Completion
Vim’s completion system transforms text entry from manual typing into an intelligent, context-aware process. This chapter explores built-in completion mechanisms, omni-completion for language-aware suggestions, and modern completion frameworks that bring IDE-like features to Vim/Neovim.
12.1 Understanding Vim’s Completion System
12.1.1 Completion Philosophy
Vim offers multiple completion modes, each triggered by specific key combinations in Insert mode. Unlike IDEs that automatically show suggestions, Vim requires explicit activation, giving you precise control.
Core principle: <C-x> enters
completion sub-mode, followed by a second key specifying completion
type.
12.1.2 Completion Workflow
Insert Mode →
In completion mode:
<C-n>- Next match<C-p>- Previous match<C-y>- Accept current match<C-e>- Cancel and return to original text<CR>- Accept match and insert newlineAny character - Accept match and insert that character
12.2 Basic Completion Types
12.2.1 Line Completion
(<C-x><C-l>)
Complete entire lines from current buffer or other loaded buffers.
Use case: Repeating similar code structures.
# Type: print("Hello
# Press: <C-x><C-l>
# Result: print("Hello, world!") # if this line exists elsewhereConfiguration:
set complete+=. " Current buffer
set complete+=w " Buffers in other windows
set complete+=b " Other loaded buffers
set complete+=t " Tags
vim.opt.complete = { '.', 'w', 'b', 't' }12.2.2 Keyword
Completion (<C-x><C-n> or
<C-x><C-p>)
Complete words from current buffer and sources specified in
complete option.
// Type: constru
// Press: <C-x><C-n>
// Suggests: constructorDirect shortcuts (no <C-x>
needed):
<C-n>- Next keyword match<C-p>- Previous keyword match
12.2.3 Dictionary Completion
(<C-x><C-k>)
Complete from dictionary files.
Setup:
set dictionary+=/usr/share/dict/words
set dictionary+=~/.vim/custom-dict.txt
vim.opt.dictionary = '/usr/share/dict/words,~/.config/nvim/custom-dict.txt'Create custom dictionary:
# Create language-specific dictionary
echo "async\nawait\npromise\ncallback" > ~/.vim/javascript-dict.txtautocmd FileType javascript set dictionary+=~/.vim/javascript-dict.txt
12.2.4 Thesaurus Completion
(<C-x><C-t>)
Find synonyms and related words.
Setup:
# Download thesaurus (example: OpenOffice thesaurus)
wget https://raw.githubusercontent.com/LibreOffice/dictionaries/master/en/th_en_US_v2.dat \
-O ~/.vim/thesaurus.txtset thesaurus+=~/.vim/thesaurus.txt
vim.opt.thesaurus = vim.opt.thesaurus + '~/.config/nvim/thesaurus.txt'12.2.5 File Path Completion
(<C-x><C-f>)
Complete file and directory paths.
# Type: from /home/user/proj
# Press: <C-x><C-f>
# Navigates filesystem, suggests directories/filesEnhanced with wildmenu:
set wildmenu
set wildmode=longest:full,full
vim.opt.wildmenu = true
vim.opt.wildmode = { 'longest:full', 'full' }12.2.6 Tag Completion
(<C-x><C-]>)
Complete from ctags database.
Generate tags:
# Universal Ctags
ctags -R .
# For specific languages
ctags -R --languages=python .Vim configuration:
set tags=./tags,tags;$HOME
vim.opt.tags = { './tags', 'tags', vim.fn.expand('$HOME') .. '/tags' }12.2.7 Spell Check
Completion (<C-x><C-s> or
<C-x>s)
Suggest spelling corrections.
set spell spelllang=en_us
vim.opt.spell = true
vim.opt.spelllang = { 'en_us' }Type: recomend
Press:
Suggests: recommend
12.2.8 Command-Line
Completion (<C-x><C-v>)
Complete Vim commands.
# Type in Insert mode: echo getw
# Press: <C-x><C-v>
# Suggests: getwinvar, getwinpos, etc.
12.2.9 Defined Name
Completion (<C-x><C-d>)
Complete preprocessor definitions (C/C++).
// Completes #define macros12.2.10 Include File
Completion (<C-x><C-i>)
Complete from included files (respects include
option).
// Type in C file: prin
// Press: <C-x><C-i>
// Suggests: printf (from stdio.h)12.3 Omni-Completion
12.3.1 What is Omni-Completion?
Language-aware, context-sensitive completion triggered by
<C-x><C-o>. Provides:
Method/property suggestions
Function signatures
Module imports
Type information
12.3.2 Enabling Omni-Completion
Basic setup:
filetype plugin on
set omnifunc=syntaxcomplete#Complete
vim.cmd('filetype plugin on')
vim.opt.omnifunc = 'syntaxcomplete#Complete'12.3.3 Language-Specific Omni-Completion
Vim includes built-in omni-completion for many languages:
Python:
autocmd FileType python setlocal omnifunc=python3complete#Complete
JavaScript:
autocmd FileType javascript setlocal omnifunc=javascriptcomplete#CompleteJS
HTML:
autocmd FileType html setlocal omnifunc=htmlcomplete#CompleteTags
CSS:
autocmd FileType css setlocal omnifunc=csscomplete#CompleteCSS
XML:
autocmd FileType xml setlocal omnifunc=xmlcomplete#CompleteTags
PHP:
autocmd FileType php setlocal omnifunc=phpcomplete#CompletePHP
12.3.4 Omni-Completion in Action
// Type: document.getElem
// Press: <C-x><C-o>
// Suggests: getElementById, getElementsByClassName, etc.# Type: import os
# os.pa
# Press: <C-x><C-o>
# Suggests: path, pathconf, pathconf_names, etc.12.3.5 Custom Omni-Functions
Create language-specific completion:
Vimscript example:
function! CustomOmniComplete(findstart, base)
if a:findstart
" Find start of word
let line = getline('.')
let start = col('.') - 1
while start > 0 && line[start - 1] =~ '\a'
let start -= 1
endwhile
return start
else
" Generate completions
let completions = ['apple', 'banana', 'cherry']
return filter(completions, 'v:val =~ "^" . a:base')
endif
endfunction
set omnifunc=CustomOmniComplete
Neovim (Lua) example:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'myfiletype',
callback = function()
vim.bo.omnifunc = 'v:lua.custom_omni_complete'
end
})
function _G.custom_omni_complete(findstart, base)
if findstart == 1 then
-- Find start of word
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local start = col
while start > 0 and line:sub(start, start):match('%a') do
start = start - 1
end
return start
else
-- Generate completions
local completions = { 'apple', 'banana', 'cherry', 'date' }
local matches = {}
for _, word in ipairs(completions) do
if word:sub(1, #base) == base then
table.insert(matches, word)
end
end
return matches
end
end12.4 Completion Options and Behavior
12.4.1 Complete Options
set completeopt=menu,menuone,noselect,preview
Options explained:
menu- Show popup menumenuone- Show menu even with one matchnoselect- Don’t auto-select first matchpreview- Show extra info in preview windownoinsert- Don’t insert text until selectionlongest- Insert longest common text
vim.opt.completeopt = { 'menu', 'menuone', 'noselect', 'preview' }12.4.2 Popup Menu Appearance
set pumheight=15 " Max items in popup menu
set pumwidth=20 " Min width of popup menu
vim.opt.pumheight = 15
vim.opt.pumwidth = 2012.4.3 Completion Timeout
set updatetime=300 " Faster completion (default 4000ms)
vim.opt.updatetime = 30012.4.4 Case Sensitivity
set ignorecase " Ignore case when completing
set infercase " Adjust case of completion based on typed text
vim.opt.ignorecase = true
vim.opt.infercase = true12.5 User-Defined Completion
12.5.1 Complete Function Interface
Define custom completion sources with
completefunc:
function! MyComplete(findstart, base)
if a:findstart
" Return start column of completion
return col('.') - 1
else
" Return list of matches
return ['match1', 'match2', 'match3']
endif
endfunction
set completefunc=MyComplete
Trigger with <C-x><C-u>.
12.5.2 Complete Items Structure
Return structured completion items:
function! DetailedComplete(findstart, base)
if a:findstart
return col('.') - 1
else
return [
\ {'word': 'function', 'menu': 'keyword', 'info': 'Define a function'},
\ {'word': 'class', 'menu': 'keyword', 'info': 'Define a class'},
\ {'word': 'import', 'menu': 'keyword', 'info': 'Import a module'}
\ ]
endif
endfunction
Item structure:
word- Text to insertabbr- Abbreviated form shown in menumenu- Extra text in menu (type info)info- Extra info shown in previewkind- Single letter kind indicatoricase- Ignore case (0 or 1)dup- Allow duplicate (0 or 1)empty- Allow empty string
12.5.3 Neovim Complete Item Example
function _G.detailed_complete(findstart, base)
if findstart == 1 then
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
return col
else
local items = {
{
word = 'async',
abbr = 'async',
menu = '[keyword]',
info = 'Async function modifier',
kind = 'k',
},
{
word = 'await',
abbr = 'await',
menu = '[keyword]',
info = 'Await promise resolution',
kind = 'k',
},
{
word = 'Promise',
abbr = 'Promise',
menu = '[class]',
info = 'Promise constructor',
kind = 'c',
},
}
-- Filter by base
local matches = {}
for _, item in ipairs(items) do
if vim.startswith(item.word, base) then
table.insert(matches, item)
end
end
return matches
end
end
vim.opt.completefunc = 'v:lua.detailed_complete'12.6 Modern Completion Frameworks
12.6.1 nvim-cmp (Neovim)
Modern, extensible completion engine.
Installation (lazy.nvim):
{
'hrsh7th/nvim-cmp',
dependencies = {
'hrsh7th/cmp-nvim-lsp', -- LSP source
'hrsh7th/cmp-buffer', -- Buffer source
'hrsh7th/cmp-path', -- Path source
'hrsh7th/cmp-cmdline', -- Command-line source
'hrsh7th/cmp-nvim-lua', -- Neovim Lua API
'saadparwaiz1/cmp_luasnip', -- Snippet source
'L3MON4D3/LuaSnip', -- Snippet engine
},
config = function()
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({
['<C-b>'] = cmp.mapping.scroll_docs(-4),
['<C-f>'] = cmp.mapping.scroll_docs(4),
['<C-Space>'] = cmp.mapping.complete(),
['<C-e>'] = cmp.mapping.abort(),
['<CR>'] = cmp.mapping.confirm({ select = true }),
-- Tab completion
['<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' },
{ name = 'luasnip' },
{ name = 'nvim_lua' },
}, {
{ name = 'buffer', keyword_length = 3 },
{ name = 'path' },
}),
formatting = {
format = function(entry, vim_item)
-- Kind icons
vim_item.kind = string.format('%s %s',
kind_icons[vim_item.kind], vim_item.kind)
-- Source name
vim_item.menu = ({
nvim_lsp = '[LSP]',
luasnip = '[Snip]',
buffer = '[Buf]',
path = '[Path]',
nvim_lua = '[Lua]',
})[entry.source.name]
return vim_item
end,
},
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
experimental = {
ghost_text = true, -- Show preview as ghost text
},
})
-- Command-line completion
cmp.setup.cmdline('/', {
mapping = cmp.mapping.preset.cmdline(),
sources = {
{ name = 'buffer' }
}
})
cmp.setup.cmdline(':', {
mapping = cmp.mapping.preset.cmdline(),
sources = cmp.config.sources({
{ name = 'path' }
}, {
{ name = 'cmdline' }
})
})
end
}12.6.2 nvim-cmp Source Priority
Control source order and behavior:
sources = cmp.config.sources({
-- High priority
{ name = 'nvim_lsp', priority = 1000 },
{ name = 'luasnip', priority = 750 },
}, {
-- Lower priority
{ name = 'buffer', priority = 500, keyword_length = 3 },
{ name = 'path', priority = 250 },
}),12.6.3 Custom nvim-cmp Source
Create project-specific completion:
local source = {}
function source:is_available()
return vim.bo.filetype == 'myfiletype'
end
function source:get_keyword_pattern()
return [[\k\+]]
end
function source:complete(params, callback)
local items = {
{ label = 'custom_func', kind = cmp.lsp.CompletionItemKind.Function },
{ label = 'custom_var', kind = cmp.lsp.CompletionItemKind.Variable },
}
callback({ items = items, isIncomplete = false })
end
-- Register source
require('cmp').register_source('my_source', source)
-- Use in setup
sources = {
{ name = 'my_source' },
{ name = 'nvim_lsp' },
}12.6.4 CoC.nvim (Vim & Neovim)
VSCode-like completion using Language Server Protocol.
Installation:
" Using vim-plug
Plug 'neoclide/coc.nvim', {'branch': 'release'}
Basic configuration:
" Use Tab for trigger completion
inoremap <silent><expr> <TAB>
\ coc#pum#visible() ? coc#pum#next(1) :
\ CheckBackspace() ? "\<Tab>" :
\ coc#refresh()
inoremap <expr><S-TAB> coc#pum#visible() ? coc#pum#prev(1) : "\<C-h>"
" Make <CR> to accept selected completion
inoremap <silent><expr> <CR> coc#pum#visible() ? coc#pum#confirm()
\: "\<C-g>u\<CR>\<c-r>=coc#on_enter()\<CR>"
function! CheckBackspace() abort
let col = col('.') - 1
return !col || getline('.')[col - 1] =~# '\s'
endfunction
" Use <c-space> to trigger completion
inoremap <silent><expr> <c-space> coc#refresh()
" Navigate diagnostics
nmap <silent> [g <Plug>(coc-diagnostic-prev)
nmap <silent> ]g <Plug>(coc-diagnostic-next)
" GoTo code navigation
nmap <silent> gd <Plug>(coc-definition)
nmap <silent> gy <Plug>(coc-type-definition)
nmap <silent> gi <Plug>(coc-implementation)
nmap <silent> gr <Plug>(coc-references)
" Show documentation
nnoremap <silent> K :call ShowDocumentation()<CR>
function! ShowDocumentation()
if CocAction('hasProvider', 'hover')
call CocActionAsync('doHover')
else
call feedkeys('K', 'in')
endif
endfunction
Install language servers:
:CocInstall coc-tsserver " TypeScript/JavaScript
:CocInstall coc-pyright " Python
:CocInstall coc-rust-analyzer " Rust
:CocInstall coc-json " JSON
:CocInstall coc-html " HTML
:CocInstall coc-css " CSS
12.6.5 YouCompleteMe (Vim & Neovim)
Fast, compiled completion engine.
Installation:
" Using vim-plug
Plug 'ycm-core/YouCompleteMe'
Build with language support:
cd ~/.vim/plugged/YouCompleteMe
python3 install.py --all # All languages
# Or specific:
python3 install.py --ts-completer # TypeScript/JavaScript
python3 install.py --rust-completer # Rust
python3 install.py --go-completer # GoConfiguration:
let g:ycm_key_list_select_completion = ['<C-n>', '<Down>']
let g:ycm_key_list_previous_completion = ['<C-p>', '<Up>']
let g:ycm_auto_trigger = 1
let g:ycm_min_num_of_chars_for_completion = 2
let g:ycm_complete_in_comments = 1
let g:ycm_complete_in_strings = 1
let g:ycm_collect_identifiers_from_tags_files = 1
" Semantic triggers
let g:ycm_semantic_triggers = {
\ 'c,cpp,python,java,go,rust,cs,javascript,typescript': ['re!\w{2}'],
\ }
12.7 LSP-Based Completion
12.7.1 Native Neovim LSP
Setup LSP with completion:
-- Install language servers via Mason
{
'williamboman/mason.nvim',
'williamboman/mason-lspconfig.nvim',
'neovim/nvim-lspconfig',
}
-- Configure LSP
local lspconfig = require('lspconfig')
local capabilities = require('cmp_nvim_lsp').default_capabilities()
-- TypeScript
lspconfig.tsserver.setup({
capabilities = capabilities,
on_attach = function(client, bufnr)
-- Keybindings
local opts = { buffer = bufnr }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, opts)
end,
})
-- Python
lspconfig.pyright.setup({
capabilities = capabilities,
})
-- Rust
lspconfig.rust_analyzer.setup({
capabilities = capabilities,
settings = {
['rust-analyzer'] = {
checkOnSave = {
command = 'clippy',
},
},
},
})12.7.2 LSP Signature Help
Show function signatures while typing:
-- Manual trigger
vim.keymap.set('i', '<C-k>', vim.lsp.buf.signature_help,
{ desc = 'Signature help' })
-- Automatic with plugin
{
'ray-x/lsp_signature.nvim',
config = function()
require('lsp_signature').setup({
bind = true,
handler_opts = {
border = 'rounded'
},
hint_enable = false,
})
end
}12.8 Snippet Integration
12.8.1 LuaSnip (Neovim)
Powerful snippet engine.
Basic snippets:
local ls = require('luasnip')
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
ls.add_snippets('all', {
s('todo', {
t('TODO('), i(1, 'name'), t('): '), i(2, 'description')
}),
})
ls.add_snippets('python', {
s('def', {
t('def '), i(1, 'function_name'), t('('), i(2, 'args'), t('):'),
t({'', ' '}), i(3, 'pass'),
}),
})
-- Keybindings
vim.keymap.set({'i', 's'}, '<C-l>', function()
if ls.expand_or_jumpable() then
ls.expand_or_jump()
end
end, { silent = true })
vim.keymap.set({'i', 's'}, '<C-h>', function()
if ls.jumpable(-1) then
ls.jump(-1)
end
end, { silent = true })12.8.2 UltiSnips (Vim & Neovim)
Classic snippet engine.
Installation:
Plug 'SirVer/ultisnips'
Plug 'honza/vim-snippets' " Snippet collection
Configuration:
let g:UltiSnipsExpandTrigger="<tab>"
let g:UltiSnipsJumpForwardTrigger="<c-b>"
let g:UltiSnipsJumpBackwardTrigger="<c-z>"
let g:UltiSnipsEditSplit="vertical"
Create custom snippet:
:UltiSnipsEdit
snippet func "Function definition"
function ${1:function_name}(${2:args}) {
${3:// body}
}
endsnippet
snippet for "For loop"
for (let ${1:i} = 0; $1 < ${2:count}; $1++) {
${3:// body}
}
endsnippet
12.9 Completion Recipes
Recipe 1: Context-Aware Completion
-- Complete based on filetype and context
function _G.smart_complete()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local before_cursor = line:sub(1, col)
-- If after '.', use omni-completion
if before_cursor:match('%.$') then
return vim.fn['feedkeys'](vim.api.nvim_replace_termcodes(
'<C-x><C-o>', true, false, true
))
end
-- If in string, use file completion
if before_cursor:match('["\']') then
return vim.fn['feedkeys'](vim.api.nvim_replace_termcodes(
'<C-x><C-f>', true, false, true
))
end
-- Default to keyword completion
return vim.fn['feedkeys'](vim.api.nvim_replace_termcodes(
'<C-n>', true, false, true
))
end
vim.keymap.set('i', '<C-Space>', _G.smart_complete, { expr = true })Recipe 2: Fuzzy File Finder Completion
-- Complete file paths with fuzzy matching
{
'nvim-telescope/telescope.nvim',
config = function()
local telescope = require('telescope.builtin')
-- Insert mode file completion
vim.keymap.set('i', '<C-x><C-t>', function()
telescope.find_files({
attach_mappings = function(_, map)
map('i', '<CR>', function(prompt_bufnr)
local selection = require('telescope.actions.state')
.get_selected_entry()
require('telescope.actions').close(prompt_bufnr)
-- Insert filename at cursor
local row, col = unpack(vim.api.nvim_win_get_cursor(0))
local line = vim.api.nvim_get_current_line()
local new_line = line:sub(1, col) .. selection.value
.. line:sub(col + 1)
vim.api.nvim_set_current_line(new_line)
vim.api.nvim_win_set_cursor(0, {row, col + #selection.value})
end)
return true
end,
})
end, { desc = 'Fuzzy file completion' })
end
}Recipe 3: AI-Powered Completion
-- GitHub Copilot integration
{
'github/copilot.vim',
config = function()
vim.g.copilot_no_tab_map = true
vim.keymap.set('i', '<C-J>', 'copilot#Accept("\\<CR>")', {
expr = true,
replace_keycodes = false
})
vim.g.copilot_filetypes = {
['*'] = false,
python = true,
javascript = true,
typescript = true,
rust = true,
go = true,
}
end
}
-- Alternative: Codeium
{
'Exafunction/codeium.vim',
config = function()
vim.g.codeium_disable_bindings = 1
vim.keymap.set('i', '<C-g>', function()
return vim.fn['codeium#Accept']()
end, { expr = true })
vim.keymap.set('i', '<C-n>', function()
return vim.fn['codeium#CycleCompletions'](1)
end, { expr = true })
end
}Recipe 4: Abbreviation-Based Completion
Expand abbreviations automatically:
" Vimscript abbreviations
iabbrev teh the
iabbrev @@ your.email@example.com
iabbrev ccopy Copyright 2025 Your Name
" Date expansion
iabbrev <expr> dts strftime("%Y-%m-%d")
iabbrev <expr> dtt strftime("%H:%M:%S")
-- Neovim Lua abbreviations
vim.cmd([[
iabbrev teh the
iabbrev @@ your.email@example.com
iabbrev <expr> dts strftime("%Y-%m-%d")
]])
-- Dynamic abbreviations
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.cmd([[
iabbrev <buffer> pdb import pdb; pdb.set_trace()
iabbrev <buffer> ipdb import ipdb; ipdb.set_trace()
]])
end
})Recipe 5: Custom Dictionary Per Project
-- Load project-specific dictionary
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
local dict_file = vim.fn.getcwd() .. '/.vim-dictionary'
if vim.fn.filereadable(dict_file) == 1 then
vim.opt.dictionary:append(dict_file)
print('Loaded project dictionary: ' .. dict_file)
end
end
})Create .vim-dictionary in project root: asyncHandler
promisify getElementById componentDidMount
Chapter Summary
This chapter explored Vim’s comprehensive completion system:
Built-in Completion Types:
Line completion (
<C-x><C-l>)Keyword completion (
<C-n>,<C-p>)Dictionary completion (
<C-x><C-k>)Thesaurus completion (
<C-x><C-t>)File path completion (
<C-x><C-f>)Tag completion (
<C-x><C-]>)Spell check completion (
<C-x><C-s>)Command completion (
<C-x><C-v>)Include file completion (
<C-x><C-i>)
Omni-Completion:
Language-aware completion (
<C-x><C-o>)Built-in support for Python, JavaScript, HTML, CSS, PHP
Custom omni-functions for specialized completion
Completion Configuration:
completeoptsettings (menu, preview, noselect)Case sensitivity and inference
Popup menu customization
Completion sources and priorities
Custom Completion:
User-defined completion functions
Structured completion items
Custom sources for specific filetypes
Modern Frameworks:
nvim-cmp: Extensible, fast, modern (Neovim)
CoC.nvim: VSCode-like LSP integration
YouCompleteMe: Fast compiled engine
LSP Integration:
Native Neovim LSP support
Language server configuration
Signature help and documentation
Code actions and refactoring
Snippet Systems:
LuaSnip: Modern Neovim snippet engine
UltiSnips: Classic, powerful snippets
Custom snippet creation
Dynamic snippet expansion
Advanced Techniques:
Context-aware smart completion
Fuzzy file path insertion
AI-powered completion (Copilot, Codeium)
Abbreviation systems
Project-specific dictionaries
Best Practices:
Use
noselectto avoid accidental insertionsConfigure language servers for accurate suggestions
Combine multiple sources with priority
Create project-specific dictionaries
Map intuitive keys (Tab, Ctrl-Space)
Enable signature help for function parameters
Use ghost text for inline previews
Leverage snippets for boilerplate code
Key Insights:
Vim’s completion is modular—each type serves a purpose
Modern frameworks unify completion sources
LSP integration brings IDE-level intelligence
Snippets reduce repetitive typing
Combination of sources creates comprehensive completion
Context awareness improves accuracy
Custom completion functions enable domain-specific workflows
Mastering completion transforms coding from character-by-character typing into high-level intent expression. Combined with LSP, snippets, and modern frameworks, Vim becomes a fully-featured development environment rivaling traditional IDEs while maintaining its characteristic speed and keyboard-driven efficiency.
In the next chapter, we’ll explore Marks and Jumps, covering position tracking, cross-file navigation, and movement history management for efficient codebase exploration.
Chapter 13: The vimrc/init.vim/init.lua
Your configuration file is Vim’s DNA—a living document that evolves with your workflow. This chapter explores configuration architecture, best practices, and advanced techniques for creating a maintainable, performant setup.
13.1 Configuration File Basics
13.1.1 File Locations and Naming
Vim:
# Unix/Linux/macOS
~/.vimrc
~/.vim/vimrc
# Windows
$HOME/_vimrc
$HOME/vimfiles/vimrcNeovim:
# Unix/Linux/macOS
~/.config/nvim/init.vim # Vimscript
~/.config/nvim/init.lua # Lua
# Windows
~/AppData/Local/nvim/init.vim
~/AppData/Local/nvim/init.luaCheck current configuration path:
:echo $MYVIMRC
13.1.2 Choosing Configuration Language
Neovim offers three approaches:
Pure Vimscript (
init.vim):Maximum compatibility with Vim
Familiar syntax for Vim users
Access to all Vim functions
Pure Lua (
init.lua):Better performance
Modern language features
First-class Neovim API access
Hybrid (Lua + Vimscript):
Use Lua for performance-critical code
Keep Vimscript for simple settings
Gradual migration path
13.1.3 Configuration Loading Order
Vim’s startup sequence:
System vimrc (/etc/vimrc)
User vimrc (~/.vimrc)
User gvimrc (~/.gvimrc) [GUI only]
Defaults.vim (if no user vimrc found)
Plugin scripts
After directory scripts (~/.vim/after/)
Check what files are loaded:
:scriptnames
13.2 Basic Configuration Structure
13.2.1 Minimal Vimrc Example
" ~/.vimrc - Minimal configuration
" Compatibility
set nocompatible " Use Vim defaults (not vi)
" Basic settings
set number " Show line numbers
set relativenumber " Relative line numbers
set mouse=a " Enable mouse support
set encoding=utf-8 " UTF-8 encoding
" Indentation
set tabstop=4 " Tab width
set shiftwidth=4 " Indent width
set expandtab " Use spaces instead of tabs
set smartindent " Smart auto-indenting
" Search
set incsearch " Incremental search
set hlsearch " Highlight search results
set ignorecase " Case-insensitive search
set smartcase " Case-sensitive if uppercase present
" Interface
set showcmd " Show command in bottom bar
set wildmenu " Visual autocomplete for command menu
set laststatus=2 " Always show status line
" Performance
set lazyredraw " Don't redraw during macros
set updatetime=300 " Faster completion
" Backup and swap
set nobackup " No backup files
set nowritebackup " No backup while editing
set noswapfile " No swap files
" Enable filetype detection
filetype plugin indent on
syntax enable
13.2.2 Minimal init.lua Example
-- ~/.config/nvim/init.lua - Minimal configuration
-- Basic settings
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.mouse = 'a'
vim.opt.encoding = 'utf-8'
-- Indentation
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4
vim.opt.expandtab = true
vim.opt.smartindent = true
-- Search
vim.opt.incsearch = true
vim.opt.hlsearch = true
vim.opt.ignorecase = true
vim.opt.smartcase = true
-- Interface
vim.opt.showcmd = true
vim.opt.wildmenu = true
vim.opt.laststatus = 2
-- Performance
vim.opt.lazyredraw = true
vim.opt.updatetime = 300
-- Backup and swap
vim.opt.backup = false
vim.opt.writebackup = false
vim.opt.swapfile = false
-- Enable filetype detection
vim.cmd('filetype plugin indent on')
vim.cmd('syntax enable')13.3 Modular Configuration
13.3.1 Organizing Vimscript Configuration
Directory structure: ~/.vim/
├│── vimrc # Main config
├│── autoload/ # Auto-loaded functions
├│── plugin/ # Plugin configurations
├│── ftplugin/ # Filetype-specific settings
├│── colors/ # Color schemes └── after/ # Late-loading scripts └── plugin/
Main vimrc with sourcing:
" ~/.vimrc
" Source modular configs
runtime config/settings.vim
runtime config/mappings.vim
runtime config/plugins.vim
runtime config/autocmds.vim
~/.vim/config/settings.vim:
" Basic settings module
set number
set relativenumber
set cursorline
set termguicolors
" Tab behavior
set tabstop=2
set shiftwidth=2
set expandtab
" Search settings
set ignorecase
set smartcase
set hlsearch
set incsearch
" Split behavior
set splitright
set splitbelow
~/.vim/config/mappings.vim:
" Key mappings module
" Set leader key
let mapleader = ' '
let maplocalleader = ','
" Quick save
nnoremap <leader>w :write<CR>
" Clear search highlighting
nnoremap <Esc><Esc> :nohlsearch<CR>
" Window navigation
nnoremap <C-h> <C-w>h
nnoremap <C-j> <C-w>j
nnoremap <C-k> <C-w>k
nnoremap <C-l> <C-w>l
" Buffer navigation
nnoremap <leader>bn :bnext<CR>
nnoremap <leader>bp :bprevious<CR>
13.3.2 Organizing Lua Configuration
Directory structure: ~/.config/nvim/
├│── init.lua # Entry point └── lua/ ├── core/ │ ├── options.lua │ ├── keymaps.lua │ └── autocmds.lua ├── plugins/ │ ├── init.lua │ ├── lsp.lua │ ├── telescope.lua │ └── treesitter.lua └── utils/ └── helpers.lua
~/.config/nvim/init.lua:
-- Entry point - load modules
require('core.options')
require('core.keymaps')
require('core.autocmds')
require('plugins')~/.config/nvim/lua/core/options.lua:
-- Option settings module
local opt = vim.opt
-- Line numbers
opt.number = true
opt.relativenumber = true
opt.cursorline = true
-- Tabs and indentation
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.autoindent = true
opt.smartindent = true
-- Search
opt.ignorecase = true
opt.smartcase = true
opt.hlsearch = true
opt.incsearch = true
-- Appearance
opt.termguicolors = true
opt.background = 'dark'
opt.signcolumn = 'yes'
opt.showmode = false
-- Splits
opt.splitright = true
opt.splitbelow = true
-- Clipboard
opt.clipboard = 'unnamedplus'
-- Performance
opt.updatetime = 250
opt.timeoutlen = 300
-- Backup
opt.swapfile = false
opt.backup = false
opt.undofile = true
opt.undodir = os.getenv('HOME') .. '/.vim/undodir'~/.config/nvim/lua/core/keymaps.lua:
-- Keymap settings module
local keymap = vim.keymap
local opts = { noremap = true, silent = true }
-- Leader key
vim.g.mapleader = ' '
vim.g.maplocalleader = ','
-- Quick save
keymap.set('n', '<leader>w', ':w<CR>', opts)
-- Clear search highlighting
keymap.set('n', '<Esc>', ':nohlsearch<CR>', opts)
-- Window navigation
keymap.set('n', '<C-h>', '<C-w>h', opts)
keymap.set('n', '<C-j>', '<C-w>j', opts)
keymap.set('n', '<C-k>', '<C-w>k', opts)
keymap.set('n', '<C-l>', '<C-w>l', opts)
-- Window resizing
keymap.set('n', '<C-Up>', ':resize +2<CR>', opts)
keymap.set('n', '<C-Down>', ':resize -2<CR>', opts)
keymap.set('n', '<C-Left>', ':vertical resize -2<CR>', opts)
keymap.set('n', '<C-Right>', ':vertical resize +2<CR>', opts)
-- Buffer navigation
keymap.set('n', '<S-l>', ':bnext<CR>', opts)
keymap.set('n', '<S-h>', ':bprevious<CR>', opts)
keymap.set('n', '<leader>bd', ':bdelete<CR>', opts)
-- Indentation in visual mode
keymap.set('v', '<', '<gv', opts)
keymap.set('v', '>', '>gv', opts)
-- Move text up/down
keymap.set('v', 'J', ":m '>+1<CR>gv=gv", opts)
keymap.set('v', 'K', ":m '<-2<CR>gv=gv", opts)
-- Better paste
keymap.set('v', 'p', '"_dP', opts)~/.config/nvim/lua/core/autocmds.lua:
-- Autocommand module
local autocmd = vim.api.nvim_create_autocmd
local augroup = vim.api.nvim_create_augroup
-- General settings group
local general = augroup('General', { clear = true })
-- Highlight on yank
autocmd('TextYankPost', {
group = general,
callback = function()
vim.highlight.on_yank({ higroup = 'Visual', timeout = 200 })
end,
})
-- Remove trailing whitespace on save
autocmd('BufWritePre', {
group = general,
pattern = '*',
callback = function()
local save_cursor = vim.fn.getpos('.')
vim.cmd([[%s/\s\+$//e]])
vim.fn.setpos('.', save_cursor)
end,
})
-- Auto-format on save (specific filetypes)
autocmd('BufWritePre', {
group = general,
pattern = { '*.lua', '*.py', '*.js', '*.ts' },
callback = function()
vim.lsp.buf.format({ async = false })
end,
})
-- Restore cursor position
autocmd('BufReadPost', {
group = general,
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local lcount = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= lcount then
pcall(vim.api.nvim_win_set_cursor, 0, mark)
end
end,
})
-- Filetype-specific settings
local filetype = augroup('FileType', { clear = true })
-- Python
autocmd('FileType', {
group = filetype,
pattern = 'python',
callback = function()
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.textwidth = 88
end,
})
-- Go
autocmd('FileType', {
group = filetype,
pattern = 'go',
callback = function()
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.expandtab = false
end,
})
-- Web development
autocmd('FileType', {
group = filetype,
pattern = { 'javascript', 'typescript', 'html', 'css', 'json' },
callback = function()
vim.opt_local.tabstop = 2
vim.opt_local.shiftwidth = 2
end,
})
-- Markdown
autocmd('FileType', {
group = filetype,
pattern = 'markdown',
callback = function()
vim.opt_local.wrap = true
vim.opt_local.spell = true
vim.opt_local.textwidth = 80
end,
})13.4 Plugin Management
13.4.1 Using vim-plug (Vim & Neovim)
Installation:
# Vim
curl -fLo ~/.vim/autoload/plug.vim --create-dirs \
https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim
# Neovim
sh -c 'curl -fLo "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/autoload/plug.vim --create-dirs \
https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim'Configuration (~/.vimrc or ~/.config/nvim/init.vim):
call plug#begin('~/.vim/plugged')
" Essential plugins
Plug 'tpope/vim-sensible' " Sensible defaults
Plug 'tpope/vim-surround' " Surround text objects
Plug 'tpope/vim-commentary' " Comment/uncomment
" File navigation
Plug 'junegunn/fzf', { 'do': { -> fzf#install() } }
Plug 'junegunn/fzf.vim'
" Status line
Plug 'vim-airline/vim-airline'
Plug 'vim-airline/vim-airline-themes'
" Git integration
Plug 'tpope/vim-fugitive'
Plug 'airblade/vim-gitgutter'
" Syntax highlighting
Plug 'sheerun/vim-polyglot'
" Color schemes
Plug 'morhetz/gruvbox'
Plug 'dracula/vim', { 'as': 'dracula' }
" Conditional loading
Plug 'scrooloose/nerdtree', { 'on': 'NERDTreeToggle' }
call plug#end()
" Plugin settings
colorscheme gruvbox
let g:airline_theme = 'gruvbox'
Commands:
:PlugInstall " Install plugins
:PlugUpdate " Update plugins
:PlugClean " Remove unlisted plugins
:PlugUpgrade " Upgrade vim-plug itself
:PlugStatus " Check plugin status
13.4.2 Using lazy.nvim (Neovim)
Modern, performance-focused plugin manager.
Bootstrap installation (~/.config/nvim/init.lua):
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable',
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
-- Load plugins
require('lazy').setup('plugins', {
defaults = {
lazy = true, -- Lazy-load by default
},
performance = {
cache = {
enabled = true,
},
rtp = {
disabled_plugins = {
'gzip',
'tarPlugin',
'tohtml',
'tutor',
'zipPlugin',
},
},
},
})~/.config/nvim/lua/plugins/init.lua:
return {
-- Essential plugins
{ 'nvim-lua/plenary.nvim' },
-- Treesitter
{
'nvim-treesitter/nvim-treesitter',
build = ':TSUpdate',
event = { 'BufReadPost', 'BufNewFile' },
config = function()
require('plugins.treesitter')
end,
},
-- LSP
{
'neovim/nvim-lspconfig',
event = { 'BufReadPre', 'BufNewFile' },
dependencies = {
'williamboman/mason.nvim',
'williamboman/mason-lspconfig.nvim',
},
config = function()
require('plugins.lsp')
end,
},
-- Completion
{
'hrsh7th/nvim-cmp',
event = 'InsertEnter',
dependencies = {
'hrsh7th/cmp-nvim-lsp',
'hrsh7th/cmp-buffer',
'hrsh7th/cmp-path',
'L3MON4D3/LuaSnip',
'saadparwaiz1/cmp_luasnip',
},
config = function()
require('plugins.cmp')
end,
},
-- Telescope
{
'nvim-telescope/telescope.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' },
},
config = function()
require('plugins.telescope')
end,
},
-- Git
{
'lewis6991/gitsigns.nvim',
event = { 'BufReadPre', 'BufNewFile' },
config = function()
require('gitsigns').setup()
end,
},
-- Color scheme
{
'catppuccin/nvim',
name = 'catppuccin',
lazy = false,
priority = 1000,
config = function()
vim.cmd.colorscheme('catppuccin')
end,
},
-- Status line
{
'nvim-lualine/lualine.nvim',
event = 'VeryLazy',
config = function()
require('lualine').setup({
options = {
theme = 'catppuccin',
},
})
end,
},
}Lazy-loading strategies:
{
'plugin-name',
-- Load on specific commands
cmd = { 'Command1', 'Command2' },
-- Load on specific events
event = { 'BufReadPost', 'BufNewFile' },
-- Load on specific filetypes
ft = { 'python', 'javascript' },
-- Load on keymap
keys = {
{ '<leader>x', '<cmd>Command<cr>', desc = 'Description' },
},
-- Load immediately
lazy = false,
-- Load before other plugins
priority = 1000,
-- Load after another plugin
dependencies = { 'other-plugin' },
}13.4.3 Using Packer (Neovim - Legacy)
~/.config/nvim/lua/plugins.lua:
-- Auto-install packer
local ensure_packer = function()
local fn = vim.fn
local install_path = fn.stdpath('data')..'/site/pack/packer/start/packer.nvim'
if fn.empty(fn.glob(install_path)) > 0 then
fn.system({
'git', 'clone', '--depth', '1',
'https://github.com/wbthomason/packer.nvim',
install_path
})
vim.cmd [[packadd packer.nvim]]
return true
end
return false
end
local packer_bootstrap = ensure_packer()
return require('packer').startup(function(use)
use 'wbthomason/packer.nvim'
use {
'nvim-treesitter/nvim-treesitter',
run = ':TSUpdate'
}
use {
'neovim/nvim-lspconfig',
requires = {
'williamboman/mason.nvim',
'williamboman/mason-lspconfig.nvim',
},
config = function()
require('lsp-config')
end
}
-- Auto-compile on save
if packer_bootstrap then
require('packer').sync()
end
end)13.5 Performance Optimization
13.5.1 Profiling Startup Time
Vim:
vim --startuptime startup.logNeovim:
nvim --startuptime startup.logAnalyze results:
sort -k2 -n startup.log | tail -2013.5.2 Lazy Loading Techniques
Defer non-essential plugins:
-- Load after UI is ready
vim.defer_fn(function()
require('plugins.non_essential')
end, 100)Autoload filetype-specific plugins:
" Vimscript
autocmd FileType python packadd python-specific-plugin
-- Lua
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
require('python_specific_config')
end,
})13.5.3 Disable Built-in Plugins
-- Disable unused built-in plugins
vim.g.loaded_gzip = 1
vim.g.loaded_zip = 1
vim.g.loaded_zipPlugin = 1
vim.g.loaded_tar = 1
vim.g.loaded_tarPlugin = 1
vim.g.loaded_getscript = 1
vim.g.loaded_getscriptPlugin = 1
vim.g.loaded_vimball = 1
vim.g.loaded_vimballPlugin = 1
vim.g.loaded_2html_plugin = 1
vim.g.loaded_logiPat = 1
vim.g.loaded_rrhelper = 1
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
vim.g.loaded_netrwSettings = 1
vim.g.loaded_netrwFileHandlers = 113.5.4 Optimize Options
-- Performance settings
vim.opt.updatetime = 250 -- Faster completion
vim.opt.timeoutlen = 300 -- Faster key sequence timeout
vim.opt.lazyredraw = true -- Don't redraw during macros
vim.opt.synmaxcol = 240 -- Don't highlight long lines
vim.opt.re = 0 -- Use newer regex engine
-- Disable unnecessary features
vim.opt.backup = false
vim.opt.writebackup = false
vim.opt.swapfile = false13.6 Advanced Configuration Patterns
13.6.1 Conditional Configuration
Load different configs based on environment:
-- Detect environment
local is_work = vim.fn.isdirectory(vim.fn.expand('~/work')) == 1
local is_wsl = vim.fn.has('wsl') == 1
local is_gui = vim.fn.has('gui_running') == 1
if is_work then
require('work_config')
else
require('personal_config')
end
if is_wsl then
vim.opt.clipboard = 'unnamedplus'
vim.g.clipboard = {
name = 'WslClipboard',
copy = {
['+'] = 'clip.exe',
['*'] = 'clip.exe',
},
paste = {
['+'] = 'powershell.exe -c [Console]::Out.Write($(Get-Clipboard -Raw).tostring().replace("`r", ""))',
['*'] = 'powershell.exe -c [Console]::Out.Write($(Get-Clipboard -Raw).tostring().replace("`r", ""))',
},
cache_enabled = 0,
}
endMachine-specific overrides:
-- ~/.config/nvim/init.lua
require('core.options')
require('core.keymaps')
require('plugins')
-- Load local overrides if they exist
local local_config = vim.fn.stdpath('config') .. '/lua/local.lua'
if vim.fn.filereadable(local_config) == 1 then
dofile(local_config)
end~/.config/nvim/lua/local.lua (gitignored):
-- Machine-specific settings
vim.opt.guifont = 'JetBrainsMono Nerd Font:h12'
-- Work-specific plugins
require('lazy').setup({
{ 'company/internal-plugin' },
})13.6.2 Multi-File Configuration Loading
~/.config/nvim/lua/utils/helpers.lua:
local M = {}
-- Source all Lua files in a directory
function M.source_dir(dir)
local config_path = vim.fn.stdpath('config') .. '/lua/' .. dir
local files = vim.fn.glob(config_path .. '/*.lua', false, true)
for _, file in ipairs(files) do
local module = file:match('lua/(.+)%.lua$'):gsub('/', '.')
require(module)
end
end
-- Create autocommand helper
function M.augroup(name, commands)
local group = vim.api.nvim_create_augroup(name, { clear = true })
for _, command in ipairs(commands) do
local event = command[1]
local pattern = command[2]
local callback = command[3]
vim.api.nvim_create_autocmd(event, {
group = group,
pattern = pattern,
callback = callback,
})
end
end
-- Keymap helper with descriptions
function M.map(mode, lhs, rhs, desc, opts)
opts = opts or {}
opts.desc = desc
opts.noremap = opts.noremap == nil and true or opts.noremap
opts.silent = opts.silent == nil and true or opts.silent
vim.keymap.set(mode, lhs, rhs, opts)
end
return MUsage:
local helpers = require('utils.helpers')
-- Load all configs from 'config' directory
helpers.source_dir('config')
-- Create autocommands
helpers.augroup('MyGroup', {
{ 'BufWritePre', '*.lua', function() vim.lsp.buf.format() end },
{ 'TextYankPost', '*', function() vim.highlight.on_yank() end },
})
-- Create keymaps
helpers.map('n', '<leader>w', ':w<CR>', 'Save file')
helpers.map('n', '<leader>q', ':q<CR>', 'Quit')13.6.3 Dynamic Option Setting
Context-aware settings:
-- Adjust based on window size
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
local width = vim.api.nvim_win_get_width(0)
if width < 100 then
vim.opt.number = false
vim.opt.signcolumn = 'no'
else
vim.opt.number = true
vim.opt.signcolumn = 'yes'
end
end,
})
-- Adjust based on file size
vim.api.nvim_create_autocmd('BufReadPre', {
callback = function()
local file_size = vim.fn.getfsize(vim.fn.expand('<afile>'))
if file_size > 1024 * 1024 then -- 1MB
vim.opt_local.syntax = 'off'
vim.opt_local.foldmethod = 'manual'
print('Large file detected: syntax disabled')
end
end,
})13.6.4 Project-Specific Settings
Using .nvim.lua or .exrc:
-- Enable reading local config files
vim.opt.exrc = true
vim.opt.secure = true -- Restrict dangerous commands
-- ~/.config/nvim/init.lua
-- Load project-specific config
vim.api.nvim_create_autocmd('DirChanged', {
callback = function()
local project_config = vim.fn.getcwd() .. '/.nvim.lua'
if vim.fn.filereadable(project_config) == 1 then
dofile(project_config)
end
end,
})Example project config (.nvim.lua):
-- Project-specific settings
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.textwidth = 100
-- Project-specific mappings
vim.keymap.set('n', '<leader>r', ':!npm run dev<CR>')
vim.keymap.set('n', '<leader>t', ':!npm test<CR>')
-- Add project directories to path
vim.opt.path:append('src/**')13.7 Essential Settings Reference
13.7.1 Editor Behavior
-- Line numbers
vim.opt.number = true
vim.opt.relativenumber = true
-- Cursor
vim.opt.cursorline = true
vim.opt.scrolloff = 8 -- Keep 8 lines visible above/below cursor
vim.opt.sidescrolloff = 8 -- Keep 8 columns visible left/right
-- Mouse
vim.opt.mouse = 'a'
-- Clipboard integration
vim.opt.clipboard = 'unnamedplus'
-- Persistent undo
vim.opt.undofile = true
vim.opt.undodir = vim.fn.stdpath('data') .. '/undo'
vim.opt.undolevels = 10000
-- Split behavior
vim.opt.splitright = true
vim.opt.splitbelow = true
-- Command line
vim.opt.cmdheight = 1
vim.opt.showcmd = true
vim.opt.wildmode = { 'longest:full', 'full' }
-- Messages
vim.opt.shortmess:append('c') -- Don't show completion messages
vim.opt.showmode = false -- Don't show mode (use statusline)13.7.2 Indentation and Formatting
-- Tabs
vim.opt.tabstop = 2
vim.opt.softtabstop = 2
vim.opt.shiftwidth = 2
vim.opt.expandtab = true
-- Auto-indenting
vim.opt.smartindent = true
vim.opt.autoindent = true
vim.opt.breakindent = true
-- Line wrapping
vim.opt.wrap = false
vim.opt.linebreak = true -- Break at word boundaries
-- Text width
vim.opt.textwidth = 80
vim.opt.colorcolumn = '80'13.7.3 Search and Replace
-- Search
vim.opt.ignorecase = true
vim.opt.smartcase = true
vim.opt.incsearch = true
vim.opt.hlsearch = true
-- Substitution
vim.opt.inccommand = 'split' -- Show substitution preview
-- Grep program
vim.opt.grepprg = 'rg --vimgrep --no-heading --smart-case'
vim.opt.grepformat = '%f:%l:%c:%m'13.7.4 Visual Appearance
-- Colors
vim.opt.termguicolors = true
vim.opt.background = 'dark'
-- UI elements
vim.opt.signcolumn = 'yes'
vim.opt.laststatus = 3 -- Global statusline
vim.opt.pumheight = 10 -- Popup menu height
vim.opt.pumblend = 10 -- Popup transparency
vim.opt.winblend = 0 -- Window transparency
-- Concealment
vim.opt.conceallevel = 0
-- Fill characters
vim.opt.fillchars = {
fold = ' ',
eob = ' ', -- End of buffer
diff = '╱',
foldsep = ' ',
foldopen = '',
foldclose = '',
}13.7.5 Performance
-- Faster updates
vim.opt.updatetime = 250
vim.opt.timeoutlen = 300
-- Redraw optimization
vim.opt.lazyredraw = true
-- Syntax
vim.opt.synmaxcol = 240 -- Don't highlight long lines
-- Folds
vim.opt.foldmethod = 'expr'
vim.opt.foldexpr = 'nvim_treesitter#foldexpr()'
vim.opt.foldenable = false -- Start with folds open
-- Complete options
vim.opt.completeopt = { 'menu', 'menuone', 'noselect' }13.8 Configuration Best Practices
13.8.1 Version Control
Recommended .gitignore:
# Plugin directories
plugged/
lazy-lock.json
plugin/packer_compiled.lua
# Local overrides
lua/local.lua
# Generated files
*.log
.netrwhist
undodir/
# OS files
.DS_Store
Thumbs.db
Track with Git:
cd ~/.config/nvim
git init
git add init.lua lua/
git commit -m "Initial Neovim configuration"
# Push to remote
git remote add origin https://github.com/username/nvim-config.git
git push -u origin main13.8.2 Documentation
Add comments explaining non-obvious choices:
-- Use space as leader for thumb accessibility
vim.g.mapleader = ' '
-- Relative numbers for efficient motion (5j, 10k)
vim.opt.relativenumber = true
-- Persist undo history across sessions
-- Location: ~/.local/share/nvim/undo/
vim.opt.undofile = true
vim.opt.undodir = vim.fn.stdpath('data') .. '/undo'
-- Intelligent case search:
-- 'foo' matches Foo/foo/FOO
-- 'Foo' only matches Foo
vim.opt.ignorecase = true
vim.opt.smartcase = true13.8.3 Portability
Make config work across machines:
-- Detect OS
local is_windows = vim.fn.has('win32') == 1
local is_mac = vim.fn.has('mac') == 1
local is_linux = vim.fn.has('unix') == 1 and not is_mac
-- OS-specific settings
if is_windows then
vim.opt.shell = 'pwsh'
vim.opt.shellcmdflag = '-NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command'
elseif is_mac then
vim.opt.clipboard = 'unnamed' -- Use system clipboard
end
-- Check for executables before configuring
if vim.fn.executable('rg') == 1 then
vim.opt.grepprg = 'rg --vimgrep'
end
if vim.fn.executable('fd') == 1 then
vim.env.FZF_DEFAULT_COMMAND = 'fd --type f'
end13.8.4 Progressive Enhancement
Start minimal, add gradually:
-- ~/.config/nvim/init.lua
-- Core (always loaded)
require('core.options')
require('core.keymaps')
-- Plugins (optional)
local plugin_ok, _ = pcall(require, 'plugins')
if not plugin_ok then
vim.notify('Plugins not loaded - run :Lazy install', vim.log.levels.WARN)
return
end
-- Advanced features (graceful degradation)
if vim.fn.has('nvim-0.9') == 1 then
require('advanced.features')
end13.8.5 Testing Configuration Changes
Create temporary config:
# Test new config without affecting current setup
NVIM_APPNAME=nvim-test nvim
# Locations:
# Config: ~/.config/nvim-test/
# Data: ~/.local/share/nvim-test/Bisect problematic changes:
-- Binary search for issue
-- Comment out half of config, test, repeat
-- Enable debug logging
vim.cmd('set verbose=9')
vim.cmd('set verbosefile=~/.config/nvim/debug.log')Chapter Summary
This chapter explored Vim/Neovim configuration comprehensively:
Configuration Fundamentals:
File locations and naming conventions
Vimscript vs. Lua approaches
Loading order and precedence
Minimal viable configurations
Modular Organization:
Splitting config into logical modules
Directory structures for maintainability
Vimscript runtime path
Lua module system
Plugin Management:
vim-plug: Simple, universal
lazy.nvim: Modern, performance-focused
Packer: Lua-native (legacy)
Lazy-loading strategies
Performance Optimization:
Startup time profiling
Lazy-loading techniques
Disabling unused built-in plugins
Optimizing runtime options
Advanced Patterns:
Conditional configuration (environment-aware)
Multi-file loading systems
Dynamic option adjustment
Project-specific settings
Helper function libraries
Essential Settings:
Editor behavior (cursor, mouse, clipboard)
Indentation and formatting
Search and replace
Visual appearance
Performance tuning
Best Practices:
Version control workflows
Documentation standards
Cross-platform portability
Progressive enhancement
Safe testing procedures
Key Insights:
Start minimal, expand based on needs
Modularize for maintainability
Lazy-load for performance
Document non-obvious choices
Make portable across environments
Version control configuration
Test changes in isolation
Use helpers to reduce boilerplate
Prefer Lua for Neovim (performance)
Keep machine-specific settings separate
Configuration Philosophy:
Simplicity: Don’t add what you don’t use
Performance: Lazy-load everything possible
Maintainability: Clear structure and documentation
Portability: Work across machines/OSes
Evolution: Configuration grows with expertise
Your vimrc/init.lua is a living document—it should evolve as your workflow matures. Start with basics, add features as you encounter limitations, and ruthlessly remove what you don’t use. A well-crafted configuration transforms Vim from a text editor into a personalized development environment perfectly suited to your workflow.
In the next chapter, we’ll explore Marks and Jumps, covering position tracking, cross-file navigation, and movement history management for efficient codebase exploration.
Chapter 14: Options and Settings
Vim’s power lies not just in its commands, but in its configurability. This chapter provides a comprehensive reference to Vim’s options system, covering how to set, query, and manipulate settings that control every aspect of editor behavior.
14.1 Understanding the Options System
14.1.1 Option Types
Vim options fall into three categories:
Boolean Options:
" Enable/disable
set number " Enable line numbers
set nonumber " Disable line numbers
set number! " Toggle line numbers
set invnumber " Toggle (alternative syntax)
" Query state
set number? " Shows 'number' or 'nonumber'
Numeric Options:
" Set value
set tabstop=4
set shiftwidth=2
set history=1000
" Query value
set tabstop? " Shows 'tabstop=4'
String Options:
" Set value
set backspace=indent,eol,start
set wildmode=longest:full,full
set listchars=tab:▸\ ,trail:·
" Query value
set backspace? " Shows current value
14.1.2 Option Scope
Global Options: Affect all buffers and windows.
set background=dark
set updatetime=300
Local Options: Specific to buffer or window.
" Buffer-local
setlocal tabstop=2
setlocal textwidth=80
" Window-local
setlocal wrap
setlocal cursorline
Global-Local Options: Have both global default and local override.
" Set global default
setglobal tabstop=4
" Set local override
setlocal tabstop=2
" Query both
set tabstop? " Local value
setglobal tabstop? " Global default
14.1.3 Lua API for Options (Neovim)
-- Set options
vim.opt.number = true
vim.opt.tabstop = 4
vim.opt.wildmode = { 'longest:full', 'full' }
-- Append/remove from list
vim.opt.runtimepath:append('~/.config/nvim/custom')
vim.opt.shortmess:append('c')
vim.opt.shortmess:remove('F')
-- Buffer-local
vim.opt_local.tabstop = 2
-- Window-local
vim.wo.cursorline = true
-- Global
vim.go.updatetime = 250
-- Get option value
print(vim.opt.tabstop:get())14.2 Display and Interface Options
14.2.1 Line Numbers and Columns
" Line numbers
set number " Show absolute line numbers
set relativenumber " Show relative line numbers
set numberwidth=4 " Width of number column (default: 4)
" Sign column (for marks, diagnostics)
set signcolumn=yes " Always show
set signcolumn=auto " Show when signs present
set signcolumn=number " Merge with number column (Neovim)
" Color column (visual guide)
set colorcolumn=80 " Single column
set colorcolumn=80,120 " Multiple columns
set colorcolumn=+1 " One column after 'textwidth'
Lua equivalent:
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.numberwidth = 4
vim.opt.signcolumn = 'yes'
vim.opt.colorcolumn = '80'14.2.2 Cursor Display
" Cursor line/column
set cursorline " Highlight current line
set nocursorline " Disable highlight
set cursorcolumn " Highlight current column
" Cursor shape (Neovim)
set guicursor=n-v-c:block-Cursor/lCursor
set guicursor+=i-ci-ve:ver25-Cursor
set guicursor+=r-cr-o:hor20-Cursor
" Cursor blink
set guicursor+=a:blinkon500-blinkoff500
Lua cursor configuration:
vim.opt.cursorline = true
vim.opt.guicursor = {
'n-v-c:block-Cursor/lCursor',
'i-ci-ve:ver25-Cursor',
'r-cr-o:hor20-Cursor',
'a:blinkon500-blinkoff500'
}14.2.3 Scrolling Behavior
" Keep cursor centered
set scrolloff=8 " Minimum lines above/below cursor
set sidescrolloff=8 " Minimum columns left/right
" Smooth scrolling
set scroll=10 " Lines to scroll with C-d/C-u
set scrolljump=5 " Lines to jump when cursor leaves screen
" Horizontal scrolling
set sidescroll=1 " Columns to scroll horizontally
set nowrap " Don't wrap long lines
Context-aware scrolloff:
-- Dynamic scrolloff based on window height
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
local height = vim.api.nvim_win_get_height(0)
vim.opt.scrolloff = math.floor(height / 4)
end,
})14.2.4 Whitespace and Special Characters
" Show invisible characters
set list " Enable
set nolist " Disable
" Define what to show
set listchars=tab:▸\ ,trail:·,nbsp:␣,extends:›,precedes:‹
set listchars+=eol:¬ " Show end-of-line
" Fill characters
set fillchars=vert:│,fold:─,diff:─
set fillchars+=eob:~ " End of buffer character
" Show tabs and trailing spaces only
set listchars=tab:»\ ,trail:·
Conditional list display:
" Show whitespace in specific filetypes
autocmd FileType python,javascript setlocal list
autocmd FileType markdown setlocal nolist
14.2.5 Window Splitting
" Split direction
set splitright " Vertical splits go right
set splitbelow " Horizontal splits go below
" Equalize splits
set equalalways " Auto-equalize on resize
set noequalalways " Manual control
" Split sizes
set winwidth=84 " Minimum width for current window
set winheight=10 " Minimum height for current window
set winminwidth=5 " Minimum width for any window
set winminheight=1 " Minimum height for any window
14.2.6 Status and Command Line
" Status line
set laststatus=2 " Always show (all windows)
set laststatus=3 " Global statusline (Neovim)
set laststatus=0 " Never show
" Command line
set cmdheight=1 " Height of command line
set cmdheight=0 " Hide when not in use (Neovim)
set showcmd " Show partial commands
set noshowcmd " Don't show
" Mode display
set showmode " Show current mode
set noshowmode " Hide mode (use statusline)
" Messages
set shortmess=atI " Abbreviate messages
set shortmess+=c " Don't show completion messages
set shortmess+=F " Don't show file info when editing
Shortmess flags: a: All abbreviations t: Truncate file messages I: No intro message c: No completion messages F: No file info W: No [w] when writing A: No attention messages
14.3 Editing Behavior Options
14.3.1 Indentation
" Tab settings
set tabstop=4 " Tab display width
set softtabstop=4 " Tab insert width
set shiftwidth=4 " Indent width
set expandtab " Use spaces instead of tabs
set noexpandtab " Use actual tabs
" Auto-indent
set autoindent " Copy indent from current line
set smartindent " Smart auto-indenting
set cindent " C-style indenting
set indentexpr=GetPythonIndent() " Custom indent expression
" Indent rounding
set shiftround " Round indent to shiftwidth multiple
" Backspace behavior
set backspace=indent,eol,start " Allow backspacing over everything
Filetype-specific indentation:
autocmd FileType python setlocal ts=4 sw=4 sts=4 et
autocmd FileType javascript setlocal ts=2 sw=2 sts=2 et
autocmd FileType go setlocal ts=4 sw=4 sts=4 noet
Lua approach:
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.opt_local.softtabstop = 4
vim.opt_local.expandtab = true
end,
})14.3.2 Line Wrapping and Formatting
" Visual wrapping
set wrap " Wrap long lines
set nowrap " Don't wrap
set linebreak " Break at word boundaries
set breakindent " Indent wrapped lines
set breakindentopt=shift:2 " Extra indent for wrapped lines
" Show wrap indicator
set showbreak=↪\
" Text width
set textwidth=80 " Auto-wrap at 80 characters
set textwidth=0 " Disable auto-wrap
" Format options
set formatoptions=tcqrn1
" t: Auto-wrap text using textwidth
" c: Auto-wrap comments
" q: Allow formatting comments with 'gq'
" r: Auto-insert comment leader after <CR>
" n: Recognize numbered lists
" 1: Don't break line after one-letter word
" Add/remove format options
set formatoptions+=j " Remove comment leader when joining
set formatoptions-=o " Don't insert comment leader with 'o'/'O'
Lua format options:
vim.opt.formatoptions = {
t = true, -- Auto-wrap text
c = true, -- Auto-wrap comments
q = true, -- Allow gq formatting
r = true, -- Continue comments
n = true, -- Recognize numbered lists
j = true, -- Remove comment leader when joining
}14.3.3 Joining and Deletion
" Join behavior
set nojoinspaces " Don't insert two spaces after punctuation
" Virtual editing
set virtualedit=block " Allow cursor beyond EOL in visual block
set virtualedit=all " Allow everywhere
set virtualedit=onemore " Allow one char beyond EOL
" Deletion
set backspace=indent,eol,start " Allow backspace over everything
14.3.4 Insert Mode Options
" Timeout for key sequences
set timeout " Enable timeout
set timeoutlen=500 " Timeout in ms (default: 1000)
set ttimeoutlen=10 " Key code timeout (faster Esc)
" Insert completion
set completeopt=menu,menuone,noselect
" menu: Use popup menu
" menuone: Show menu even for single match
" noselect: Don't auto-select
" preview: Show preview window
" Popup menu
set pumheight=15 " Maximum items in popup
set pumwidth=20 " Minimum width
set pumblend=10 " Transparency (0-100, Neovim)
14.4 Search and Pattern Matching
14.4.1 Search Behavior
" Case sensitivity
set ignorecase " Case-insensitive search
set smartcase " Case-sensitive if uppercase present
set noignorecase " Always case-sensitive
" Search highlighting
set hlsearch " Highlight matches
set nohlsearch " No highlighting
set incsearch " Incremental search (show while typing)
" Search wrapping
set wrapscan " Wrap around file
set nowrapscan " Stop at end of file
" Show match count (Neovim)
set shortmess-=S " Show search count in messages
Live substitution preview:
set inccommand=split " Show preview in split (Neovim)
set inccommand=nosplit " Show preview inline
14.4.2 Pattern Matching
" Magic mode (regex behavior)
set magic " Default regex mode
set nomagic " All chars except ^$ are literal
" Regex engine
set regexpengine=0 " Auto-select (default)
set regexpengine=1 " Old engine
set regexpengine=2 " NFA engine (faster)
" Max column for syntax highlighting
set synmaxcol=200 " Don't highlight beyond column 200
14.4.3 Grep Integration
" External grep program
set grepprg=grep\ -n\ $*\ /dev/null
set grepprg=rg\ --vimgrep\ --no-heading\ --smart-case
" Grep format
set grepformat=%f:%l:%c:%m,%f:%l:%m
Lua configuration:
-- Use ripgrep if available
if vim.fn.executable('rg') == 1 then
vim.opt.grepprg = 'rg --vimgrep --no-heading --smart-case'
vim.opt.grepformat = '%f:%l:%c:%m'
end14.5 File and Buffer Options
14.5.1 File Handling
" File encoding
set encoding=utf-8 " Internal encoding
set fileencoding=utf-8 " File encoding when saving
set fileencodings=utf-8,latin1 " Encodings to try when reading
" File format (line endings)
set fileformat=unix " Use LF
set fileformat=dos " Use CRLF
set fileformats=unix,dos,mac " Formats to try
" Auto-read/write
set autoread " Reload file if changed outside Vim
set autowrite " Auto-write before commands like :next
set autowriteall " Auto-write on more events
" File detection
filetype on " Enable filetype detection
filetype plugin on " Enable filetype plugins
filetype indent on " Enable filetype-based indentation
14.5.2 Backup, Swap, and Undo
" Backup files
set backup " Keep backup file
set nobackup " Don't keep backup
set writebackup " Make backup before overwriting
set backupdir=~/.vim/backup " Backup directory
set backupext=.bak " Backup file extension
" Swap files
set swapfile " Use swap file
set noswapfile " Disable swap
set directory=~/.vim/swap " Swap file directory
set updatecount=200 " Write swap after N characters
set updatetime=4000 " Write swap after N ms idle
" Persistent undo
set undofile " Enable persistent undo
set noundofile " Disable
set undodir=~/.vim/undo " Undo file directory
set undolevels=1000 " Max undo levels
set undoreload=10000 " Max lines to save for undo on reload
Lua setup with auto-create directories:
-- Create directories if they don't exist
local data_dir = vim.fn.stdpath('data')
local function ensure_dir(dir)
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
return dir
end
vim.opt.backup = false
vim.opt.writebackup = false
vim.opt.swapfile = false
vim.opt.undofile = true
vim.opt.undodir = ensure_dir(data_dir .. '/undo')
vim.opt.undolevels = 1000014.5.3 Buffer Behavior
" Hidden buffers
set hidden " Allow hidden buffers with unsaved changes
set nohidden " Require saving before hiding
" Buffer switching
set switchbuf=useopen " Jump to open window if buffer visible
set switchbuf+=usetab " Include other tabs in search
" Write behavior
set write " Allow writing files
set nowrite " Prevent writing
14.5.4 Session and View Options
" Session save options
set sessionoptions=blank,buffers,curdir,folds,help,tabpages,winsize
set sessionoptions+=terminal " Save terminal buffers (Neovim)
" View save options
set viewoptions=folds,cursor,curdir
14.6 Completion and Wildmenu
14.6.1 Command-Line Completion
" Enable wildmenu
set wildmenu " Visual command completion
set nowildmenu " Disable
" Completion mode
set wildmode=full " Complete first full match
set wildmode=longest " Complete longest common string
set wildmode=longest:full " Complete longest, then full
set wildmode=list " List all matches
set wildmode=longest:full,full " Longest then cycle through full
" Ignore patterns
set wildignore=*.o,*.obj,*.pyc
set wildignore+=*/.git/*,*/node_modules/*
set wildignore+=*.jpg,*.png,*.gif
" Completion options
set wildoptions=pum " Use popup menu (Neovim)
set wildoptions+=fuzzy " Fuzzy matching (Neovim 0.10+)
Lua wildignore patterns:
vim.opt.wildignore = {
'*.o', '*.obj', '*.pyc',
'*/.git/*', '*/node_modules/*',
'*.jpg', '*.png', '*.gif',
'*.swp', '*.bak',
}14.6.2 Insert Mode Completion
" Completion sources
set complete=.,w,b,u,t,i
" .: Current buffer
" w: Other windows
" b: Other buffers
" u: Unloaded buffers
" t: Tags
" i: Current and included files
" Completion options
set completeopt=menu,menuone,preview
set completeopt=menu,menuone,noselect " Don't auto-select
" Dictionary/thesaurus
set dictionary=/usr/share/dict/words
set thesaurus=~/.vim/thesaurus.txt
" Scan depth
for completion
set complete=.,w,b,u,t
set completeopt=menu,menuone,noselect
set completeopt+=preview " add preview
**Lua equivalent:**
lua
vim.opt.complete = { '.', 'w', 'b', 'u', 't' }
vim.opt.completeopt = { 'menu', 'menuone', 'noselect', 'preview' }
## 14.7 Reference Tables
| Category | Common Options | Purpose |
|-----------|----------------|----------|
| **Editor Behavior** | `scrolloff`, `hidden`, `switchbuf`, `updatetime` | Control general editing and buffer behavior |
| **Indentation** | `tabstop`, `shiftwidth`, `expandtab`, `smartindent` | Manage indentation style |
| **Search** | `ignorecase`, `smartcase`, `hlsearch`, `incsearch`, `inccommand` | Configure searching behavior |
| **Visual Appearance** | `number`, `relativenumber`, `signcolumn`, `cursorline`, `colorcolumn`, `laststatus` | Display and interface customization |
| **Performance** | `lazyredraw`, `ttyfast`, `synmaxcol`, `updatetime` | Speed and optimization settings |
**Boolean, Numeric, and String Value Summary**
| Type | Example | Description |
|------|----------|-------------|
| Boolean | `set wrap` / `set nowrap` | True/False toggle |
| Numeric | `set tabstop=4` | Integer-based setting |
| String | `set listchars=tab:▸\ ,trail:·` | Textual setting (commas separate values) |
## 14.8 Best Practices
### 14.8.1 Configuration Tracking
Keep your configuration version-controlled:
bash
$ git init ~/.config/nvim
$ echo 'plugged/' >> ~/.config/nvim/.gitignore
$ echo 'undodir/' >> ~/.config/nvim/.gitignore
$ echo 'lua/local.lua' >> ~/.config/nvim/.gitignore
### 14.8.2 Documentation
Document non-obvious settings inside your config:
vim
" Ensure caching only updates on idle
set updatetime=250 " default 4000ms
### 14.8.3 Portability
When using plugins or features, test gracefully:
lua
if vim.fn.has('unix') == 1 then
vim.opt.shell = 'bash'
end
if vim.fn.executable('rg') == 1 then
vim.opt.grepprg = 'rg --vimgrep'
end
### 14.8.4 Configuration Testing
Create temporary isolated environments to test:
bash
$ NVIM_APPNAME=nvim-test nvim
This loads a dedicated config tree at `~/.local/share/nvim-test`.
---
✅ **End of Chapter 14: Options and Settings**
Next up in the sequence is **Chapter #15: Marks and Jumps** — focusing on motion history, jump lists, marks (local and global), and efficient navigation through code or documents.
---
# Chapter 15: Key Mappings
Key mappings are the foundation of personalizing Vim to match your workflow. This chapter explores Vim's mapping system in depth, from basic remaps to complex mode-specific mappings, providing you with the knowledge to craft an efficient, personalized editing environment.
## 15.1 Understanding the Mapping System
### 15.1.1 Basic Mapping Syntax
The fundamental mapping commands follow this pattern:
```vim
{mode}{nore}map {lhs} {rhs}
Where:
{mode}: Specifies which mode(s) the mapping applies to{nore}: Optional - prevents recursive mapping{lhs}: Left-hand side - the keys you press{rhs}: Right-hand side - the action performed
Simple example:
" Map jk to escape in insert mode
inoremap jk <Esc>
" Map space to fold toggle in normal mode
nnoremap <Space> za
15.1.2 Mapping Modes
| Command | Modes | Description |
|---|---|---|
map |
Normal, Visual, Select, Operator-pending | General mapping |
nmap |
Normal | Normal mode only |
vmap |
Visual, Select | Visual and Select modes |
xmap |
Visual | Visual mode only |
smap |
Select | Select mode only |
imap |
Insert | Insert mode |
cmap |
Command-line | Command-line mode |
omap |
Operator-pending | After an operator (like d, c) |
tmap |
Terminal | Terminal mode (Neovim) |
lmap |
Insert, Command-line, Lang-Arg | Language mappings |
Mode-specific examples:
" Normal mode: quick save
nnoremap <C-s> :w<CR>
" Visual mode: surround with quotes
xnoremap <leader>q c"<C-r>""<Esc>
" Insert mode: auto-complete parentheses
inoremap ( ()<Left>
" Operator-pending: operate on entire function
onoremap af :<C-u>normal! gg0vG$<CR>
" Terminal mode: easy escape (Neovim)
tnoremap <Esc> <C-\><C-n>
15.1.3 Recursive vs Non-Recursive
Recursive mappings (map):
" Can trigger other mappings
nmap x dd
nmap y x " y will trigger dd (through x)
Non-recursive mappings
(noremap):
" Direct, no expansion
nnoremap x dd
nnoremap y x " y will only trigger literal x key
Best practice: Always prefer noremap
variants unless you specifically need recursion.
" Bad - could cause unexpected behavior
imap jk <Esc>
nmap <Space> :echo "pressed"<CR>
" Good - explicit and safe
inoremap jk <Esc>
nnoremap <Space> :echo "pressed"<CR>
15.2 Special Keys and Notation
15.2.1 Special Key Codes
<CR> " Enter/Return
<Esc> " Escape
<Space> " Space bar
<Tab> " Tab
<BS> " Backspace
<Del> " Delete
<Up> " Up arrow
<Down> " Down arrow
<Left> " Left arrow
<Right> " Right arrow
<Home> " Home
<End> " End
<PageUp> " Page Up
<PageDown> " Page Down
<F1>-<F12> " Function keys
<Insert> " Insert
15.2.2 Modifier Keys
<C-x> " Ctrl+x
<M-x> " Alt+x (Meta)
<S-x> " Shift+x
<A-x> " Alt+x (alternative)
<D-x> " Cmd+x (macOS GUI)
" Combinations
<C-S-x> " Ctrl+Shift+x
<C-M-x> " Ctrl+Alt+x
Practical examples:
" Control combinations
nnoremap <C-h> <C-w>h " Navigate left window
nnoremap <C-j> <C-w>j " Navigate down window
nnoremap <C-k> <C-w>k " Navigate up window
nnoremap <C-l> <C-w>l " Navigate right window
" Alt combinations (terminal-dependent)
nnoremap <M-j> :m .+1<CR>== " Move line down
nnoremap <M-k> :m .-2<CR>== " Move line up
" Function keys
nnoremap <F2> :set invpaste<CR>
nnoremap <F3> :set invnumber<CR>
nnoremap <F4> :set invrelativenumber<CR>
15.2.3 Special Sequences
<Leader> " Leader key (default: \)
<LocalLeader> " Local leader (filetype-specific)
<silent> " Don't echo command
<expr> " Evaluate as expression
<buffer> " Buffer-local mapping
<nowait> " Don't wait for more keys
<script> " Only use script-local mappings
<unique> " Fail if mapping already exists
15.3 Leader Key Mappings
15.3.1 Configuring the Leader
" Set leader before any leader mappings
let mapleader = " " " Space as leader
let maplocalleader = "," " Comma as local leader
Lua configuration:
vim.g.mapleader = ' '
vim.g.maplocalleader = ','15.3.2 Leader-Based Workflows
" File operations
nnoremap <leader>w :w<CR>
nnoremap <leader>q :q<CR>
nnoremap <leader>x :x<CR>
" Buffer management
nnoremap <leader>bd :bdelete<CR>
nnoremap <leader>bn :bnext<CR>
nnoremap <leader>bp :bprevious<CR>
nnoremap <leader>bl :buffers<CR>
" Window operations
nnoremap <leader>v :vsplit<CR>
nnoremap <leader>s :split<CR>
nnoremap <leader>c :close<CR>
nnoremap <leader>o :only<CR>
" Search and navigation
nnoremap <leader>h :nohlsearch<CR>
nnoremap <leader>f :find
nnoremap <leader>b :buffer
" Quick edit config
nnoremap <leader>ev :edit $MYVIMRC<CR>
nnoremap <leader>sv :source $MYVIMRC<CR>
15.3.3 Organized Leader Mappings
Group related functionality with mnemonic prefixes:
" Code operations (c prefix)
nnoremap <leader>cf :lua vim.lsp.buf.format()<CR>
nnoremap <leader>cr :lua vim.lsp.buf.rename()<CR>
nnoremap <leader>ca :lua vim.lsp.buf.code_action()<CR>
" Git operations (g prefix)
nnoremap <leader>gs :Git status<CR>
nnoremap <leader>gc :Git commit<CR>
nnoremap <leader>gp :Git push<CR>
nnoremap <leader>gl :Git log<CR>
" Toggle options (t prefix)
nnoremap <leader>tn :set invnumber<CR>
nnoremap <leader>tr :set invrelativenumber<CR>
nnoremap <leader>tw :set invwrap<CR>
nnoremap <leader>tl :set invlist<CR>
" Search/Find (f prefix)
nnoremap <leader>ff :Files<CR>
nnoremap <leader>fg :Rg<CR>
nnoremap <leader>fb :Buffers<CR>
nnoremap <leader>fh :History<CR>
15.4 Buffer-Local and Filetype Mappings
15.4.1 Buffer-Local Mappings
" In specific buffer
nnoremap <buffer> <leader>r :!python %<CR>
" Auto-command approach
autocmd FileType python nnoremap <buffer> <F5> :!python %<CR>
autocmd FileType javascript nnoremap <buffer> <F5> :!node %<CR>
Lua approach:
-- In ftplugin/python.lua
vim.keymap.set('n', '<F5>', ':!python %<CR>', { buffer = true })
-- Or with autocmd
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.keymap.set('n', '<F5>', ':!python %<CR>', { buffer = true })
end,
})15.4.2 Filetype-Specific Mappings
Create dedicated filetype plugin files:
~/.vim/after/ftplugin/markdown.vim:
" Markdown-specific mappings
nnoremap <buffer> <leader>p :MarkdownPreview<CR>
nnoremap <buffer> <leader>b :! pandoc % -o %:r.pdf<CR>
inoremap <buffer> ;b ****<Left><Left>
inoremap <buffer> ;i **<Left>
inoremap <buffer> ;c ``<Left>
~/.config/nvim/after/ftplugin/lua.lua:
-- Lua-specific mappings
vim.keymap.set('n', '<leader>r', ':luafile %<CR>', { buffer = true })
vim.keymap.set('n', '<leader>t', ':lua require("plenary.test").test_file()<CR>', { buffer = true })~/.vim/after/ftplugin/go.vim:
" Go-specific mappings
nnoremap <buffer> <leader>b :GoBuild<CR>
nnoremap <buffer> <leader>r :GoRun<CR>
nnoremap <buffer> <leader>t :GoTest<CR>
nnoremap <buffer> <leader>c :GoCoverageToggle<CR>
15.5 Advanced Mapping Techniques
15.5.1 Expression Mappings
Use <expr> to evaluate Vim expressions:
" Smart tab completion
inoremap <expr> <Tab> pumvisible() ? "\<C-n>" : "\<Tab>"
inoremap <expr> <S-Tab> pumvisible() ? "\<C-p>" : "\<S-Tab>"
" Conditional mapping based on context
inoremap <expr> <CR> pumvisible() ? "\<C-y>" : "\<CR>"
" Smart home key
nnoremap <expr> <Home> col('.') == 1 ? '^' : '0'
" Context-aware paste
inoremap <expr> <C-v> getline('.')[col('.')-2] ==# ' ' ? '<C-r>"' : ' <C-r>"'
Lua expression mappings:
-- Smart tab completion
vim.keymap.set('i', '<Tab>', function()
return vim.fn.pumvisible() == 1 and '<C-n>' or '<Tab>'
end, { expr = true })
-- Smart enter
vim.keymap.set('i', '<CR>', function()
return vim.fn.pumvisible() == 1 and '<C-y>' or '<CR>'
end, { expr = true })15.5.2 Silent Mappings
Suppress command echo with <silent>:
" Shows ":write" in command line
nnoremap <leader>w :write<CR>
" Silent - no echo
nnoremap <silent> <leader>w :write<CR>
" Useful for complex commands
nnoremap <silent> <leader>d :lua vim.diagnostic.open_float()<CR>
15.5.3 Script-Local Mappings
Use <script> to restrict remapping to
script-local mappings only:
nnoremap <script> <SID>MyFunc :call <SID>LocalFunction()<CR>
nnoremap <unique> <script> <Plug>MyPlugin :call <SID>PluginFunc()<CR>
15.5.4 Mapping with Arguments
" Command with arguments
nnoremap <leader>g :Rg<Space>
" Pre-filled search
nnoremap <leader>* :Rg <C-r><C-w><CR>
" Visual mode - search selected text
xnoremap <leader>* y:Rg <C-r>"<CR>
15.5.5 Multi-Line Mappings
" Using bar separator
nnoremap <leader>ev :edit $MYVIMRC<CR>
" Using continuation
nnoremap <leader>sv :source $MYVIMRC
\ \| echo "Vimrc reloaded!"<CR>
" Complex command
nnoremap <leader>p :let @+ = expand("%:p")
\ \| echo "Copied: " . @+<CR>
15.6 Common Mapping Patterns
15.6.1 Window Navigation
" Simplified window movement
nnoremap <C-h> <C-w>h
nnoremap <C-j> <C-w>j
nnoremap <C-k> <C-w>k
nnoremap <C-l> <C-w>l
" Window resizing
nnoremap <C-Up> :resize +2<CR>
nnoremap <C-Down> :resize -2<CR>
nnoremap <C-Left> :vertical resize -2<CR>
nnoremap <C-Right> :vertical resize +2<CR>
" Window creation
nnoremap <leader>v :vsplit<CR>
nnoremap <leader>s :split<CR>
15.6.2 Buffer Navigation
" Quick buffer switching
nnoremap <leader>bn :bnext<CR>
nnoremap <leader>bp :bprevious<CR>
nnoremap <leader>bd :bdelete<CR>
nnoremap <leader>bl :buffers<CR>:buffer<Space>
" Jump to specific buffer
nnoremap <leader>1 :buffer 1<CR>
nnoremap <leader>2 :buffer 2<CR>
nnoremap <leader>3 :buffer 3<CR>
15.6.3 Search Enhancements
" Clear search highlight
nnoremap <Esc><Esc> :nohlsearch<CR>
nnoremap <leader>h :nohlsearch<CR>
" Search and replace current word
nnoremap <leader>r :%s/\<<C-r><C-w>\>//g<Left><Left>
" Search visual selection
xnoremap * y/\V<C-r>"<CR>
xnoremap # y?\V<C-r>"<CR>
" Center search results
nnoremap n nzzzv
nnoremap N Nzzzv
15.6.4 Text Manipulation
" Move lines up/down
nnoremap <M-j> :m .+1<CR>==
nnoremap <M-k> :m .-2<CR>==
vnoremap <M-j> :m '>+1<CR>gv=gv
vnoremap <M-k> :m '<-2<CR>gv=gv
" Duplicate line/selection
nnoremap <leader>d yyp
vnoremap <leader>d y`>p
" Join without spaces
nnoremap <leader>J gJ
" Toggle case of word
nnoremap <leader>u viwU<Esc>
nnoremap <leader>l viwu<Esc>
15.6.5 Quick Edits
" Insert line above/below without leaving normal mode
nnoremap <leader>o o<Esc>
nnoremap <leader>O O<Esc>
" Append semicolon/comma to end of line
nnoremap <leader>; A;<Esc>
nnoremap <leader>, A,<Esc>
" Delete without yanking
nnoremap <leader>d "_d
vnoremap <leader>d "_d
" Paste without yanking in visual mode
xnoremap <leader>p "_dP
15.7 Lua Mapping API (Neovim)
15.7.1 Basic Lua Mappings
-- vim.keymap.set(mode, lhs, rhs, opts)
vim.keymap.set('n', '<leader>w', ':w<CR>')
-- Multiple modes
vim.keymap.set({'n', 'v'}, '<leader>y', '"+y')
-- With options
vim.keymap.set('n', '<leader>f', ':Files<CR>', {
silent = true,
noremap = true,
desc = 'Find files'
})15.7.2 Mapping Options
local opts = {
noremap = true, -- Non-recursive (default in vim.keymap.set)
silent = true, -- Don't echo command
expr = false, -- Not an expression
buffer = nil, -- Global (or buffer number for buffer-local)
nowait = false, -- Don't wait for more keys
desc = nil, -- Description (for which-key, etc.)
}
vim.keymap.set('n', '<leader>d', vim.diagnostic.open_float, opts)15.7.3 Function Mappings
-- Direct function call
vim.keymap.set('n', '<leader>f', function()
print("Hello from Lua!")
end, { silent = true })
-- With LSP
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { desc = 'Go to definition' })
vim.keymap.set('n', 'K', vim.lsp.buf.hover, { desc = 'Hover documentation' })
-- Complex logic
vim.keymap.set('n', '<leader>p', function()
local path = vim.fn.expand('%:p')
vim.fn.setreg('+', path)
print('Copied: ' .. path)
end, { desc = 'Copy file path' })15.7.4 Buffer-Local Lua Mappings
-- In autocmd
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.keymap.set('n', '<F5>', ':!python %<CR>', { buffer = true })
end,
})
-- In ftplugin file
local bufnr = vim.api.nvim_get_current_buf()
vim.keymap.set('n', '<leader>r', ':!python %<CR>', { buffer = bufnr })15.7.5 Deleting Mappings
-- Delete mapping
vim.keymap.del('n', '<leader>w')
-- Delete buffer-local mapping
vim.keymap.del('n', '<leader>w', { buffer = true })15.8 Practical Mapping Examples
15.8.1 Quick File Operations
" Quick save
nnoremap <leader>w :w<CR>
inoremap <C-s> <Esc>:w<CR>a
" Save and quit
nnoremap <leader>x :x<CR>
" Quit without saving
nnoremap <leader>q :q!<CR>
" Save all buffers
nnoremap <leader>wa :wa<CR>
" Source current file
nnoremap <leader>so :source %<CR>
15.8.2 Terminal Integration (Neovim)
-- Toggle terminal
vim.keymap.set('n', '<C-`>', ':terminal<CR>i', { desc = 'Open terminal' })
vim.keymap.set('t', '<Esc>', '<C-\\><C-n>', { desc = 'Exit terminal mode' })
-- Send current line to terminal
vim.keymap.set('n', '<leader>sl', function()
local line = vim.api.nvim_get_current_line()
vim.fn.chansend(vim.b.terminal_job_id, line .. '\n')
end, { desc = 'Send line to terminal' })15.8.3 Diagnostic Navigation (Neovim)
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, { desc = 'Previous diagnostic' })
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, { desc = 'Next diagnostic' })
vim.keymap.set('n', '<leader>e', vim.diagnostic.open_float, { desc = 'Show diagnostic' })
vim.keymap.set('n', '<leader>q', vim.diagnostic.setloclist, { desc = 'Diagnostic list' })15.8.4 LSP Mappings (Neovim)
-- Attach to buffer when LSP connects
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufnr = args.buf
local opts = { buffer = bufnr }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, opts)
vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts)
vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', '<C-k>', vim.lsp.buf.signature_help, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, opts)
vim.keymap.set('n', '<leader>f', vim.lsp.buf.format, opts)
end,
})15.8.5 Clipboard Operations
" Copy to system clipboard
vnoremap <leader>y "+y
nnoremap <leader>Y "+yg_
nnoremap <leader>y "+y
" Paste from system clipboard
nnoremap <leader>p "+p
nnoremap <leader>P "+P
vnoremap <leader>p "+p
vnoremap <leader>P "+P
" Cut to system clipboard
vnoremap <leader>d "+d
15.9 Debugging and Discovering Mappings
15.9.1 Viewing Current Mappings
" View all mappings
:map
" View mode-specific mappings
:nmap " Normal mode
:imap " Insert mode
:vmap " Visual/Select mode
:xmap " Visual mode only
" View mappings for specific key
:nmap <leader>
:verbose nmap <C-l>
" View mappings in specific buffer
:map <buffer>
Lua equivalent:
-- Get all keymaps
vim.api.nvim_get_keymap('n')
-- Get buffer-local keymaps
vim.api.nvim_buf_get_keymap(0, 'n')15.9.2 Finding Mapping Conflicts
" Verbose shows where mapping was defined
:verbose nmap <leader>f
" Check if key is mapped
:nmap <Space>
" Output: No mapping found
15.9.3 Temporary Disabling
" Unmap temporarily
:nunmap <leader>w
" Restore (if you saved it)
nnoremap <leader>w :w<CR>
" Clear all mappings for a mode
:mapclear
:nmapclear
:imapclear
15.10 Best Practices
15.10.1 Mapping Guidelines
Always use
noremapvariants unless you specifically need recursionUse
<leader>for custom mappings to avoid conflictsMake mappings mnemonic - easier to remember
Document complex mappings with comments
Use
<silent>for mappings that don’t need command echoPrefer buffer-local mappings for filetype-specific functionality
Group related mappings with consistent prefixes
15.10.2 Organization Strategy
Example structure in init.lua:
-- ~/.config/nvim/lua/mappings.lua
local M = {}
-- General mappings
M.general = function()
vim.keymap.set('n', '<leader>w', ':w<CR>')
vim.keymap.set('n', '<leader>q', ':q<CR>')
end
-- Window mappings
M.windows = function()
vim.keymap.set('n', '<C-h>', '<C-w>h')
vim.keymap.set('n', '<C-j>', '<C-w>j')
vim.keymap.set('n', '<C-k>', '<C-w>k')
vim.keymap.set('n', '<C-l>', '<C-w>l')
end
-- Buffer mappings
M.buffers = function()
vim.keymap.set('n', '<leader>bn', ':bnext<CR>')
vim.keymap.set('n', '<leader>bp', ':bprevious<CR>')
end
-- Initialize all
M.setup = function()
M.general()
M.windows()
M.buffers()
end
return MThen in init.lua:
require('mappings').setup()15.10.3 Testing Mappings
-- Conditional mapping based on feature availability
if vim.fn.has('nvim-0.10') == 1 then
vim.keymap.set('n', '<leader>f', vim.lsp.buf.format)
else
vim.keymap.set('n', '<leader>f', ':!prettier %<CR>')
end
-- Check for plugin before mapping
if pcall(require, 'telescope') then
vim.keymap.set('n', '<leader>ff', ':Telescope find_files<CR>')
end✅ End of Chapter 15: Key Mappings
You now have comprehensive knowledge of Vim’s mapping system, from basic remaps to advanced Lua-based configurations. The next chapter in the sequence would naturally be Chapter #16: Marks and Jumps — exploring navigation history, jump lists, marks (local, global, and special), and efficient movement through your editing history.
Chapter 16: Autocommands and Events
Autocommands are Vim’s event-driven automation system, allowing you to execute commands automatically in response to specific events. This chapter explores how to leverage autocommands to create dynamic, context-aware editing environments.
16.1 Understanding Autocommands
16.1.1 Basic Syntax
Vimscript syntax:
autocmd [group] {event} {pattern} {command}
Where:
[group]: Optional autocmd group name{event}: The triggering event (e.g.,BufRead,FileType){pattern}: File pattern to match (e.g.,*.py,*.md){command}: Command(s) to execute
Simple examples:
" Auto-save when focus is lost
autocmd FocusLost * silent! wa
" Set line numbers for Python files
autocmd FileType python setlocal number
" Remove trailing whitespace on save
autocmd BufWritePre * :%s/\s\+$//e
16.1.2 Lua API (Neovim)
Basic structure:
vim.api.nvim_create_autocmd({event}, {
pattern = {pattern},
callback = function()
-- your code here
end,
group = {group}, -- optional
desc = "Description", -- optional
})Examples:
-- Auto-save on focus lost
vim.api.nvim_create_autocmd('FocusLost', {
pattern = '*',
command = 'silent! wa',
desc = 'Auto-save all buffers'
})
-- Set options for Python files
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.opt_local.number = true
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 4
end,
desc = 'Python file settings'
})16.2 Common Events
16.2.1 Buffer Events
| Event | Triggered When |
|---|---|
BufNewFile |
Creating a new buffer for a file that doesn’t exist |
BufRead, BufReadPost |
After reading a buffer (editing a new file) |
BufReadPre |
Before reading a buffer |
BufWrite, BufWritePost |
After writing the entire buffer |
BufWritePre |
Before writing the entire buffer |
BufWriteCmd |
Before writing, for custom write commands |
BufEnter |
After entering a buffer |
BufLeave |
Before leaving a buffer |
BufWinEnter |
After a buffer is displayed in a window |
BufWinLeave |
Before a buffer is removed from a window |
BufUnload |
Before unloading a buffer |
BufDelete |
Before deleting a buffer |
BufHidden |
Just after a buffer becomes hidden |
Examples:
" Create directory if it doesn't exist when saving
autocmd BufWritePre * call mkdir(expand('<afile>:p:h'), 'p')
" Jump to last known cursor position
autocmd BufReadPost *
\ if line("'\"") >= 1 && line("'\"") <= line("$") && &ft !~# 'commit'
\ | exe "normal! g`\""
\ | endif
" Auto-reload file if changed externally
autocmd FocusGained,BufEnter * checktime
Lua equivalents:
-- Create directory on save
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
callback = function()
local dir = vim.fn.expand('<afile>:p:h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end,
desc = 'Create parent directories on save'
})
-- Jump to last position
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local lcount = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= lcount then
pcall(vim.api.nvim_win_set_cursor, 0, mark)
end
end,
desc = 'Jump to last position'
})
-- Auto-reload on focus
vim.api.nvim_create_autocmd({'FocusGained', 'BufEnter'}, {
pattern = '*',
command = 'checktime',
desc = 'Check if file changed outside Vim'
})16.2.2 File Type Events
" FileType triggers when filetype is set
autocmd FileType python setlocal expandtab shiftwidth=4
autocmd FileType javascript setlocal expandtab shiftwidth=2
autocmd FileType go setlocal noexpandtab tabstop=4
" Multiple file types
autocmd FileType python,ruby,yaml setlocal expandtab shiftwidth=2
" File pattern matching
autocmd BufRead,BufNewFile *.conf setfiletype conf
autocmd BufRead,BufNewFile Dockerfile* setfiletype dockerfile
Lua approach:
-- Single filetype
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 4
vim.opt_local.tabstop = 4
end,
})
-- Multiple filetypes
vim.api.nvim_create_autocmd('FileType', {
pattern = {'python', 'ruby', 'yaml'},
callback = function()
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 2
end,
})
-- Detect filetype by pattern
vim.api.nvim_create_autocmd({'BufRead', 'BufNewFile'}, {
pattern = '*.conf',
callback = function()
vim.bo.filetype = 'conf'
end,
})16.2.3 Window and Tab Events
| Event | Triggered When |
|---|---|
WinEnter |
After entering a window |
WinLeave |
Before leaving a window |
WinNew |
After creating a new window |
WinClosed |
After closing a window |
TabEnter |
After entering a tab page |
TabLeave |
Before leaving a tab page |
TabNew |
After creating a new tab page |
TabClosed |
After closing a tab page |
Examples:
" Highlight active window
autocmd WinEnter * setlocal cursorline
autocmd WinLeave * setlocal nocursorline
" Auto-resize windows on vim resize
autocmd VimResized * wincmd =
" Set working directory to current file's directory
autocmd BufEnter * silent! lcd %:p:h
Lua equivalents:
-- Highlight active window
local cursorline_group = vim.api.nvim_create_augroup('CursorLine', { clear = true })
vim.api.nvim_create_autocmd('WinEnter', {
group = cursorline_group,
pattern = '*',
callback = function()
vim.opt_local.cursorline = true
end,
})
vim.api.nvim_create_autocmd('WinLeave', {
group = cursorline_group,
pattern = '*',
callback = function()
vim.opt_local.cursorline = false
end,
})
-- Auto-resize windows
vim.api.nvim_create_autocmd('VimResized', {
pattern = '*',
command = 'wincmd =',
desc = 'Equalize window sizes on resize'
})16.2.4 UI and Display Events
| Event | Triggered When |
|---|---|
VimEnter |
After doing all the startup stuff |
VimLeave |
Before exiting Vim |
VimResized |
After the Vim window was resized |
FocusGained |
Vim got input focus |
FocusLost |
Vim lost input focus |
ColorScheme |
After loading a color scheme |
OptionSet |
After setting an option |
CursorHold |
User doesn’t press a key for updatetime
milliseconds |
CursorHoldI |
Like CursorHold but in Insert mode |
CursorMoved |
Cursor moved in Normal mode |
CursorMovedI |
Cursor moved in Insert mode |
Examples:
" Auto-save on focus lost
autocmd FocusLost * silent! wa
" Show cursor line only in active window
autocmd VimEnter,WinEnter,BufWinEnter * setlocal cursorline
autocmd WinLeave * setlocal nocursorline
" Trigger after period of inactivity (useful for linting)
autocmd CursorHold,CursorHoldI * silent! checktime
" React to option changes
autocmd OptionSet background call SetupColorscheme()
Lua equivalents:
-- Auto-save on focus lost
vim.api.nvim_create_autocmd('FocusLost', {
pattern = '*',
command = 'silent! wa',
})
-- Update diagnostics on cursor hold
vim.api.nvim_create_autocmd('CursorHold', {
callback = function()
vim.diagnostic.open_float(nil, { focus = false, scope = 'cursor' })
end,
desc = 'Show diagnostics on cursor hold'
})
-- React to colorscheme changes
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
-- Custom highlight adjustments
vim.api.nvim_set_hl(0, 'Normal', { bg = 'NONE' })
vim.api.nvim_set_hl(0, 'NormalFloat', { bg = 'NONE' })
end,
})16.2.5 Editing Events
| Event | Triggered When |
|---|---|
InsertEnter |
Just before starting Insert mode |
InsertLeave |
Just after leaving Insert mode |
InsertChange |
When typing <Insert> in Insert/Replace
mode |
TextChanged |
After a change was made in Normal mode |
TextChangedI |
After a change was made in Insert mode |
TextYankPost |
After text has been yanked or deleted |
Examples:
" Show relative numbers in normal mode, absolute in insert
autocmd InsertEnter * set norelativenumber
autocmd InsertLeave * set relativenumber
" Highlight yanked text
autocmd TextYankPost * silent! lua vim.highlight.on_yank()
" Auto-format on text change (with debounce)
autocmd TextChanged,TextChangedI <buffer>
\ call timer_start(500, {-> execute('lua vim.lsp.buf.format()')})
Lua equivalents:
-- Relative numbers toggle
local number_toggle = vim.api.nvim_create_augroup('NumberToggle', { clear = true })
vim.api.nvim_create_autocmd('InsertEnter', {
group = number_toggle,
pattern = '*',
callback = function()
vim.opt.relativenumber = false
end,
})
vim.api.nvim_create_autocmd('InsertLeave', {
group = number_toggle,
pattern = '*',
callback = function()
vim.opt.relativenumber = true
end,
})
-- Highlight on yank
vim.api.nvim_create_autocmd('TextYankPost', {
callback = function()
vim.highlight.on_yank({ higroup = 'IncSearch', timeout = 200 })
end,
desc = 'Highlight yanked text'
})16.2.6 LSP and Diagnostic Events (Neovim)
| Event | Triggered When |
|---|---|
LspAttach |
When LSP client attaches to buffer |
LspDetach |
When LSP client detaches from buffer |
DiagnosticChanged |
When diagnostics change |
Examples:
-- Set up LSP keymaps when LSP attaches
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufnr = args.buf
local client = vim.lsp.get_client_by_id(args.data.client_id)
-- Set keymaps
local opts = { buffer = bufnr }
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
-- Enable inlay hints if supported
if client.server_capabilities.inlayHintProvider then
vim.lsp.inlay_hint.enable(bufnr, true)
end
end,
desc = 'LSP setup on attach'
})
-- Update diagnostics display
vim.api.nvim_create_autocmd('DiagnosticChanged', {
callback = function()
vim.diagnostic.setloclist({ open = false })
end,
})16.3 Autocommand Groups
16.3.1 Why Use Groups?
Groups help organize autocommands and prevent duplication when sourcing your vimrc multiple times.
Without groups (bad):
" Each time you source vimrc, this gets added again!
autocmd BufWritePre * :%s/\s\+$//e
autocmd BufWritePre * :%s/\s\+$//e " Now it runs twice!
With groups (good):
augroup TrimWhitespace
autocmd! " Clear existing autocmds in this group
autocmd BufWritePre * :%s/\s\+$//e
augroup END
16.3.2 Vimscript Groups
" Create and populate a group
augroup MyCustomGroup
autocmd! " Clear all autocmds in this group
autocmd FileType python setlocal expandtab
autocmd FileType javascript setlocal shiftwidth=2
autocmd BufWritePre * :%s/\s\+$//e
augroup END
" Multiple groups for organization
augroup FileTypeSettings
autocmd!
autocmd FileType python setlocal expandtab shiftwidth=4
autocmd FileType ruby setlocal expandtab shiftwidth=2
augroup END
augroup SaveSettings
autocmd!
autocmd BufWritePre * :%s/\s\+$//e
autocmd FocusLost * silent! wa
augroup END
16.3.3 Lua Groups
-- Create a group
local mygroup = vim.api.nvim_create_augroup('MyCustomGroup', { clear = true })
-- Add autocmds to the group
vim.api.nvim_create_autocmd('FileType', {
group = mygroup,
pattern = 'python',
callback = function()
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 4
end,
})
vim.api.nvim_create_autocmd('BufWritePre', {
group = mygroup,
pattern = '*',
callback = function()
-- Remove trailing whitespace
local save_cursor = vim.fn.getpos('.')
vim.cmd([[%s/\s\+$//e]])
vim.fn.setpos('.', save_cursor)
end,
})
-- Organize by functionality
local filetype_group = vim.api.nvim_create_augroup('FileTypeSettings', { clear = true })
local save_group = vim.api.nvim_create_augroup('SaveSettings', { clear = true })
vim.api.nvim_create_autocmd('FileType', {
group = filetype_group,
pattern = {'python', 'ruby', 'lua'},
callback = function()
vim.opt_local.expandtab = true
end,
})
vim.api.nvim_create_autocmd({'FocusLost', 'BufLeave'}, {
group = save_group,
pattern = '*',
command = 'silent! wa',
})16.4 Advanced Patterns and Techniques
16.4.1 Multiple Patterns
" Match multiple file extensions
autocmd BufRead,BufNewFile *.txt,*.md,*.markdown setfiletype markdown
" Match multiple events
autocmd BufRead,BufNewFile *.sh,*.bash setfiletype sh
" Complex patterns
autocmd BufRead,BufNewFile */templates/*.html setfiletype htmldjango
autocmd BufRead,BufNewFile .env* setfiletype sh
Lua equivalent:
-- Multiple patterns
vim.api.nvim_create_autocmd({'BufRead', 'BufNewFile'}, {
pattern = {'*.txt', '*.md', '*.markdown'},
callback = function()
vim.bo.filetype = 'markdown'
end,
})
-- Complex path patterns
vim.api.nvim_create_autocmd({'BufRead', 'BufNewFile'}, {
pattern = '*/templates/*.html',
callback = function()
vim.bo.filetype = 'htmldjango'
end,
})16.4.2 Conditional Execution
" Only for certain filetypes
autocmd FileType python if &textwidth == 0 | setlocal textwidth=79 | endif
" Check if plugin exists
autocmd VimEnter * if exists(':NERDTree') | echom "NERDTree available" | endif
" Pattern matching
autocmd BufWritePost *.vim if &filetype == 'vim' | source % | endif
Lua with conditions:
-- Conditional setting
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = function()
if vim.bo.textwidth == 0 then
vim.bo.textwidth = 79
end
end,
})
-- Check plugin availability
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
if pcall(require, 'telescope') then
print('Telescope available')
end
end,
})
-- Pattern-based logic
vim.api.nvim_create_autocmd('BufWritePost', {
pattern = '*.lua',
callback = function()
if vim.bo.filetype == 'lua' then
vim.cmd('source %')
end
end,
})16.4.3 Using Functions
" Define function first
function! StripTrailingWhitespace()
let l:save = winsaveview()
keeppatterns %s/\s\+$//e
call winrestview(l:save)
endfunction
" Use in autocmd
autocmd BufWritePre * call StripTrailingWhitespace()
" More complex example
function! SetupPython()
setlocal expandtab
setlocal shiftwidth=4
setlocal colorcolumn=80
nnoremap <buffer> <F5> :!python %<CR>
endfunction
autocmd FileType python call SetupPython()
Lua functions:
-- Define reusable functions
local function strip_trailing_whitespace()
local save_cursor = vim.fn.getpos('.')
vim.cmd([[keeppatterns %s/\s\+$//e]])
vim.fn.setpos('.', save_cursor)
end
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
callback = strip_trailing_whitespace,
})
-- Complex setup function
local function setup_python()
vim.opt_local.expandtab = true
vim.opt_local.shiftwidth = 4
vim.opt_local.colorcolumn = '80'
vim.keymap.set('n', '<F5>', ':!python %<CR>', { buffer = true })
end
vim.api.nvim_create_autocmd('FileType', {
pattern = 'python',
callback = setup_python,
})16.4.4 Nested Autocommands
" Enable nested autocommand execution
autocmd FileType * ++nested call SomeFunction()
" Example: cascade effect
augroup NestedExample
autocmd!
autocmd BufRead *.txt setfiletype text
autocmd FileType text ++nested setlocal spell
augroup END
Lua equivalent:
vim.api.nvim_create_autocmd('FileType', {
pattern = '*',
callback = function()
-- nested = true allows other autocmds to trigger
end,
nested = true,
})16.4.5 One-Time Autocommands
" Execute only once then remove
autocmd VimEnter * ++once echo "Welcome to Vim!"
" Lua doesn't have built-in once, but you can implement it
Lua implementation of once:
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
print('Welcome to Neovim!')
return true -- returning true deletes the autocmd
end,
once = true, -- Neovim 0.8+
})16.5 Practical Autocommand Recipes
16.5.1 File Management
-- Auto-create directories on save
vim.api.nvim_create_autocmd('BufWritePre', {
callback = function()
local dir = vim.fn.expand('<afile>:p:h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end,
})
-- Automatically delete trailing whitespace
local function trim_whitespace()
local save_cursor = vim.fn.getpos('.')
local old_query = vim.fn.getreg('/')
vim.cmd([[silent! %s/\s\+$//e]])
vim.fn.setpos('.', save_cursor)
vim.fn.setreg('/', old_query)
end
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = {'*.py', '*.js', '*.lua', '*.rs', '*.go'},
callback = trim_whitespace,
})
-- Automatically chmod +x for shell scripts
vim.api.nvim_create_autocmd('BufWritePost', {
pattern = {'*.sh', '*.bash', '*.zsh'},
callback = function()
vim.fn.system('chmod +x ' .. vim.fn.expand('%'))
end,
})
-- Auto-reload files changed outside Vim
vim.api.nvim_create_autocmd({'FocusGained', 'TermClose', 'TermLeave'}, {
command = 'checktime',
})16.5.2 Window and UI Management
-- Equalize window sizes on resize
vim.api.nvim_create_autocmd('VimResized', {
pattern = '*',
command = 'tabdo wincmd =',
})
-- Highlight on yank
vim.api.nvim_create_autocmd('TextYankPost', {
callback = function()
vim.highlight.on_yank({
higroup = 'IncSearch',
timeout = 150,
})
end,
})
-- Disable cursorline in inactive windows
local cursorline_group = vim.api.nvim_create_augroup('CursorLine', { clear = true })
vim.api.nvim_create_autocmd({'WinEnter', 'BufEnter'}, {
group = cursorline_group,
callback = function()
if vim.bo.filetype ~= 'TelescopePrompt' then
vim.opt_local.cursorline = true
end
end,
})
vim.api.nvim_create_autocmd('WinLeave', {
group = cursorline_group,
callback = function()
vim.opt_local.cursorline = false
end,
})
-- Close certain filetypes with 'q'
vim.api.nvim_create_autocmd('FileType', {
pattern = {'help', 'qf', 'man', 'lspinfo'},
callback = function(event)
vim.bo[event.buf].buflisted = false
vim.keymap.set('n', 'q', '<cmd>close<cr>', { buffer = event.buf, silent = true })
end,
})16.5.3 Format and Linting
-- Auto-format on save (with LSP)
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = {'*.py', '*.lua', '*.rs', '*.go'},
callback = function()
vim.lsp.buf.format({ timeout_ms = 2000 })
end,
})
-- Format with external tool
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*.py',
callback = function()
vim.cmd([[silent! !black %]])
end,
})
-- Run linter after save
vim.api.nvim_create_autocmd('BufWritePost', {
pattern = '*.js',
callback = function()
vim.fn.system('eslint --fix ' .. vim.fn.expand('%'))
vim.cmd('edit') -- Reload file
end,
})16.5.4 Project-Specific Settings
-- Load project-specific vimrc
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
local project_vimrc = vim.fn.getcwd() .. '/.nvimrc'
if vim.fn.filereadable(project_vimrc) == 1 then
vim.cmd('source ' .. project_vimrc)
end
end,
})
-- Set working directory to project root
vim.api.nvim_create_autocmd('BufEnter', {
callback = function()
local root_markers = {'.git', 'package.json', 'Cargo.toml', 'go.mod'}
local root_dir = vim.fs.dirname(vim.fs.find(root_markers, {
upward = true,
path = vim.fn.expand('%:p:h')
})[1])
if root_dir then
vim.fn.chdir(root_dir)
end
end,
})16.5.5 Terminal Integration
-- Start terminal in insert mode
vim.api.nvim_create_autocmd('TermOpen', {
callback = function()
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.signcolumn = 'no'
vim.cmd('startinsert')
end,
})
-- Close terminal buffer without confirmation
vim.api.nvim_create_autocmd('TermClose', {
callback = function()
vim.cmd('bdelete!')
end,
})16.5.6 Session and State Management
-- Auto-save session on exit
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
vim.cmd('mksession! ~/.config/nvim/session/last.vim')
end,
})
-- Restore cursor position
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local lcount = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= lcount then
pcall(vim.api.nvim_win_set_cursor, 0, mark)
end
end,
})
-- Save folds
vim.api.nvim_create_autocmd('BufWinLeave', {
pattern = '*.*',
command = 'mkview',
})
vim.api.nvim_create_autocmd('BufWinEnter', {
pattern = '*.*',
command = 'silent! loadview',
})16.6 Performance Considerations
16.6.1 Debouncing Frequent Events
-- Bad: Runs on every cursor movement
vim.api.nvim_create_autocmd('CursorMoved', {
callback = function()
-- Expensive operation
vim.lsp.buf.hover()
end,
})
-- Good: Debounce with timer
local hover_timer = nil
vim.api.nvim_create_autocmd('CursorHold', {
callback = function()
if hover_timer then
vim.fn.timer_stop(hover_timer)
end
hover_timer = vim.fn.timer_start(500, function()
vim.lsp.buf.hover()
end)
end,
})16.6.2 Limiting Patterns
" Bad: Runs on all files
autocmd BufWritePre * call ExpensiveFunction()
" Good: Only specific files
autocmd BufWritePre *.py,*.lua call ExpensiveFunction()
" Better: Use buffer-local when possible
autocmd FileType python autocmd BufWritePre <buffer> call PythonFormat()
16.6.3 Lazy Loading
-- Load heavy plugins only when needed
vim.api.nvim_create_autocmd('FileType', {
pattern = 'markdown',
once = true,
callback = function()
require('markdown-preview').setup()
end,
})
-- Defer expensive setup
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
vim.defer_fn(function()
-- Load non-critical plugins
require('telescope').setup()
end, 100)
end,
})16.7 Debugging Autocommands
16.7.1 Listing Autocommands
" View all autocommands
:autocmd
" View specific event
:autocmd BufWritePre
" View specific group
:autocmd MyGroup
" View for specific pattern
:autocmd * *.py
" Verbose output shows where defined
:verbose autocmd BufWritePre
Lua inspection:
-- Get all autocommands
vim.api.nvim_get_autocmds({})
-- Get specific group
vim.api.nvim_get_autocmds({ group = 'MyGroup' })
-- Get specific event
vim.api.nvim_get_autocmds({ event = 'BufWritePre' })16.7.2 Temporary Disabling
" Disable all autocommands temporarily
:autocmd! MyGroup
" Disable specific autocommand
:autocmd! MyGroup BufWritePre
" Re-enable by re-sourcing config
:source $MYVIMRC
16.7.3 Testing with echom
" Add debug output
augroup TestGroup
autocmd!
autocmd BufWritePre * echom "About to write: " . expand('%')
augroup END
" View messages
:messages
Lua debugging:
vim.api.nvim_create_autocmd('BufWritePre', {
callback = function()
print('Writing file: ' .. vim.fn.expand('%'))
-- Your actual code here
end,
})16.8 Best Practices
16.8.1 Organization Strategy
-- ~/.config/nvim/lua/autocmds.lua
local M = {}
function M.setup()
-- File management
M.setup_file_management()
-- UI enhancements
M.setup_ui()
-- LSP integration
M.setup_lsp()
-- Filetype specific
M.setup_filetypes()
end
function M.setup_file_management()
local group = vim.api.nvim_create_augroup('FileManagement', { clear = true })
vim.api.nvim_create_autocmd('BufWritePre', {
group = group,
callback = function()
local dir = vim.fn.expand('<afile>:p:h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end,
})
end
function M.setup_ui()
local group = vim.api.nvim_create_augroup('UIEnhancements', { clear = true })
vim.api.nvim_create_autocmd('TextYankPost', {
group = group,
callback = function()
vim.highlight.on_yank({ timeout = 200 })
end,
})
end
return MIn init.lua:
require('autocmds').setup()16.8.2 Guidelines
Always use augroups to prevent duplicate autocmds
Clear groups with
autocmd!in Vimscript orclear = truein LuaUse specific patterns instead of
*when possiblePrefer
callbackovercommandin Lua for better error handlingAdd descriptions to document purpose
Test expensive operations for performance impact
Use buffer-local autocmds for filetype-specific behavior
Defer non-critical operations to improve startup time
16.8.3 Common Pitfalls to Avoid
-- ❌ Bad: No group, will duplicate
vim.api.nvim_create_autocmd('BufWritePre', {
callback = function() end,
})
-- ✅ Good: Uses group
local group = vim.api.nvim_create_augroup('MyGroup', { clear = true })
vim.api.nvim_create_autocmd('BufWritePre', {
group = group,
callback = function() end,
})
-- ❌ Bad: Too broad pattern
vim.api.nvim_create_autocmd('CursorMoved', {
pattern = '*',
callback = expensive_function,
})
-- ✅ Good: Use CursorHold and specific patterns
vim.api.nvim_create_autocmd('CursorHold', {
pattern = '*.py',
callback = expensive_function,
})
-- ❌ Bad: Synchronous heavy operation
vim.api.nvim_create_autocmd('BufWritePost', {
callback = function()
vim.fn.system('sleep 2') -- Blocks Vim!
end,
})
-- ✅ Good: Async operation
vim.api.nvim_create_autocmd('BufWritePost', {
callback = function()
vim.fn.jobstart('long-running-command')
end,
})✅ End of Chapter 16: Autocommands and Events
You now have comprehensive knowledge of Vim’s event-driven automation system. You can create sophisticated, responsive configurations that adapt to different contexts, file types, and editing scenarios. Master these patterns to build a truly personalized and efficient editing environment.
Chapter 17: VimScript Fundamentals
VimScript is Vim’s built-in scripting language, essential for customization, plugin development, and automation. While Neovim increasingly favors Lua, VimScript remains vital for compatibility and understanding existing configurations.
17.1 Understanding VimScript
17.1.1 What is VimScript?
VimScript (also called Vimscript or VimL) is:
A domain-specific language designed for Vim customization
Interpreted, not compiled
Dynamically typed with loose type checking
Command-oriented, reflecting Vim’s Ex-command heritage
Essential for understanding most Vim plugins and configurations
17.1.2 Where VimScript Lives
" In your .vimrc or init.vim
set number
nnoremap <leader>w :w<CR>
" In autoload files: ~/.vim/autoload/myutils.vim
function! myutils#greeting()
echo "Hello!"
endfunction
" In plugin files: ~/.vim/plugin/myplugin.vim
if exists('g:loaded_myplugin')
finish
endif
let g:loaded_myplugin = 1
" In ftplugin: ~/.vim/ftplugin/python.vim
setlocal expandtab
setlocal shiftwidth=4
17.1.3 Executing VimScript
" Execute a single command
:echo "Hello, Vim!"
" Execute multiple lines
:execute "normal! gg" | echo "Jumped to top"
" Source a file
:source ~/.vimrc
:source % " Source current file
" Evaluate an expression
:echo 2 + 2 " Outputs: 4
" Execute normal mode commands
:normal! ggVG " Select all
17.2 Variables and Data Types
17.2.1 Variable Scopes
VimScript has explicit scope prefixes:
| Prefix | Scope | Example |
|---|---|---|
g: |
Global | let g:myvar = 1 |
l: |
Function-local | let l:temp = 2 |
s: |
Script-local | let s:counter = 0 |
a: |
Function argument | function! Func(arg) uses a:arg |
b: |
Buffer-local | let b:filename = "test.txt" |
w: |
Window-local | let w:view = "split" |
t: |
Tab-local | let t:name = "Project" |
v: |
Vim predefined | v:version, v:true |
$ |
Environment | $HOME, $PATH |
@ |
Register | @a, @" |
& |
Option | &number, &expandtab |
Examples:
" Global variable (accessible everywhere)
let g:my_global_var = "accessible everywhere"
" Script-local (only in this script file)
let s:my_script_var = "only in this file"
" Buffer-local (specific to current buffer)
let b:my_buffer_var = "buffer specific"
" Window-local
let w:my_window_var = "window specific"
" Function local
function! MyFunc()
let l:local_var = "only in function"
echo l:local_var
endfunction
" No prefix defaults to function-local inside functions,
" global outside functions
let implicit_global = "global by default"
17.2.2 Data Types
Numbers
" Integers
let decimal = 42
let hex = 0x2A " Hexadecimal
let octal = 052 " Octal
" Arithmetic
let sum = 10 + 5 " 15
let diff = 10 - 5 " 5
let prod = 10 * 5 " 50
let quot = 10 / 3 " 3 (integer division)
let mod = 10 % 3 " 1
Floats
" Floating point numbers (Vim 7.2+)
let pi = 3.14159
let sci = 1.5e-3 " Scientific notation
" Float operations
let result = 10.0 / 3.0 " 3.333333
let rounded = floor(3.7) " 3.0
let ceiled = ceil(3.2) " 4.0
Strings
" Single vs double quotes
let single = 'literal string, no escapes except '''
let double = "can have\nnew lines and\ttabs"
" String concatenation
let name = "Vim"
let greeting = "Hello, " . name . "!"
let concat = "Line1\n" . "Line2"
" String operations
let length = len("Hello") " 5
let upper = toupper("hello") " HELLO
let lower = tolower("HELLO") " hello
let sub = strpart("Hello", 1, 3) " ell
let idx = stridx("Hello", "ll") " 2
" String formatting
let formatted = printf("Value: %d", 42)
let padded = printf("%5s", "Hi") " " Hi"
Lists
" Creating lists
let empty = []
let numbers = [1, 2, 3, 4, 5]
let mixed = [1, "two", 3.0, [4, 5]]
" Accessing elements
let first = numbers[0] " 1
let last = numbers[-1] " 5
let slice = numbers[1:3] " [2, 3, 4]
" List operations
call add(numbers, 6) " Add to end: [1, 2, 3, 4, 5, 6]
call insert(numbers, 0) " Insert at start
call remove(numbers, 0) " Remove first element
call extend(numbers, [7, 8]) " Extend list
let length = len(numbers) " List length
let joined = join(numbers, ", ") " "1, 2, 3, 4, 5"
let reversed = reverse(copy(numbers))
" Iterating
for num in numbers
echo num
endfor
" List functions
let sorted = sort(copy(numbers))
let unique = uniq(sort(copy(numbers)))
let found = index(numbers, 3) " Find index
Dictionaries
" Creating dictionaries
let empty = {}
let person = {
\ "name": "Alice",
\ "age": 30,
\ "active": 1
\ }
" Accessing values
let name = person["name"] " Using bracket notation
let age = person.age " Using dot notation (if key is valid identifier)
" Modifying
let person.city = "New York" " Add key
let person["age"] = 31 " Update value
unlet person.active " Remove key
" Dictionary operations
let keys = keys(person) " Get all keys
let values = values(person) " Get all values
let has_key = has_key(person, "name") " Check key exists
" Iterating
for key in keys(person)
echo key . ": " . person[key]
endfor
" Using items() for key-value pairs
for [key, value] in items(person)
echo key . " => " . value
endfor
" Merge dictionaries
let defaults = {"theme": "dark", "font": "mono"}
let config = {"theme": "light"}
let merged = extend(copy(defaults), config) " config overrides defaults
Booleans
" VimScript uses 0 for false, non-zero for true
let is_true = 1
let is_false = 0
" Special constants (Vim 7.4.1154+)
let really_true = v:true
let really_false = v:false
let nothing = v:null
let not_a_number = v:none
" Boolean operations
let and_result = 1 && 0 " 0
let or_result = 1 || 0 " 1
let not_result = !1 " 0
" Comparison
let equal = (5 == 5) " 1
let not_equal = (5 != 3) " 1
let greater = (5 > 3) " 1
let less_eq = (3 <= 5) " 1
" String comparison (case-sensitive by default)
let same = "abc" ==# "abc" " 1 (case-sensitive)
let diff = "abc" ==? "ABC" " 1 (case-insensitive)
17.2.3 Type Checking and Conversion
" Type checking
echo type(42) " 0 (Number)
echo type(3.14) " 5 (Float)
echo type("string") " 1 (String)
echo type([]) " 3 (List)
echo type({}) " 4 (Dictionary)
echo type(function('tr')) " 2 (Funcref)
" Type constants
let is_number = type(var) == type(0)
let is_string = type(var) == type("")
let is_list = type(var) == type([])
let is_dict = type(var) == type({})
" Type conversion
let str_to_num = str2nr("42") " 42
let num_to_str = string(42) " "42"
let str_to_float = str2float("3.14") " 3.14
let list_to_str = string([1, 2, 3]) " "[1, 2, 3]"
" Evaluation
let code = "2 + 2"
let result = eval(code) " 4
17.3 Operators and Expressions
17.3.1 Arithmetic Operators
let a = 10
let b = 3
echo a + b " 13
echo a - b " 7
echo a * b " 30
echo a / b " 3 (integer division)
echo a % b " 1 (modulo)
echo a . b " "103" (string concatenation)
17.3.2 Comparison Operators
" Numeric comparison
echo 5 == 5 " 1 (true)
echo 5 != 3 " 1 (true)
echo 5 > 3 " 1
echo 5 < 3 " 0 (false)
echo 5 >= 5 " 1
echo 5 <= 3 " 0
" String comparison - depends on 'ignorecase' setting
echo "abc" == "ABC" " Depends on 'ignorecase'
" Case-sensitive operators
echo "abc" ==# "ABC" " 0 (case-sensitive, always)
echo "abc" !=# "ABC" " 1
" Case-insensitive operators
echo "abc" ==? "ABC" " 1 (case-insensitive, always)
echo "abc" !=? "ABC" " 0
" Pattern matching
echo "hello" =~ "hel" " 1 (matches)
echo "hello" =~ "^hel" " 1 (starts with)
echo "HELLO" =~# "hello" " 0 (case-sensitive)
echo "HELLO" =~? "hello" " 1 (case-insensitive)
" Negative pattern matching
echo "hello" !~ "xyz" " 1 (doesn't match)
17.3.3 Logical Operators
" AND operator
echo 1 && 1 " 1
echo 1 && 0 " 0
echo 0 && 1 " 0
" OR operator
echo 1 || 0 " 1
echo 0 || 0 " 0
" NOT operator
echo !1 " 0
echo !0 " 1
echo !!5 " 1 (double negation)
" Short-circuit evaluation
let result = expensive_func() || cheap_func() " If first is true, second not evaluated
17.3.4 Ternary Operator
" condition ? true_value : false_value
let max = a > b ? a : b
let status = is_active ? "active" : "inactive"
" Nested ternary
let grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : 'F'
17.4 Control Flow
17.4.1 If Statements
" Basic if
if condition
echo "True branch"
endif
" If-else
if score >= 60
echo "Pass"
else
echo "Fail"
endif
" If-elseif-else
if score >= 90
echo "A"
elseif score >= 80
echo "B"
elseif score >= 70
echo "C"
elseif score >= 60
echo "D"
else
echo "F"
endif
" One-liner (using bar |)
if exists('g:myvar') | echo g:myvar | endif
" Checking existence
if exists('g:my_variable')
echo g:my_variable
endif
if has('nvim')
" Neovim-specific code
elseif has('patch-8.2.0')
" Vim 8.2+ specific code
endif
" Check if function exists
if exists('*MyFunction')
call MyFunction()
endif
17.4.2 For Loops
" Iterate over list
let numbers = [1, 2, 3, 4, 5]
for num in numbers
echo num
endfor
" Iterate over range
for i in range(5) " 0, 1, 2, 3, 4
echo i
endfor
for i in range(1, 5) " 1, 2, 3, 4, 5
echo i
endfor
for i in range(0, 10, 2) " 0, 2, 4, 6, 8, 10 (step of 2)
echo i
endfor
" Iterate over dictionary
let person = {"name": "Alice", "age": 30}
for key in keys(person)
echo key . ": " . person[key]
endfor
" Using items() for key-value pairs
for [key, val] in items(person)
echo key . " => " . val
endfor
" Continue and break
for i in range(10)
if i == 3
continue " Skip to next iteration
endif
if i == 7
break " Exit loop
endif
echo i
endfor
17.4.3 While Loops
" Basic while
let counter = 0
while counter < 5
echo counter
let counter += 1
endwhile
" With break
let i = 0
while 1 " Infinite loop
let i += 1
if i > 10
break
endif
echo i
endwhile
" With continue
let i = 0
while i < 10
let i += 1
if i % 2 == 0
continue " Skip even numbers
endif
echo i
endwhile
17.4.4 Try-Catch
" Basic try-catch
try
" Code that might fail
call NonExistentFunction()
catch
echo "An error occurred!"
endtry
" Catch specific errors
try
let result = 10 / 0
catch /E484:/ " Division by zero
echo "Cannot divide by zero"
catch /E117:/ " Unknown function
echo "Function not found"
catch
echo "Unknown error: " . v:exception
endtry
" Finally block (always executed)
try
call SomeFunction()
catch
echo "Error: " . v:exception
finally
echo "Cleanup code"
endtry
" Throwing exceptions
function! Divide(a, b)
if a:b == 0
throw "Cannot divide by zero"
endif
return a:a / a:b
endfunction
try
echo Divide(10, 0)
catch
echo "Caught: " . v:exception
endtry
17.5 Functions
17.5.1 Defining Functions
" Basic function
function! Greet()
echo "Hello!"
endfunction
" Call it
call Greet()
" Function with arguments
function! Greet(name)
echo "Hello, " . a:name . "!"
endfunction
call Greet("Alice")
" Multiple arguments
function! Add(x, y)
return a:x + a:y
endfunction
echo Add(5, 3) " 8
" Variable arguments (...)
function! Sum(...)
let total = 0
for num in a:000 " a:000 is list of all arguments
let total += num
endfor
return total
endfunction
echo Sum(1, 2, 3, 4, 5) " 15
" Mixed regular and variable arguments
function! LogMessage(level, ...)
echo a:level . ": " . join(a:000, " ")
endfunction
call LogMessage("ERROR", "File", "not", "found")
17.5.2 Function Modifiers
" abort: Stop on first error
function! SafeFunc() abort
let x = undefined_var " Would stop here
echo "This won't execute"
endfunction
" range: Function works on range
function! ProcessRange() range
echo "First line: " . a:firstline
echo "Last line: " . a:lastline
endfunction
" Call with range: :5,10call ProcessRange()
" dict: Function is dictionary method
let myobj = {}
function! myobj.method() dict
echo "Called on: " . string(self)
endfunction
call myobj.method()
" Combining modifiers
function! MyFunc() range abort
" Function body
endfunction
17.5.3 Return Values
" Explicit return
function! Square(n)
return a:n * a:n
endfunction
" Early return
function! IsPositive(n)
if a:n <= 0
return 0
endif
return 1
endfunction
" No return (implicitly returns 0)
function! NoReturn()
echo "No return value"
endfunction
echo NoReturn() " 0
17.5.4 Local Variables in Functions
function! Calculate()
" Explicit local scope
let l:temp = 10
let l:result = l:temp * 2
" Implicit local scope (inside function)
let another = 5
return l:result + another
endfunction
17.5.5 Autoload Functions
" File: ~/.vim/autoload/myutils.vim
function! myutils#hello()
echo "Hello from autoload!"
endfunction
function! myutils#math#add(a, b)
return a:a + a:b
endfunction
" In your vimrc or other scripts:
call myutils#hello()
echo myutils#math#add(5, 3)
17.5.6 Lambda Functions (Vim 8+)
" Lambda syntax: {args -> expression}
let Double = {x -> x * 2}
echo Double(5) " 10
" Multiple arguments
let Add = {x, y -> x + y}
echo Add(3, 4) " 7
" Using with map()
let numbers = [1, 2, 3, 4]
let doubled = map(copy(numbers), {idx, val -> val * 2})
echo doubled " [2, 4, 6, 8]
" Using with filter()
let evens = filter(copy(numbers), {idx, val -> val % 2 == 0})
echo evens " [2, 4]
17.5.7 Function References
" Get reference to function
function! MyFunc()
echo "Called!"
endfunction
let Funcref = function('MyFunc')
call Funcref()
" Partial application
function! Greet(greeting, name)
echo a:greeting . ", " . a:name
endfunction
let SayHello = function('Greet', ['Hello'])
call SayHello('Alice') " "Hello, Alice"
" Check if funcref
echo type(Funcref) == type(function('tr')) " 1
17.6 Working with Vim’s Environment
17.6.1 Options
" Set options
set number
set relativenumber
set tabstop=4
" Get option value
echo &number " 1
echo &tabstop " 4
" Set option from variable
let &tabstop = 8
" Toggle boolean options
set number! " Toggle
set nonumber " Disable
set number " Enable
" Local vs global options
setlocal tabstop=4 " Buffer-local
setglobal tabstop=8 " Global default
set tabstop=2 " Set both
" Query options
set number? " Shows current value
set tabstop?
17.6.2 Registers
" Access registers
echo @" " Unnamed register (last yank/delete)
echo @0 " Last yank
echo @a " Named register a
echo @/ " Last search pattern
echo @: " Last command
" Set registers
let @a = "text to store"
let @/ = "search pattern"
" Append to register
let @A = "append this" " Capital letter appends
" Clear register
let @a = ""
17.6.3 Environment Variables
" Access environment variables
echo $HOME
echo $USER
echo $PATH
" Set environment variable
let $MY_VAR = "value"
" Check if exists
if !empty($MY_VAR)
echo "Variable is set"
endif
17.6.4 Executing External Commands
" Run shell command
:!ls -la
" Capture output
let output = system('ls -la')
echo output
" Execute and insert output
:read !date
" Execute current line as shell command
:.w !sh
" Use systemlist() for list of lines
let files = systemlist('ls')
for file in files
echo file
endfor
17.7 String Manipulation
17.7.1 Common String Functions
" Length
echo len("Hello") " 5
" Case conversion
echo toupper("hello") " HELLO
echo tolower("HELLO") " hello
" Substring
echo strpart("Hello World", 6, 5) " World
echo "Hello"[1:3] " ell
" Finding substrings
echo stridx("Hello", "ll") " 2 (index)
echo match("Hello", "l") " 2 (first match)
echo strridx("Hello", "l") " 3 (last occurrence)
" Splitting and joining
let parts = split("one,two,three", ",") " ['one', 'two', 'three']
let joined = join(parts, "-") " "one-two-three"
" Trimming
echo trim(" hello ") " "hello"
" Replacement
echo substitute("Hello World", "World", "Vim", "") " "Hello Vim"
echo substitute("a b c", " ", "-", "g") " "a-b-c" (global)
" Repeating
echo repeat("ab", 3) " "ababab"
" Formatting
echo printf("%s is %d years old", "Alice", 30)
echo printf("%5d", 42) " " 42" (padded)
echo printf("%-5s", "Hi") " "Hi " (left-aligned)
17.7.2 Regular Expressions in VimScript
" Pattern matching
if "hello" =~ "hel"
echo "Match found"
endif
" Extract matches
let text = "Price: $42.50"
let match = matchstr(text, '\d\+\.\d\+') " "42.50"
" Get match position
let pos = match(text, '\d') " Position of first digit
" Get all matches
let numbers = matchlist("abc123def456", '\d\+')
echo numbers " ['123', '', '', '', '', '', '', '', '', '']
" Substitute
let newtext = substitute("hello world", "world", "vim", "")
" With regex
let result = substitute("test123", '\d\+', 'NUMBER', '') " "testNUMBER"
17.8 Working with Buffers, Windows, and Files
17.8.1 Buffer Operations
" Get current buffer number
echo bufnr('%')
" Get buffer name
echo bufname('%') " Current buffer
echo bufname(3) " Buffer 3
" Check if buffer exists
if bufexists('myfile.txt')
echo "Buffer exists"
endif
" Check if buffer is loaded
if bufloaded(3)
echo "Buffer 3 is loaded"
endif
" Get buffer list
let buffers = getbufinfo()
for buf in buffers
echo buf.bufnr . ": " . buf.name
endfor
" Execute in buffer
call setbufvar(2, '&number', 1) " Set option in buffer 2
let value = getbufvar(2, '&number') " Get option from buffer 2
17.8.2 Window Operations
" Get current window number
echo winnr()
" Get window count
echo winnr('$')
" Get window ID
echo win_getid()
" Move between windows
execute "normal! \<C-w>w"
" Window dimensions
echo winwidth(0) " Current window width
echo winheight(0) " Current window height
" Save and restore view
let saved_view = winsaveview()
" ... do operations ...
call winrestview(saved_view)
17.8.3 File Operations
" Read file into list
let lines = readfile('myfile.txt')
" Write list to file
call writefile(lines, 'output.txt')
" Append to file
call writefile(['new line'], 'output.txt', 'a')
" Check if file exists
if filereadable('myfile.txt')
echo "File is readable"
endif
" Get file info
echo getfsize('myfile.txt') " Size in bytes
echo getftime('myfile.txt') " Modification time
" File type checks
echo isdirectory('/tmp') " 1
echo filereadable('/etc/hosts') " 1 (usually)
" Expand filename
echo expand('%') " Current file
echo expand('%:p') " Full path
echo expand('%:h') " Head (directory)
echo expand('%:t') " Tail (filename)
echo expand('%:r') " Root (without extension)
echo expand('%:e') " Extension
" Globbing
let files = glob('*.txt') " Returns string
let file_list = globpath('.', '*.txt') " Search in path
17.9 Practical Examples
17.9.1 Toggle Comment Function
function! ToggleComment()
let comment_char = {
\ 'vim': '"',
\ 'python': '#',
\ 'javascript': '//',
\ 'c': '//',
\ }
let ft = &filetype
if !has_key(comment_char, ft)
echo "No comment character defined for " . ft
return
endif
let char = comment_char[ft]
let line = getline('.')
if line =~ '^\s*' . char
" Remove comment
execute 's/^\(\s*\)' . char . '\s*/\1/'
else
" Add comment
execute 's/^/'. char . ' /'
endif
endfunction
nnoremap <leader>c :call ToggleComment()<CR>
17.9.2 Create a Simple Plugin
" File: ~/.vim/plugin/timestamp.vim
if exists('g:loaded_timestamp')
finish
endif
let g:loaded_timestamp = 1
function! s:InsertTimestamp()
let timestamp = strftime('%Y-%m-%d %H:%M:%S')
execute "normal! i" . timestamp
endfunction
command! Timestamp call s:InsertTimestamp()
nnoremap <leader>ts :Timestamp<CR>
17.9.3 Custom Status Line Function
function! GitBranch()
if !isdirectory('.git')
return ''
endif
try
let branch = system('git branch --show-current 2>/dev/null')
return ' [' . substitute(branch, '\n', '', '') . ']'
catch
return ''
endtry
endfunction
function! CustomStatusLine()
let status = ''
let status .= ' %f' " Filename
let status .= ' %m' " Modified flag
let status .= GitBranch() " Git branch
let status .= '%=' " Right align
let status .= ' %y' " File type
let status .= ' %p%%' " Percentage through file
let status .= ' %l:%c' " Line:Column
return status
endfunction
set statusline=%!CustomStatusLine()
17.9.4 Simple Autocomplete Function
function! SimpleComplete(findstart, base)
if a:findstart
" Find start of word
let line = getline('.')
let start = col('.') - 1
while start > 0 && line[start - 1] =~ '\a'
let start -= 1
endwhile
return start
else
" Find matching words
let words = ['apple', 'application', 'apply', 'banana', 'band']
let matches = []
for word in words
if word =~ '^' . a:base
call add(matches, word)
endif
endfor
return matches
endif
endfunction
set completefunc=SimpleComplete
" Use with <C-x><C-u> in insert mode
✅ End of Chapter 17: VimScript Fundamentals
You now have a solid foundation in VimScript programming. You understand variables, data types, control flow, functions, and practical applications. This knowledge enables you to read and write VimScript for customization, plugin development, and automation tasks. In the next chapter, we’ll explore Lua integration in Neovim, which builds upon these concepts with a modern scripting approach.
Chapter 18: Advanced VimScript
Building on the fundamentals, this chapter explores advanced VimScript techniques for creating sophisticated plugins, optimizing performance, and mastering intricate language features.
18.1 Advanced Function Techniques
18.1.1 Closure and Partial Application
" Creating closures
function! MakeCounter()
let count = 0
function! Increment() closure
let count += 1
return count
endfunction
return function('Increment')
endfunction
let Counter1 = MakeCounter()
let Counter2 = MakeCounter()
echo Counter1() " 1
echo Counter1() " 2
echo Counter2() " 1 (independent counter)
Practical Example: Memoization
function! Memoize(func)
let cache = {}
function! Wrapper(...) closure
let key = string(a:000)
if !has_key(cache, key)
let cache[key] = call(a:func, a:000)
endif
return cache[key]
endfunction
return function('Wrapper')
endfunction
" Expensive function
function! Fibonacci(n)
if a:n <= 1
return a:n
endif
return Fibonacci(a:n - 1) + Fibonacci(a:n - 2)
endfunction
let FastFib = Memoize(function('Fibonacci'))
echo FastFib(30) " Fast after first call
18.1.2 Function Factories
" Create functions dynamically
function! MakeGreeter(greeting)
let Greeter = {name -> a:greeting . ', ' . name . '!'}
return Greeter
endfunction
let SayHello = MakeGreeter('Hello')
let SayHi = MakeGreeter('Hi')
echo SayHello('Alice') " Hello, Alice!
echo SayHi('Bob') " Hi, Bob!
Creating Command Wrappers:
function! MakeCommand(cmd, opts)
function! Wrapper(...) closure
let args = join(a:000, ' ')
let full_cmd = a:cmd . ' ' . a:opts . ' ' . args
return system(full_cmd)
endfunction
return function('Wrapper')
endfunction
let GitLog = MakeCommand('git', 'log --oneline')
let GitStatus = MakeCommand('git', 'status --short')
echo GitLog('-5') " Last 5 commits
echo GitStatus() " Short status
18.1.3 Method Chaining with Dictionaries
" Fluent interface pattern
let Builder = {
\ 'value': '',
\ }
function! Builder.append(text) dict
let self.value .= a:text
return self
endfunction
function! Builder.uppercase() dict
let self.value = toupper(self.value)
return self
endfunction
function! Builder.build() dict
return self.value
endfunction
function! Builder.new()
return copy(Builder)
endfunction
" Usage
let result = Builder.new()
\.append('hello ')
\.append('world')
\.uppercase()
\.build()
echo result " HELLO WORLD
18.2 Advanced Data Structure Patterns
18.2.1 Object-Oriented Programming
" Base class
let Animal = {
\ 'name': '',
\ 'age': 0,
\ }
function! Animal.init(name, age) dict
let self.name = a:name
let self.age = a:age
return self
endfunction
function! Animal.speak() dict
echo self.name . ' makes a sound'
endfunction
function! Animal.new(...)
let obj = copy(self)
if a:0 > 0
call call(obj.init, a:000, obj)
endif
return obj
endfunction
" Derived class
let Dog = copy(Animal)
function! Dog.speak() dict
echo self.name . ' barks: Woof!'
endfunction
function! Dog.fetch() dict
echo self.name . ' is fetching!'
endfunction
" Usage
let mydog = Dog.new('Rex', 3)
call mydog.speak() " Rex barks: Woof!
call mydog.fetch() " Rex is fetching!
Advanced Inheritance:
" Mixin pattern
let Serializable = {}
function! Serializable.toJSON() dict
return json_encode(self)
endfunction
function! Serializable.fromJSON(json) dict
let data = json_decode(a:json)
call extend(self, data)
return self
endfunction
" Mix it in
call extend(Dog, Serializable)
let dog = Dog.new('Max', 5)
let json = dog.toJSON()
echo json
let restored = Dog.new()
call restored.fromJSON(json)
18.2.2 State Machines
" Traffic light state machine
let TrafficLight = {
\ 'state': 'red',
\ 'transitions': {
\ 'red': 'green',
\ 'green': 'yellow',
\ 'yellow': 'red',
\ },
\ 'durations': {
\ 'red': 3000,
\ 'green': 5000,
\ 'yellow': 2000,
\ }
\ }
function! TrafficLight.next() dict
let self.state = self.transitions[self.state]
echo 'Light is now: ' . toupper(self.state)
return self.state
endfunction
function! TrafficLight.duration() dict
return self.durations[self.state]
endfunction
function! TrafficLight.reset() dict
let self.state = 'red'
endfunction
" Usage
let light = copy(TrafficLight)
call light.next() " Green
call light.next() " Yellow
call light.next() " Red
18.2.3 Observer Pattern
let Observable = {
\ 'observers': []
\ }
function! Observable.subscribe(observer) dict
call add(self.observers, a:observer)
endfunction
function! Observable.unsubscribe(observer) dict
call filter(self.observers, 'v:val != a:observer')
endfunction
function! Observable.notify(event) dict
for observer in self.observers
call observer(a:event)
endfor
endfunction
" Usage
function! LogObserver(event)
echo 'Logged: ' . string(a:event)
endfunction
function! AlertObserver(event)
if a:event.priority == 'high'
echo 'ALERT: ' . a:event.message
endif
endfunction
let subject = copy(Observable)
call subject.subscribe(function('LogObserver'))
call subject.subscribe(function('AlertObserver'))
call subject.notify({'message': 'Test', 'priority': 'high'})
18.3 Metaprogramming
18.3.1 Dynamic Function Creation
" Generate getter/setter functions
function! GenerateAccessors(dict, fields)
for field in a:fields
" Getter
let getter_name = 'get_' . field
let a:dict[getter_name] = function('s:Getter', [field])
" Setter
let setter_name = 'set_' . field
let a:dict[setter_name] = function('s:Setter', [field])
endfor
endfunction
function! s:Getter(field) dict
return self[a:field]
endfunction
function! s:Setter(field, value) dict
let self[a:field] = a:value
endfunction
" Usage
let Person = {'name': '', 'age': 0}
call GenerateAccessors(Person, ['name', 'age'])
let p = copy(Person)
call p.set_name('Alice')
call p.set_age(30)
echo p.get_name() " Alice
18.3.2 Code Generation
" Generate repetitive mappings
function! GenerateMappings(prefix, commands)
for [key, cmd] in items(a:commands)
let mapping = a:prefix . key
execute 'nnoremap ' . mapping . ' :' . cmd . '<CR>'
endfor
endfunction
call GenerateMappings('<leader>g', {
\ 's': 'Git status',
\ 'c': 'Git commit',
\ 'p': 'Git push',
\ 'l': 'Git log',
\ })
Template-Based Generation:
function! GenerateClass(name, fields)
let template = [
\ 'let {{CLASS}} = {',
\ ]
" Add fields
for field in a:fields
call add(template, " \\ '" . field . "': '',")
endfor
call add(template, ' \\ }')
call add(template, '')
" Add constructor
call add(template, 'function! {{CLASS}}.new(...) dict')
call add(template, ' let obj = copy(self)')
for i in range(len(a:fields))
let field = a:fields[i]
let template_line = printf(' if a:0 > %d | let obj.%s = a:%d | endif',
\ i, field, i + 1)
call add(template, template_line)
endfor
call add(template, ' return obj')
call add(template, 'endfunction')
" Replace placeholder
let code = []
for line in template
call add(code, substitute(line, '{{CLASS}}', a:name, 'g'))
endfor
return code
endfunction
" Generate and execute
let class_code = GenerateClass('User', ['name', 'email', 'role'])
for line in class_code
execute line
endfor
let user = User.new('Alice', 'alice@example.com', 'admin')
18.3.3 Reflection and Introspection
" Inspect function signature
function! InspectFunction(funcname)
let info = {
\ 'exists': exists('*' . a:funcname),
\ 'name': a:funcname,
\ }
if info.exists
" Get function definition
redir => output
silent execute 'function ' . a:funcname
redir END
let lines = split(output, '\n')
let info.definition = lines
" Parse arguments
if len(lines) > 0
let sig = lines[0]
let info.signature = sig
endif
endif
return info
endfunction
echo InspectFunction('strftime')
Discover Available Methods:
function! GetMethods(dict)
let methods = []
for [key, Val] in items(a:dict)
if type(Val) == v:t_func
call add(methods, key)
endif
endfor
return sort(methods)
endfunction
" Usage
let obj = copy(Dog)
echo GetMethods(obj)
18.4 Performance Optimization
18.4.1 Profiling VimScript
" Profile a script
:profile start profile.log
:profile func *
:profile file *
" Your operations here
source myscript.vim
:profile pause
:noautocmd qall!
Profiling Specific Functions:
function! ProfiledFunction()
let start = reltime()
" Your code here
for i in range(10000)
let x = i * 2
endfor
let elapsed = reltimefloat(reltime(start))
echo printf('Elapsed: %.6f seconds', elapsed)
endfunction
18.4.2 Optimization Techniques
Use dict Functions for Methods:
" Slow - searches for 'self'
function! MyObj.method()
echo self.value
endfunction
" Fast - knows it's a dict method
function! MyObj.method() dict
echo self.value
endfunction
Avoid Repeated Lookups:
" Slow
for i in range(1000)
let x = g:my_complex_dict.deep.nested.value
endfor
" Fast - cache the lookup
let cached = g:my_complex_dict.deep.nested.value
for i in range(1000)
let x = cached
endfor
Use += for String Concatenation
(Carefully):
" Slower for many iterations (creates many intermediate strings)
let result = ''
for i in range(1000)
let result = result . i
endfor
" Faster - build list then join
let parts = []
for i in range(1000)
call add(parts, i)
endfor
let result = join(parts, '')
Lazy Evaluation:
" Evaluate only when needed
let g:lazy_value = ''
function! GetLazyValue()
if empty(g:lazy_value)
let g:lazy_value = ExpensiveComputation()
endif
return g:lazy_value
endfunction
18.4.3 Avoiding Common Performance Pitfalls
" BAD: O(n²) complexity
function! RemoveDuplicates_Slow(list)
let result = []
for item in a:list
if index(result, item) == -1
call add(result, item)
endif
endfor
return result
endfunction
" GOOD: O(n) using dictionary
function! RemoveDuplicates_Fast(list)
let seen = {}
let result = []
for item in a:list
if !has_key(seen, item)
let seen[item] = 1
call add(result, item)
endif
endfor
return result
endfunction
" BEST: Use built-in
function! RemoveDuplicates_Best(list)
return uniq(sort(copy(a:list)))
endfunction
18.5 Error Handling and Debugging
18.5.1 Advanced Error Handling
" Custom exception types
function! ThrowTyped(type, message)
throw a:type . ': ' . a:message
endfunction
function! ParseConfig(file)
if !filereadable(a:file)
call ThrowTyped('FileNotFound', a:file)
endif
let lines = readfile(a:file)
if empty(lines)
call ThrowTyped('EmptyFile', a:file)
endif
" Parse logic...
return {'status': 'ok'}
endfunction
" Usage with specific error handling
try
let config = ParseConfig('config.txt')
catch /^FileNotFound:/
echo 'Config file not found, using defaults'
let config = GetDefaultConfig()
catch /^EmptyFile:/
echo 'Config file is empty'
let config = {}
catch
echo 'Unknown error: ' . v:exception
let config = {}
endtry
18.5.2 Assertion Framework
let g:assert_enabled = 1
function! Assert(condition, message)
if !g:assert_enabled
return
endif
if !a:condition
let stack = expand('<sfile>')
throw 'AssertionError: ' . a:message . ' at ' . stack
endif
endfunction
function! AssertEqual(expected, actual)
let msg = printf('Expected %s but got %s',
\ string(a:expected), string(a:actual))
call Assert(a:expected == a:actual, msg)
endfunction
function! AssertType(value, expected_type)
let actual = type(a:value)
let msg = printf('Expected type %d but got %d', a:expected_type, actual)
call Assert(actual == a:expected_type, msg)
endfunction
" Usage
function! Divide(a, b)
call Assert(a:b != 0, 'Division by zero')
return a:a / a:b
endfunction
18.5.3 Debug Utilities
" Debug logger
let s:debug_file = expand('~/vim_debug.log')
function! DebugLog(...)
if !exists('g:debug_mode') || !g:debug_mode
return
endif
let timestamp = strftime('%Y-%m-%d %H:%M:%S')
let message = join(a:000, ' ')
let line = printf('[%s] %s', timestamp, message)
call writefile([line], s:debug_file, 'a')
endfunction
" Variable inspector
function! Inspect(var)
echo '=== Variable Inspection ==='
echo 'Type: ' . type(a:var)
echo 'Value: ' . string(a:var)
if type(a:var) == v:t_dict
echo 'Keys: ' . string(keys(a:var))
elseif type(a:var) == v:t_list
echo 'Length: ' . len(a:var)
endif
endfunction
" Stack trace
function! GetStackTrace()
let stack = []
let level = 0
while 1
try
let info = 'level ' . level . ': ' . expand('<sfile>')
call add(stack, info)
let level += 1
catch
break
endtry
endwhile
return stack
endfunction
18.6 Advanced Text Processing
18.6.1 Complex Substitutions
" Substitution with expression
function! IncrementNumbers()
let counter = 0
%s/\d\+/\=counter + (counter += 1)/g
endfunction
" Context-aware replacement
function! SmartReplace()
" Different replacement based on context
%s/\<\(\w\+\)\>/\=s:TransformWord(submatch(1))/g
endfunction
function! s:TransformWord(word)
if a:word =~ '^\u' " Starts with uppercase
return toupper(a:word)
else
return tolower(a:word)
endif
endfunction
" Multi-pattern replacement
function! ReplaceMultiple(patterns)
for [pattern, replacement] in items(a:patterns)
execute '%s/' . pattern . '/' . replacement . '/ge'
endfor
endfunction
call ReplaceMultiple({
\ 'TODO': 'DONE',
\ 'FIXME': 'FIXED',
\ 'XXX': 'RESOLVED'
\ })
18.6.2 Text Object Manipulation
" Extract all matches from buffer
function! ExtractMatches(pattern)
let matches = []
let pos = [1, 1]
while 1
let match = searchpos(a:pattern, 'W', 0, 0, 'n')
if match == [0, 0]
break
endif
call setpos('.', [0] + match + [0])
let text = matchstr(getline('.'), a:pattern)
call add(matches, text)
" Move past this match
call search(a:pattern, 'W')
endwhile
return matches
endfunction
" Parse structured text
function! ParseKeyValue(text)
let result = {}
for line in split(a:text, '\n')
let match = matchlist(line, '^\s*\(\w\+\)\s*=\s*\(.*\)\s*$')
if !empty(match)
let result[match[1]] = match[2]
endif
endfor
return result
endfunction
18.6.3 Advanced Buffer Manipulation
" Safe buffer modification
function! ModifyBuffer(bufnr, func)
let saved_view = winsaveview()
let saved_buf = bufnr('%')
try
execute 'buffer ' . a:bufnr
call a:func()
finally
execute 'buffer ' . saved_buf
call winrestview(saved_view)
endtry
endfunction
" Batch operations on lines
function! ProcessLines(start, end, func)
let lines = getline(a:start, a:end)
let new_lines = map(lines, a:func)
call setline(a:start, new_lines)
endfunction
" Usage: uppercase lines 1-10
call ProcessLines(1, 10, {idx, val -> toupper(val)})
" Virtual text manipulation (conceptual)
function! TransformSelection(func)
let [start_line, start_col] = getpos("'<")[1:2]
let [end_line, end_col] = getpos("'>")[1:2]
if start_line == end_line
" Single line
let line = getline(start_line)
let before = line[:start_col-2]
let selection = line[start_col-1:end_col-1]
let after = line[end_col:]
let transformed = before . a:func(selection) . after
call setline(start_line, transformed)
else
" Multi-line - more complex
" Left as exercise...
endif
endfunction
18.7 Plugin Development Patterns
18.7.1 Plugin Structure
" plugin/myplugin.vim - Entry point
if exists('g:loaded_myplugin')
finish
endif
let g:loaded_myplugin = 1
" Set defaults
if !exists('g:myplugin_option')
let g:myplugin_option = 'default'
endif
" Define commands
command! MyPluginCommand call myplugin#main#run()
" Define mappings
nnoremap <Plug>(myplugin-action) :call myplugin#action#execute()<CR>
if !exists('g:myplugin_no_mappings')
nmap <leader>mp <Plug>(myplugin-action)
endif
" autoload/myplugin/main.vim - Core functionality
let s:save_cpo = &cpo
set cpo&vim
function! myplugin#main#run()
" Main logic
echo "Running MyPlugin"
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
18.7.2 Configuration System
" Flexible configuration
let s:default_config = {
\ 'enabled': 1,
\ 'timeout': 5000,
\ 'callbacks': {
\ 'on_success': function('s:DefaultSuccess'),
\ 'on_error': function('s:DefaultError'),
\ }
\ }
function! s:GetConfig()
if !exists('g:myplugin_config')
let g:myplugin_config = {}
endif
return extend(copy(s:default_config), g:myplugin_config)
endfunction
function! s:ValidateConfig(config)
call Assert(has_key(a:config, 'enabled'), 'Missing enabled key')
call AssertType(a:config.enabled, v:t_number)
" More validation...
endfunction
" User configuration
let g:myplugin_config = {
\ 'timeout': 10000,
\ 'callbacks': {
\ 'on_success': function('MyCustomSuccess')
\ }
\ }
18.7.3 Event System
" Custom event dispatcher
let s:event_handlers = {}
function! myplugin#event#on(event, handler)
if !has_key(s:event_handlers, a:event)
let s:event_handlers[a:event] = []
endif
call add(s:event_handlers[a:event], a:handler)
endfunction
function! myplugin#event#emit(event, data)
if !has_key(s:event_handlers, a:event)
return
endif
for Handler in s:event_handlers[a:event]
try
call Handler(a:data)
catch
call s:LogError('Event handler failed: ' . v:exception)
endtry
endfor
endfunction
" Usage
call myplugin#event#on('file_saved', function('MyHandler'))
call myplugin#event#emit('file_saved', {'file': expand('%')})
18.8 Asynchronous Operations (Vim 8+)
18.8.1 Jobs and Channels
" Simple job execution
function! RunAsync(cmd, callback)
let options = {
\ 'out_cb': {channel, msg -> a:callback(msg)},
\ 'err_cb': {channel, msg -> s:ErrorHandler(msg)},
\ 'exit_cb': {job, status -> s:ExitHandler(status)}
\ }
let job = job_start(a:cmd, options)
return job
endfunction
" Example: Async grep
function! AsyncGrep(pattern, callback)
let cmd = ['grep', '-r', a:pattern, '.']
call RunAsync(cmd, a:callback)
endfunction
function! HandleGrepResult(line)
echo 'Found: ' . a:line
endfunction
call AsyncGrep('TODO', function('HandleGrepResult'))
18.8.2 Progress Tracking
let s:async_tasks = {}
function! TrackAsyncTask(name, cmd)
let task = {
\ 'name': a:name,
\ 'status': 'running',
\ 'output': [],
\ 'start_time': localtime()
\ }
let options = {
\ 'out_cb': {ch, msg -> s:TaskOutput(a:name, msg)},
\ 'exit_cb': {job, status -> s:TaskComplete(a:name, status)}
\ }
let task.job = job_start(a:cmd, options)
let s:async_tasks[a:name] = task
return task
endfunction
function! s:TaskOutput(name, msg)
call add(s:async_tasks[a:name].output, a:msg)
call s:UpdateProgress(a:name)
endfunction
function! s:TaskComplete(name, status)
let s:async_tasks[a:name].status = a:status == 0 ? 'success' : 'failed'
let s:async_tasks[a:name].end_time = localtime()
call s:UpdateProgress(a:name)
endfunction
function! s:UpdateProgress(name)
" Update UI to show progress
redraw
echo 'Task ' . a:name . ': ' . s:async_tasks[a:name].status
endfunction
18.9 Testing VimScript
18.9.1 Unit Testing Framework
" Simple test framework
let s:tests = []
let s:test_results = {'passed': 0, 'failed': 0}
function! Test(name, func)
call add(s:tests, {'name': a:name, 'func': a:func})
endfunction
function! RunTests()
for test in s:tests
echo 'Running: ' . test.name
try
call test.func()
let s:test_results.passed += 1
echo ' ✓ PASSED'
catch
let s:test_results.failed += 1
echo ' ✗ FAILED: ' . v:exception
endtry
endfor
echo printf("\nResults: %d passed, %d failed",
\ s:test_results.passed, s:test_results.failed)
endfunction
" Example tests
call Test('Addition works', {-> AssertEqual(4, 2 + 2)})
call Test('Strings concat', {-> AssertEqual('ab', 'a' . 'b')})
call RunTests()
18.9.2 Mock Objects
" Mock system for testing
function! MockFunction(name, return_value)
let s:mocks[a:name] = {
\ 'calls': [],
\ 'return_value': a:return_value
\ }
execute printf('function! %s(...)\n return s:CallMock("%s", a:000)\nendfunction',
\ a:name, a:name)
endfunction
function! s:CallMock(name, args)
call add(s:mocks[a:name].calls, a:args)
return s:mocks[a:name].return_value
endfunction
function! VerifyMockCalled(name, times)
let actual = len(s:mocks[a:name].calls)
call AssertEqual(a:times, actual)
endfunction
" Usage
let s:mocks = {}
call MockFunction('ExpensiveFunc', 42)
" Test code that calls ExpensiveFunc
let result = ExpensiveFunc(1, 2, 3)
call VerifyMockCalled('ExpensiveFunc', 1)
✅ End of Chapter 18: Advanced VimScript
You’ve now mastered advanced VimScript techniques including closures, OOP patterns, metaprogramming, performance optimization, asynchronous operations, and testing strategies. These skills enable you to build sophisticated, maintainable plugins and deeply customize your Vim environment. The next chapter will explore Lua integration in Neovim, offering a modern alternative with enhanced capabilities.
Chapter 19: Writing VimScript Plugins
This chapter guides you through the complete process of creating, structuring, and distributing professional VimScript plugins, from initial design to publication.
19.1 Plugin Architecture and Design Patterns
19.1.1 Standard Plugin Directory Structure
" Recommended plugin layout:
"
" myplugin/
" ├── plugin/ " Loaded once on startup
" │ └── myplugin.vim
" ├── autoload/ " Lazy-loaded functionality
" │ ├── myplugin.vim
" │ └── myplugin/
" │ ├── core.vim
" │ ├── utils.vim
" │ └── ui.vim
" ├── ftplugin/ " Filetype-specific code
" │ └── python.vim
" ├── syntax/ " Syntax definitions
" │ └── myplugin.vim
" ├── doc/ " Documentation
" │ └── myplugin.txt
" ├── after/ " Loaded after other plugins
" │ └── plugin/
" ├── colors/ " Color schemes
" ├── compiler/ " Compiler definitions
" ├── indent/ " Indentation rules
" └── README.md
19.1.2 Plugin Entry Point Pattern
" plugin/myplugin.vim - Main entry point
" Guard against multiple loading
if exists('g:loaded_myplugin')
finish
endif
let g:loaded_myplugin = 1
" Save user's cpoptions and set to vim default
let s:save_cpo = &cpo
set cpo&vim
" Check version compatibility
if v:version < 800
echohl WarningMsg
echomsg 'MyPlugin requires Vim 8.0 or later'
echohl None
finish
endif
" Define default configuration
if !exists('g:myplugin_enabled')
let g:myplugin_enabled = 1
endif
if !exists('g:myplugin_mappings')
let g:myplugin_mappings = {}
endif
if !exists('g:myplugin_options')
let g:myplugin_options = {
\ 'auto_save': 0,
\ 'timeout': 3000,
\ 'debug': 0
\ }
endif
" Define user commands
command! -nargs=0 MyPluginEnable call myplugin#enable()
command! -nargs=0 MyPluginDisable call myplugin#disable()
command! -nargs=? MyPluginToggle call myplugin#toggle(<f-args>)
command! -nargs=* -complete=customlist,myplugin#complete
\ MyPluginAction call myplugin#action(<f-args>)
" Define <Plug> mappings (no default keys, let user choose)
nnoremap <silent> <Plug>(myplugin-do-something)
\ :<C-u>call myplugin#do_something()<CR>
vnoremap <silent> <Plug>(myplugin-visual-action)
\ :<C-u>call myplugin#visual_action()<CR>
" Optional default mappings (with opt-out mechanism)
if !exists('g:myplugin_no_default_mappings')
if empty(maparg('<leader>mp', 'n'))
nmap <leader>mp <Plug>(myplugin-do-something)
endif
endif
" Auto-commands for initialization
augroup MyPluginInit
autocmd!
autocmd VimEnter * call myplugin#on_vim_enter()
autocmd BufEnter * call myplugin#on_buf_enter()
augroup END
" Restore cpoptions
let &cpo = s:save_cpo
unlet s:save_cpo
19.1.3 Autoload Pattern for Performance
" autoload/myplugin.vim - Lazy-loaded core functionality
let s:save_cpo = &cpo
set cpo&vim
" Script-local variables (shared across all autoload functions)
let s:plugin_name = 'MyPlugin'
let s:initialized = 0
" Private initialization function
function! s:initialize()
if s:initialized
return
endif
" Perform expensive setup only once
let s:cache = {}
let s:state = {
\ 'enabled': g:myplugin_enabled,
\ 'buffers': {}
\ }
let s:initialized = 1
if g:myplugin_options.debug
echom s:plugin_name . ' initialized'
endif
endfunction
" Public API functions
function! myplugin#enable()
call s:initialize()
let s:state.enabled = 1
call s:apply_to_all_buffers()
echom s:plugin_name . ' enabled'
endfunction
function! myplugin#disable()
let s:state.enabled = 0
call s:cleanup_all_buffers()
echom s:plugin_name . ' disabled'
endfunction
function! myplugin#toggle(...)
if s:state.enabled
call myplugin#disable()
else
call myplugin#enable()
endif
endfunction
function! myplugin#do_something()
call s:initialize()
if !s:state.enabled
echohl WarningMsg
echo s:plugin_name . ' is disabled'
echohl None
return
endif
" Main functionality
let result = s:perform_action()
call s:update_state(result)
return result
endfunction
" Completion function for commands
function! myplugin#complete(ArgLead, CmdLine, CursorPos)
let options = ['enable', 'disable', 'toggle', 'status', 'reset']
return filter(copy(options), 'v:val =~ "^" . a:ArgLead')
endfunction
" Event handlers
function! myplugin#on_vim_enter()
call s:initialize()
if s:state.enabled
call s:setup_global_features()
endif
endfunction
function! myplugin#on_buf_enter()
if !s:initialized || !s:state.enabled
return
endif
let bufnr = bufnr('%')
" Track buffer if not already tracked
if !has_key(s:state.buffers, bufnr)
call s:setup_buffer(bufnr)
endif
endfunction
" Private helper functions
function! s:perform_action()
" Delegate to specialized modules
return myplugin#core#process(getline(1, '$'))
endfunction
function! s:setup_buffer(bufnr)
let s:state.buffers[a:bufnr] = {
\ 'initialized': localtime(),
\ 'modified': 0
\ }
" Set buffer-local options
call setbufvar(a:bufnr, '&omnifunc', 'myplugin#complete')
" Create buffer-local commands
call setbufvar(a:bufnr, 'myplugin_buffer_cmd',
\ 'command! -buffer MyPluginLocal call myplugin#buffer_action()')
execute 'au BufEnter <buffer=' . a:bufnr . '> ' .
\ getbufvar(a:bufnr, 'myplugin_buffer_cmd')
endfunction
function! s:apply_to_all_buffers()
for bufnr in range(1, bufnr('$'))
if buflisted(bufnr)
call s:setup_buffer(bufnr)
endif
endfor
endfunction
function! s:cleanup_all_buffers()
for bufnr in keys(s:state.buffers)
call s:cleanup_buffer(bufnr)
endfor
let s:state.buffers = {}
endfunction
function! s:cleanup_buffer(bufnr)
" Remove buffer-local settings
silent! call setbufvar(a:bufnr, '&omnifunc', '')
silent! execute 'au! BufEnter <buffer=' . a:bufnr . '>'
endfunction
function! s:update_state(result)
" Update internal state based on action result
if type(a:result) == v:t_dict && has_key(a:result, 'cache')
let s:cache = extend(s:cache, a:result.cache)
endif
endfunction
function! s:setup_global_features()
" Setup features that affect all buffers
if has('timers')
let s:timer = timer_start(1000, function('s:periodic_check'),
\ {'repeat': -1})
endif
endfunction
function! s:periodic_check(timer)
" Periodic maintenance task
if s:state.enabled
call s:cleanup_old_cache()
endif
endfunction
function! s:cleanup_old_cache()
let cutoff = localtime() - 3600 " 1 hour ago
call filter(s:cache, 'v:val.timestamp > ' . cutoff)
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
19.1.4 Modular Design with Submodules
" autoload/myplugin/core.vim - Core processing logic
let s:save_cpo = &cpo
set cpo&vim
function! myplugin#core#process(lines)
let processed = []
for line in a:lines
let result = s:process_line(line)
if !empty(result)
call add(processed, result)
endif
endfor
return {
\ 'lines': processed,
\ 'count': len(processed),
\ 'cache': s:build_cache(processed)
\ }
endfunction
function! s:process_line(line)
" Use utility functions from another module
let cleaned = myplugin#utils#strip_whitespace(a:line)
if empty(cleaned)
return ''
endif
return s:transform(cleaned)
endfunction
function! s:transform(text)
" Actual transformation logic
return toupper(a:text)
endfunction
function! s:build_cache(lines)
let cache = {}
for line in a:lines
let key = s:compute_key(line)
let cache[key] = {
\ 'text': line,
\ 'timestamp': localtime()
\ }
endfor
return cache
endfunction
function! s:compute_key(text)
" Simple hash function
let hash = 0
for char in split(a:text, '\zs')
let hash = (hash * 31 + char2nr(char)) % 1000000
endfor
return string(hash)
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
" autoload/myplugin/utils.vim - Utility functions
let s:save_cpo = &cpo
set cpo&vim
function! myplugin#utils#strip_whitespace(text)
return substitute(a:text, '^\s*\|\s*$', '', 'g')
endfunction
function! myplugin#utils#is_empty_line(text)
return a:text =~# '^\s*$'
endfunction
function! myplugin#utils#ensure_list(value)
return type(a:value) == v:t_list ? a:value : [a:value]
endfunction
function! myplugin#utils#safe_execute(cmd)
try
execute a:cmd
return {'success': 1}
catch
return {
\ 'success': 0,
\ 'error': v:exception,
\ 'throwpoint': v:throwpoint
\ }
endtry
endfunction
function! myplugin#utils#debounce(func, delay)
" Returns a debounced version of the function
if !has('timers')
return a:func
endif
let context = {
\ 'func': a:func,
\ 'delay': a:delay,
\ 'timer': -1
\ }
function! context.call(...) dict
if self.timer != -1
call timer_stop(self.timer)
endif
let Args = a:000
let self.timer = timer_start(self.delay,
\ {_ -> call(self.func, Args)})
endfunction
return context.call
endfunction
" File system utilities
function! myplugin#utils#read_json(filepath)
if !filereadable(a:filepath)
throw 'File not found: ' . a:filepath
endif
let content = join(readfile(a:filepath), "\n")
if exists('*json_decode')
return json_decode(content)
else
" Fallback for older Vim versions
return eval(content)
endif
endfunction
function! myplugin#utils#write_json(filepath, data)
let content = json_encode(a:data)
call writefile(split(content, "\n"), a:filepath)
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
19.2 Configuration and Options Management
19.2.1 Flexible Configuration System
" autoload/myplugin/config.vim - Configuration management
let s:save_cpo = &cpo
set cpo&vim
" Default configuration
let s:defaults = {
\ 'enable': 1,
\ 'auto_start': 1,
\ 'mappings': {
\ 'toggle': '<leader>mt',
\ 'execute': '<leader>me',
\ 'reset': '<leader>mr'
\ },
\ 'features': {
\ 'auto_save': 0,
\ 'notifications': 1,
\ 'syntax_highlight': 1
\ },
\ 'paths': {
\ 'cache': expand('~/.cache/myplugin'),
\ 'config': expand('~/.config/myplugin')
\ },
\ 'hooks': {
\ 'pre_process': v:null,
\ 'post_process': v:null,
\ 'on_error': v:null
\ }
\ }
" Merge user config with defaults
function! myplugin#config#get()
if !exists('s:config')
let user_config = exists('g:myplugin_config') ?
\ g:myplugin_config : {}
let s:config = s:deep_merge(s:defaults, user_config)
call s:validate_config(s:config)
endif
return s:config
endfunction
function! myplugin#config#set(key, value)
let config = myplugin#config#get()
" Support nested keys: 'features.auto_save'
let keys = split(a:key, '\.')
let current = config
for key in keys[:-2]
if !has_key(current, key)
let current[key] = {}
endif
let current = current[key]
endfor
let current[keys[-1]] = a:value
" Re-validate after change
call s:validate_config(config)
" Trigger update hooks
call s:on_config_change(a:key, a:value)
endfunction
function! myplugin#config#get_value(key, ...)
let config = myplugin#config#get()
let default = a:0 > 0 ? a:1 : v:null
let keys = split(a:key, '\.')
let value = config
for key in keys
if type(value) != v:t_dict || !has_key(value, key)
return default
endif
let value = value[key]
endfor
return value
endfunction
function! myplugin#config#reset()
unlet! s:config
echom 'MyPlugin configuration reset to defaults'
endfunction
" Deep merge two dictionaries
function! s:deep_merge(base, override)
let result = copy(a:base)
for [key, value] in items(a:override)
if type(value) == v:t_dict && type(get(result, key, '')) == v:t_dict
let result[key] = s:deep_merge(result[key], value)
else
let result[key] = value
endif
endfor
return result
endfunction
" Validate configuration
function! s:validate_config(config)
" Type checking
call s:assert_type(a:config.enable, v:t_number, 'enable')
call s:assert_type(a:config.mappings, v:t_dict, 'mappings')
call s:assert_type(a:config.features, v:t_dict, 'features')
" Value constraints
if a:config.features.auto_save
if !exists('*timer_start')
throw 'auto_save requires Vim with +timers'
endif
endif
" Path validation
for [key, path] in items(a:config.paths)
if !isdirectory(fnamemodify(path, ':h'))
echohl WarningMsg
echom 'Creating directory: ' . path
echohl None
call mkdir(path, 'p')
endif
endfor
endfunction
function! s:assert_type(value, expected_type, name)
let actual_type = type(a:value)
if actual_type != a:expected_type
throw printf('Config error: %s must be type %d, got %d',
\ a:name, a:expected_type, actual_type)
endif
endfunction
" Configuration change hooks
function! s:on_config_change(key, value)
if a:key == 'features.auto_save'
if a:value
call myplugin#features#enable_autosave()
else
call myplugin#features#disable_autosave()
endif
endif
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
19.2.2 User Configuration Examples
" User's vimrc/init.vim configuration examples
" Simple configuration
let g:myplugin_config = {
\ 'enable': 1,
\ 'features': {'auto_save': 1}
\ }
" Advanced configuration with hooks
let g:myplugin_config = {
\ 'enable': 1,
\ 'mappings': {
\ 'toggle': '<F5>',
\ 'execute': '<F6>'
\ },
\ 'features': {
\ 'auto_save': 1,
\ 'notifications': 1
\ },
\ 'hooks': {
\ 'pre_process': function('MyPreProcess'),
\ 'post_process': function('MyPostProcess')
\ }
\ }
function! MyPreProcess(data)
" Custom preprocessing
echo 'Pre-processing: ' . string(a:data)
return a:data
endfunction
function! MyPostProcess(result)
" Custom post-processing
echo 'Result: ' . string(a:result)
endfunction
19.3 Error Handling and User Feedback
19.3.1 Comprehensive Error Handling
" autoload/myplugin/error.vim - Error handling system
let s:save_cpo = &cpo
set cpo&vim
let s:error_log = []
let s:max_log_size = 100
" Error levels
let s:ERROR = 0
let s:WARNING = 1
let s:INFO = 2
function! myplugin#error#throw(message, ...)
let level = a:0 > 0 ? a:1 : s:ERROR
let context = a:0 > 1 ? a:2 : {}
let error = {
\ 'message': a:message,
\ 'level': level,
\ 'timestamp': strftime('%Y-%m-%d %H:%M:%S'),
\ 'context': context,
\ 'stacktrace': s:get_stacktrace()
\ }
call s:log_error(error)
" Call user hook if configured
let hook = myplugin#config#get_value('hooks.on_error', v:null)
if hook != v:null && type(hook) == v:t_func
call hook(error)
endif
" Display to user based on level
call s:display_error(error)
" Throw exception for ERROR level
if level == s:ERROR
throw 'MyPlugin: ' . a:message
endif
endfunction
function! myplugin#error#wrap(func, ...)
" Wraps a function with error handling
let context = a:0 > 0 ? a:1 : {}
try
return call(a:func, a:000[1:])
catch
call myplugin#error#throw(
\ 'Error in ' . string(a:func) . ': ' . v:exception,
\ s:ERROR,
\ context
\ )
endtry
endfunction
function! myplugin#error#get_log()
return copy(s:error_log)
endfunction
function! myplugin#error#clear_log()
let s:error_log = []
endfunction
function! myplugin#error#show_log()
if empty(s:error_log)
echo 'No errors logged'
return
endif
for entry in s:error_log[-10:] " Show last 10
echo printf('[%s] %s: %s',
\ entry.timestamp,
\ s:level_name(entry.level),
\ entry.message)
endfor
endfunction
function! s:log_error(error)
call add(s:error_log, a:error)
" Trim log if too large
if len(s:error_log) > s:max_log_size
let s:error_log = s:error_log[-s:max_log_size:]
endif
" Write to file if debug mode
if myplugin#config#get_value('debug', 0)
call s:write_to_file(a:error)
endif
endfunction
function! s:display_error(error)
let level_names = ['ERROR', 'WARNING', 'INFO']
let colors = ['ErrorMsg', 'WarningMsg', 'MoreMsg']
execute 'echohl ' . colors[a:error.level]
echom '[MyPlugin ' . level_names[a:error.level] . '] ' . a:error.message
echohl None
endfunction
function! s:get_stacktrace()
let stack = []
let level = 1
while 1
let location = 'level' . level
try
" This will error when we run out of stack
let info = expand('<sfile>')
call add(stack, info)
let level += 1
catch
break
endtry
if level > 20 " Safety limit
break
endif
endwhile
return stack
endfunction
function! s:write_to_file(error)
let logfile = myplugin#config#get_value('paths.cache') . '/error.log'
let line = printf('[%s] %s: %s',
\ a:error.timestamp,
\ s:level_name(a:error.level),
\ a:error.message)
call writefile([line], logfile, 'a')
endfunction
function! s:level_name(level)
return ['ERROR', 'WARNING', 'INFO'][a:level]
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
19.3.2 User Notifications
" autoload/myplugin/notify.vim - Notification system
let s:save_cpo = &cpo
set cpo&vim
function! myplugin#notify#info(message)
if !myplugin#config#get_value('features.notifications', 1)
return
endif
echohl MoreMsg
echo '[MyPlugin] ' . a:message
echohl None
endfunction
function! myplugin#notify#warn(message)
echohl WarningMsg
echom '[MyPlugin Warning] ' . a:message
echohl None
endfunction
function! myplugin#notify#error(message)
echohl ErrorMsg
echom '[MyPlugin Error] ' . a:message
echohl None
endfunction
function! myplugin#notify#progress(current, total, message)
" Show progress bar
let percent = (a:current * 100) / a:total
let bar_width = 30
let filled = (bar_width * a:current) / a:total
let bar = '[' . repeat('=', filled) . repeat(' ', bar_width - filled) . ']'
redraw
echo printf('%s %d%% - %s', bar, percent, a:message)
endfunction
function! myplugin#notify#confirm(message, ...)
let choices = a:0 > 0 ? a:1 : ['Yes', 'No']
let default = a:0 > 1 ? a:2 : 1
let choice_str = join(map(copy(choices),
\ '(v:key + 1) . ". " . v:val'), "\n")
let response = inputlist([a:message, choice_str])
if response < 1 || response > len(choices)
return default - 1
endif
return response - 1
endfunction
let &cpo = s:save_cpo
unlet s:save_cpo
19.4 Testing Your Plugin
19.4.1 Unit Testing Framework
" test/test_runner.vim - Simple test framework
let s:tests = []
let s:suite_name = ''
function! Test(name, func)
call add(s:tests, {
\ 'name': s:suite_name . ' - ' . a:name,
\ 'func': a:func
\ })
endfunction
function! Suite(name)
let s:suite_name = a:name
endfunction
function! RunAllTests()
let passed = 0
let failed = 0
let errors = []
echo 'Running ' . len(s:tests) . ' tests...'
echo '=================================='
for test in s:tests
try
call test.func()
let passed += 1
echo '✓ ' . test.name
catch
let failed += 1
echo '✗ ' . test.name
call add(errors, {
\ 'test': test.name,
\ 'error': v:exception
\ })
endtry
endfor
echo '=================================='
echo printf('Results: %d passed, %d failed', passed, failed)
if !empty(errors)
echo "\nFailures:"
for err in errors
echo err.test . ': ' . err.error
endfor
endif
return failed == 0
endfunction
" Assertion functions
function! AssertEqual(expected, actual, ...)
let msg = a:0 > 0 ? a:1 : 'Assertion failed'
if a:expected != a:actual
throw msg . printf(': expected %s, got %s',
\ string(a:expected), string(a:actual))
endif
endfunction
function! AssertTrue(condition, ...)
let msg = a:0 > 0 ? a:1 : 'Expected true'
if !a:condition
throw msg
endif
endfunction
function! AssertFalse(condition, ...)
let msg = a:0 > 0 ? a:1 : 'Expected false'
if a:condition
throw msg
endif
endfunction
function! AssertThrows(func, ...)
let pattern = a:0 > 0 ? a:1 : '.*'
let threw = 0
try
call a:func()
catch
if v:exception =~ pattern
let threw = 1
else
throw 'Wrong exception: expected /' . pattern .
\ '/, got: ' . v:exception
endif
endtry
if !threw
throw 'Expected function to throw exception'
endif
endfunction
19.4.2 Example Test Suite
" test/test_myplugin.vim
source test/test_runner.vim
source plugin/myplugin.vim
call Suite('Core Functionality')
call Test('initialization works', {->
\ AssertTrue(exists('g:loaded_myplugin'))
\ })
call Test('config merges correctly', {->
\ AssertEqual(1, myplugin#config#get_value('enable'))
\ })
call Test('utils strip whitespace', {->
\ AssertEqual('hello', myplugin#utils#strip_whitespace(' hello '))
\ })
call Suite('Error Handling')
call Test('error thrown for invalid input', {->
\ AssertThrows({-> myplugin#process('')}, 'Invalid')
\ })
call Suite('Processing')
call Test('processes lines correctly', {->
\ let result = myplugin#core#process(['hello', 'world'])
\,
\ AssertEqual(2, result.count)
\ })
" Run all tests
if RunAllTests()
quit
else
cquit
endif
19.5 Documentation
19.5.1 Writing Help Documentation
" doc/myplugin.txt - Plugin documentation
*myplugin.txt* Description of MyPlugin
Author: Your Name <email@example.com>
License: MIT
Version: 1.0.0
==============================================================================
CONTENTS *myplugin-contents*
1. Introduction ......................... |myplugin-intro|
2. Installation ......................... |myplugin-installation|
3. Usage ................................ |myplugin-usage|
4. Configuration ........................ |myplugin-config|
5. Commands ............................. |myplugin-commands|
6. Mappings ............................. |myplugin-mappings|
7. Functions ............................ |myplugin-functions|
8. FAQ .................................. |myplugin-faq|
9. License .............................. |myplugin-license|
==============================================================================
1. Introduction *myplugin-intro*
MyPlugin is a powerful tool for doing X, Y, and Z in Vim.
Features:~
* Feature 1
* Feature 2
* Feature 3
Requirements:~
* Vim 8.0+ or Neovim 0.5+
* (Optional) Feature X for advanced functionality
==============================================================================
2. Installation *myplugin-installation*
Using vim-plug:~
>
Plug 'username/myplugin'
<
Using packer.nvim:~
>
use 'username/myplugin'
<
Manual installation:~
Copy the plugin files to your Vim directory:
>
~/.vim/
├── plugin/myplugin.vim
├── autoload/myplugin.vim
└── doc/myplugin.txt
<
After installation, run: >
:helptags ~/.vim/doc
<
==============================================================================
3. Usage *myplugin-usage*
Basic usage example:~
>
:MyPluginEnable
:MyPluginAction process
<
For more examples, see |myplugin-examples|.
==============================================================================
4. Configuration *myplugin-config*
*g:myplugin_config*
Configure MyPlugin by setting this variable in your vimrc:
>
let g:myplugin_config = {
\ 'enable': 1,
\ 'features': {
\ 'auto_save': 0,
\ 'notifications': 1
\ },
\ 'mappings': {
\ 'toggle': '<leader>mt'
\ }
\ }
<
Options:~
enable Enable/disable plugin (default: 1)
features.auto_save Auto-save on changes (default: 0)
features.notifications Show notifications (default: 1)
mappings.toggle Key mapping for toggle (default: <leader>mt)
==============================================================================
5. Commands *myplugin-commands*
:MyPluginEnable *:MyPluginEnable*
Enable the plugin.
:MyPluginDisable *:MyPluginDisable*
Disable the plugin.
:MyPluginToggle *:MyPluginToggle*
Toggle plugin on/off.
:MyPluginAction {action} *:MyPluginAction*
Execute specified action. Available actions:
- process: Process current buffer
- reset: Reset to defaults
- status: Show current status
==============================================================================
6. Mappings *myplugin-mappings*
*<Plug>(myplugin-toggle)*
<Plug>(myplugin-toggle)
Toggle the plugin functionality.
Default mapping: <leader>mt (can be changed via config)
Example custom mapping: >
nmap <F5> <Plug>(myplugin-toggle)
<
*<Plug>(myplugin-execute)*
<Plug>(myplugin-execute)
Execute main plugin action.
Example: >
nmap <leader>me <Plug>(myplugin-execute)
<
==============================================================================
7. Functions *myplugin-functions*
myplugin#enable() *myplugin#enable()*
Enable the plugin programmatically.
Example: >
call myplugin#enable()
<
myplugin#config#set({key}, {value}) *myplugin#config#set()*
Set configuration value at runtime.
Parameters:~
{key} Configuration key (supports dot notation)
{value} New value
Example: >
call myplugin#config#set('features.auto_save', 1)
<
myplugin#config#get_value({key}, [{default}]) *myplugin#config#get_value()*
Get configuration value.
Parameters:~
{key} Configuration key
{default} Optional default if key not found
Returns: Configuration value or default
Example: >
let auto_save = myplugin#config#get_value('features.auto_save', 0)
<
==============================================================================
8. FAQ *myplugin-faq*
Q: The plugin doesn't work!
A: Make sure you have Vim 8.0+ and run :MyPluginEnable
Q: How do I disable default mappings?
A: Set this in your vimrc: >
let g:myplugin_no_default_mappings = 1
<
Q: Can I use custom hooks?
A: Yes! See |myplugin-config| for hook configuration.
==============================================================================
9. License *myplugin-license*
MIT License
Copyright (c) 2025 Your Name
Permission is hereby granted...
(full license text)
vim:tw=78:ts=8:ft=help:norl:
19.6 Publishing Your Plugin
19.6.1 Creating a README
# MyPlugin
A powerful Vim plugin for X, Y, and Z.
## Features
- ✨ Feature 1
- 🚀 Feature 2
- 🎯 Feature 3
## Installation
### Using [vim-plug](https://github.com/junegunn/vim-plug)
```vim
Plug 'username/myplugin'
### Using [packer.nvim](https://github.com/wbthomason/packer.nvim)
lua
use 'username/myplugin'
## Quick Start
vim
" Enable the plugin
:MyPluginEnable
" Execute action
:MyPluginAction process
## Configuration
vim
let g:myplugin_config = {
\ 'enable': 1,
\ 'features': {
\ 'auto_save': 0,
\ 'notifications': 1
\ }
\ }
For full configuration options, see [documentation](doc/myplugin.txt).
## Documentation
Full documentation is available via `:help myplugin` after installation.
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md).
## License
MIT License - see [LICENSE](LICENSE) for details.
### 19.6.2 Version Control Best Practices
```bash
# .gitignore for Vim plugins
*.swp
*.swo
*~
.DS_Store
tags
.netrwhist
# Test artifacts
test/tmp/
test/fixtures/
# Documentation generated files
doc/tags19.6.3 Semantic Versioning
" Maintain version in plugin
let g:myplugin_version = '1.2.3'
" Breaking changes: increment MAJOR (1.0.0 -> 2.0.0)
" New features: increment MINOR (1.0.0 -> 1.1.0)
" Bug fixes: increment PATCH (1.0.0 -> 1.0.1)
19.7 Advanced Plugin Patterns
19.7.1 Plugin Extensions and Hooks
" Allow other plugins to extend functionality
let g:myplugin_extensions = []
function! myplugin#register_extension(extension)
call add(g:myplugin_extensions, a:extension)
endfunction
function! myplugin#call_extensions(hook, ...)
for ext in g:myplugin_extensions
if has_key(ext, a:hook)
call call(ext[a:hook], a:000)
endif
endfor
endfunction
" Other plugins can register:
" call myplugin#register_extension({
" \ 'on_process': function('MyCustomProcessor')
" \ })
19.7.2 Lazy Loading Optimization
" plugin/myplugin.vim - Minimal loading
if exists('g:loaded_myplugin')
finish
endif
let g:loaded_myplugin = 1
" Only define commands, delay everything else
command! -nargs=0 MyPluginLoad call s:load_plugin()
function! s:load_plugin()
" Load actual functionality
runtime! autoload/myplugin.vim
call myplugin#initialize()
" Remove load command, add real commands
delcommand MyPluginLoad
command! -nargs=0 MyPluginEnable call myplugin#enable()
" ... other commands
endfunction
" Auto-load on specific events if desired
augroup MyPluginAutoLoad
autocmd!
autocmd FileType python,javascript call s:load_plugin()
augroup END
✅ End of Chapter 19: Writing VimScript Plugins
You now have comprehensive knowledge of plugin architecture, configuration systems, error handling, testing, documentation, and publication. These patterns will help you create professional, maintainable plugins that follow community best practices. The next chapter explores Lua programming for Neovim, offering modern alternatives to VimScript.
Chapter 20: Lua Basics for Neovim
This chapter introduces Lua programming in the context of Neovim, covering the fundamentals needed to write modern Neovim configurations and plugins using Lua instead of VimScript.
20.1 Why Lua in Neovim?
20.1.1 Advantages of Lua
Lua offers several benefits over VimScript:
Performance: Lua is significantly faster, especially for complex operations
Modern Language: First-class functions, proper scoping, better data structures
LuaJIT: Neovim uses LuaJIT for JIT compilation and FFI capabilities
Rich Ecosystem: Access to Lua libraries and better tooling
Cleaner Syntax: More readable and maintainable code
20.1.2 Neovim’s Lua Integration
Neovim embeds Lua through:
:luacommand: Execute Lua code directly:luafilecommand: Execute Lua filesinit.lua: Lua-based configuration file (alternative toinit.vim)Lua modules: Organize code in
lua/directoryVim API: Access all Vim functionality from Lua
20.2 Lua Language Fundamentals
20.2.1 Basic Syntax and Data Types
-- Comments start with double dash
--[[
Multi-line comments
use this syntax
]]
-- Variables and assignment
local name = "Neovim" -- String
local version = 0.9 -- Number
local is_awesome = true -- Boolean
local nothing = nil -- Nil (null equivalent)
-- No declaration needed for globals (avoid these!)
global_var = "bad practice"
-- Always use 'local' for scope-limited variables
local x = 10
-- Multiple assignment
local a, b, c = 1, 2, 3
local x, y = 10 -- y is nil
-- Swap variables
a, b = b, a20.2.2 Strings
-- String literals
local single = 'single quotes'
local double = "double quotes"
local multiline = [[
Multi-line string
preserves formatting
]]
-- String concatenation
local greeting = "Hello" .. " " .. "Neovim"
local message = "Version: " .. 0.9
-- String methods
local text = "Neovim"
print(string.len(text)) -- 6
print(string.upper(text)) -- NEOVIM
print(string.lower(text)) -- neovim
print(string.sub(text, 1, 3)) -- Neo
print(string.rep("*", 5)) -- *****
-- String formatting
local formatted = string.format("Version %d.%d", 0, 9)
local hex = string.format("0x%x", 255) -- 0xff
-- Pattern matching (similar to regex)
local matched = string.match("hello world", "(%w+)") -- hello
local replaced = string.gsub("foo bar", "foo", "baz") -- baz bar
-- String iteration
for char in string.gmatch("abc", ".") do
print(char) -- prints a, b, c
end20.2.3 Numbers and Arithmetic
-- Integer and floating point
local int = 42
local float = 3.14159
local scientific = 1.5e-3 -- 0.0015
local hex = 0xFF -- 255
-- Arithmetic operators
local sum = 10 + 5 -- 15
local diff = 10 - 5 -- 5
local product = 10 * 5 -- 50
local quotient = 10 / 3 -- 3.333...
local floor_div = math.floor(10 / 3) -- 3
local modulo = 10 % 3 -- 1
local power = 2 ^ 3 -- 8
-- Math library functions
local abs_val = math.abs(-5) -- 5
local ceiling = math.ceil(3.2) -- 4
local floor = math.floor(3.8) -- 3
local maximum = math.max(1, 5, 3) -- 5
local minimum = math.min(1, 5, 3) -- 1
local random = math.random(1, 10) -- random between 1-10
local sqrt = math.sqrt(16) -- 4
-- Comparison
local equal = (5 == 5) -- true
local not_equal = (5 ~= 3) -- true (note: ~= not !=)
local greater = (5 > 3) -- true
local less_eq = (5 <= 5) -- true20.2.4 Booleans and Logic
-- Boolean values
local yes = true
local no = false
local unknown = nil -- Treated as false in conditions
-- Logical operators
local and_result = true and false -- false
local or_result = true or false -- true
local not_result = not true -- false
-- Short-circuit evaluation
local value = false and expensive_function() -- doesn't call function
local default = nil or "default value" -- "default value"
-- Truthiness: only false and nil are falsy
if 0 then print("0 is truthy") end -- prints
if "" then print("empty string is truthy") end -- prints
if nil then print("won't print") end -- doesn't print
if false then print("won't print") end -- doesn't print
-- Ternary-like operator (using and/or)
local result = condition and true_value or false_value
local status = is_enabled and "ON" or "OFF"
-- Comparison returns booleans
local is_greater = 5 > 3 -- true
local is_equal = "a" == "a" -- true20.3 Control Flow
20.3.1 Conditional Statements
-- if-then-else
local score = 85
if score >= 90 then
print("A grade")
elseif score >= 80 then
print("B grade")
elseif score >= 70 then
print("C grade")
else
print("Need improvement")
end
-- One-line if
if score > 60 then print("Pass") end
-- Nested conditions
if score > 50 then
if score > 80 then
print("Excellent!")
else
print("Good")
end
end
-- Using logical operators
if score >= 60 and score < 90 then
print("Pass but not excellent")
end
-- Check for nil/existence
local config = get_config() -- might return nil
if config then
-- config exists
use_config(config)
else
-- config is nil
use_defaults()
end
-- Multiple conditions
if score >= 90 or extra_credit then
print("A grade")
end20.3.2 Loops
-- while loop
local i = 1
while i <= 5 do
print(i)
i = i + 1
end
-- repeat-until loop (do-while equivalent)
local count = 0
repeat
count = count + 1
print(count)
until count >= 5
-- Numeric for loop
for i = 1, 10 do
print(i) -- 1 to 10
end
-- For loop with step
for i = 10, 1, -1 do
print(i) -- 10 down to 1
end
for i = 0, 100, 10 do
print(i) -- 0, 10, 20, ..., 100
end
-- Generic for loop (iterators)
local items = {"apple", "banana", "cherry"}
for index, value in ipairs(items) do
print(index, value)
end
-- Loop control
for i = 1, 10 do
if i == 5 then
break -- Exit loop
end
if i % 2 == 0 then
goto continue -- Skip to next iteration (Lua 5.2+)
end
print(i)
::continue::
end
-- Infinite loop with break
while true do
local input = get_input()
if input == "quit" then
break
end
process(input)
end20.4 Tables (Lua’s Primary Data Structure)
20.4.1 Tables as Arrays
-- Array-like tables (1-indexed!)
local fruits = {"apple", "banana", "cherry"}
-- Access elements
print(fruits[1]) -- apple (note: 1-indexed, not 0!)
print(fruits[2]) -- banana
-- Modify elements
fruits[1] = "apricot"
fruits[4] = "date" -- Add new element
-- Get table length
print(#fruits) -- 4
-- Iterate over array
for i = 1, #fruits do
print(i, fruits[i])
end
-- Using ipairs (safer for arrays)
for index, value in ipairs(fruits) do
print(index, value)
end
-- Table manipulation functions
table.insert(fruits, "elderberry") -- Append
table.insert(fruits, 2, "blueberry") -- Insert at position
local removed = table.remove(fruits) -- Remove last
local specific = table.remove(fruits, 2) -- Remove at position
table.sort(fruits) -- Sort in place
-- Create array with constructor
local numbers = {1, 2, 3, 4, 5}
local mixed = {10, "text", true, {nested = "table"}}
-- Empty array
local empty = {}20.4.2 Tables as Dictionaries/Objects
-- Dictionary/hash table
local person = {
name = "John",
age = 30,
city = "New York"
}
-- Access fields
print(person.name) -- John (dot notation)
print(person["age"]) -- 30 (bracket notation)
-- Add/modify fields
person.email = "john@example.com"
person["phone"] = "555-1234"
-- Check if key exists
if person.email then
print("Has email")
end
-- Remove key
person.email = nil
-- Iterate over dictionary with pairs
for key, value in pairs(person) do
print(key, value)
end
-- Mixed keys (string and number)
local mixed = {
[1] = "first",
[2] = "second",
name = "test",
["special-key"] = "value"
}
-- Dynamic keys
local key_name = "dynamic"
local data = {
[key_name] = "value" -- Use variable as key
}
-- Nested tables
local config = {
editor = {
theme = "dark",
font_size = 12,
features = {
auto_save = true,
line_numbers = true
}
},
plugins = {"lsp", "treesitter"}
}
-- Access nested values
print(config.editor.theme) -- dark
print(config.editor.features.auto_save) -- true
print(config.plugins[1]) -- lsp20.4.3 Table Functions
-- Table construction
local t1 = {1, 2, 3}
local t2 = {a = 1, b = 2}
-- Concatenate array elements
local joined = table.concat(t1, ", ") -- "1, 2, 3"
-- Sort with custom comparator
local items = {5, 2, 8, 1, 9}
table.sort(items, function(a, b)
return a > b -- Sort descending
end)
-- Get table size (works for dictionaries too)
local function table_length(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
-- Copy table (shallow copy)
local function shallow_copy(t)
local copy = {}
for k, v in pairs(t) do
copy[k] = v
end
return copy
end
-- Deep copy table (recursive)
local function deep_copy(t)
if type(t) ~= 'table' then
return t
end
local copy = {}
for k, v in pairs(t) do
copy[k] = deep_copy(v)
end
return copy
end
-- Merge tables
local function merge(t1, t2)
local result = shallow_copy(t1)
for k, v in pairs(t2) do
result[k] = v
end
return result
end
-- Check if table is empty
local function is_empty(t)
return next(t) == nil
end
-- Get keys
local function keys(t)
local result = {}
for k in pairs(t) do
table.insert(result, k)
end
return result
end
-- Get values
local function values(t)
local result = {}
for _, v in pairs(t) do
table.insert(result, v)
end
return result
end20.5 Functions
20.5.1 Function Definition and Calling
-- Basic function
function greet(name)
print("Hello, " .. name)
end
greet("Neovim") -- Hello, Neovim
-- Function with return value
function add(a, b)
return a + b
end
local sum = add(5, 3) -- 8
-- Multiple return values
function get_dimensions()
return 1920, 1080
end
local width, height = get_dimensions()
-- Ignore some return values
local w = get_dimensions() -- Only get first value
local _, h = get_dimensions() -- Ignore first, get second
-- Variable number of arguments
function sum_all(...)
local args = {...} -- Pack into table
local total = 0
for _, v in ipairs(args) do
total = total + v
end
return total
end
print(sum_all(1, 2, 3, 4, 5)) -- 15
-- Named parameters using tables
function create_window(opts)
opts = opts or {} -- Default to empty table
local width = opts.width or 80
local height = opts.height or 24
local title = opts.title or "Window"
print(string.format("%s: %dx%d", title, width, height))
end
create_window{width = 100, height = 30, title = "Editor"}
-- Anonymous functions
local square = function(x)
return x * x
end
print(square(5)) -- 2520.5.2 Local Functions and Scope
-- Local function
local function helper()
return "helper result"
end
-- Function scope
do
local function local_func()
print("Only visible in this block")
end
local_func() -- Works
end
-- local_func() -- Error: undefined
-- Forward declaration for mutual recursion
local is_even, is_odd
function is_even(n)
if n == 0 then return true end
return is_odd(n - 1)
end
function is_odd(n)
if n == 0 then return false end
return is_even(n - 1)
end20.5.3 Closures and Higher-Order Functions
-- Closure: function that captures outer scope
function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end
local counter1 = make_counter()
local counter2 = make_counter()
print(counter1()) -- 1
print(counter1()) -- 2
print(counter2()) -- 1 (separate counter)
-- Factory function
function make_adder(x)
return function(y)
return x + y
end
end
local add5 = make_adder(5)
print(add5(10)) -- 15
-- Map function
local function map(tbl, func)
local result = {}
for i, v in ipairs(tbl) do
result[i] = func(v)
end
return result
end
local numbers = {1, 2, 3, 4}
local squared = map(numbers, function(x) return x * x end)
-- squared = {1, 4, 9, 16}
-- Filter function
local function filter(tbl, predicate)
local result = {}
for _, v in ipairs(tbl) do
if predicate(v) then
table.insert(result, v)
end
end
return result
end
local evens = filter({1, 2, 3, 4, 5, 6}, function(x)
return x % 2 == 0
end)
-- evens = {2, 4, 6}
-- Reduce function
local function reduce(tbl, func, initial)
local acc = initial
for _, v in ipairs(tbl) do
acc = func(acc, v)
end
return acc
end
local sum = reduce({1, 2, 3, 4}, function(acc, x)
return acc + x
end, 0)
-- sum = 1020.5.4 Variadic Functions and Argument Handling
-- Variadic function
function printf(format, ...)
print(string.format(format, ...))
end
printf("Score: %d, Grade: %s", 95, "A")
-- Accessing variadic arguments
function print_args(...)
local args = {...}
print("Argument count:", #args)
for i, v in ipairs(args) do
print(i, v)
end
end
-- Getting vararg count
function count_args(...)
return select('#', ...)
end
print(count_args(1, 2, 3, nil, 5)) -- 5 (includes nil)
-- Select specific arguments
function get_nth_arg(n, ...)
return select(n, ...)
end
print(get_nth_arg(2, "a", "b", "c")) -- b
-- Mix fixed and variadic arguments
function log(level, format, ...)
print("[" .. level .. "]", string.format(format, ...))
end
log("INFO", "Processing %d items", 42)
-- Forwarding arguments
function wrapper(...)
return actual_function(...)
end20.6 Metatables and Metamethods
20.6.1 Basic Metatables
-- Create table and metatable
local my_table = {value = 10}
local my_metatable = {
__index = function(table, key)
return "Key '" .. key .. "' not found"
end
}
-- Set metatable
setmetatable(my_table, my_metatable)
print(my_table.value) -- 10 (exists)
print(my_table.missing_key) -- "Key 'missing_key' not found"
-- Get metatable
local mt = getmetatable(my_table)
-- Using __index for inheritance
local parent = {
name = "Parent",
greet = function(self)
print("Hello from " .. self.name)
end
}
local child = {
name = "Child"
}
setmetatable(child, {__index = parent})
child:greet() -- Hello from Child (method from parent)20.6.2 Common Metamethods
-- Arithmetic metamethods
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
local v = {x = x, y = y}
setmetatable(v, Vector)
return v
end
-- Addition
function Vector.__add(a, b)
return Vector.new(a.x + b.x, a.y + b.y)
end
-- Subtraction
function Vector.__sub(a, b)
return Vector.new(a.x - b.x, a.y - b.y)
end
-- Multiplication (scalar)
function Vector.__mul(a, scalar)
if type(a) == "number" then
a, scalar = scalar, a
end
return Vector.new(a.x * scalar, a.y * scalar)
end
-- String representation
function Vector.__tostring(v)
return string.format("Vector(%d, %d)", v.x, v.y)
end
-- Comparison
function Vector.__eq(a, b)
return a.x == b.x and a.y == b.y
end
-- Usage
local v1 = Vector.new(3, 4)
local v2 = Vector.new(1, 2)
local v3 = v1 + v2 -- Vector(4, 6)
local v4 = v1 * 2 -- Vector(6, 8)
print(v3) -- Vector(4, 6)
print(v1 == v2) -- false
-- Call metamethod
local Calculator = {value = 0}
Calculator.__index = Calculator
function Calculator.new()
return setmetatable({value = 0}, Calculator)
end
function Calculator:__call(...)
local sum = 0
for _, v in ipairs({...}) do
sum = sum + v
end
self.value = sum
return sum
end
local calc = Calculator.new()
print(calc(1, 2, 3, 4)) -- 10 (called like a function)
-- Length metamethod
local CustomArray = {}
CustomArray.__index = CustomArray
function CustomArray.new(...)
local arr = {...}
return setmetatable(arr, CustomArray)
end
function CustomArray:__len()
local count = 0
for _ in pairs(self) do
count = count + 1
end
return count
end
local arr = CustomArray.new(1, 2, 3)
print(#arr) -- 320.6.3 Object-Oriented Programming with Metatables
-- Class-like structure
local Animal = {}
Animal.__index = Animal
function Animal.new(name, sound)
local self = setmetatable({}, Animal)
self.name = name
self.sound = sound
return self
end
function Animal:speak()
print(self.name .. " says " .. self.sound)
end
function Animal:get_name()
return self.name
end
-- Inheritance
local Dog = setmetatable({}, {__index = Animal})
Dog.__index = Dog
function Dog.new(name)
local self = Animal.new(name, "Woof!")
setmetatable(self, Dog)
return self
end
function Dog:fetch()
print(self.name .. " fetches the ball")
end
-- Usage
local dog = Dog.new("Buddy")
dog:speak() -- Buddy says Woof! (inherited)
dog:fetch() -- Buddy fetches the ball
print(dog:get_name()) -- Buddy (inherited)20.7 Modules and Require
20.7.1 Creating Modules
-- File: lua/mymodule.lua
local M = {} -- Module table
-- Private function (local to module)
local function private_helper()
return "private result"
end
-- Public function
function M.public_function()
return "public: " .. private_helper()
end
-- Module data
M.version = "1.0.0"
M.config = {
enabled = true,
timeout = 5000
}
-- Module initialization
function M.setup(opts)
opts = opts or {}
M.config = vim.tbl_extend("force", M.config, opts)
end
return M20.7.2 Loading Modules
-- Load module
local mymodule = require('mymodule')
-- Use module
print(mymodule.version)
print(mymodule.public_function())
mymodule.setup({timeout = 10000})
-- Module caching: require caches modules
local m1 = require('mymodule')
local m2 = require('mymodule')
-- m1 and m2 reference same table
-- Reload module (clear cache)
package.loaded['mymodule'] = nil
local fresh = require('mymodule')
-- Conditional loading
local ok, module = pcall(require, 'optional_module')
if ok then
module.use()
else
print("Module not found, using fallback")
end20.7.3 Module Organization
-- File structure:
-- lua/
-- myapp/
-- init.lua -- Main module entry
-- config.lua -- Configuration
-- utils.lua -- Utilities
-- ui/
-- init.lua -- UI submodule
-- colors.lua -- Color utilities
-- lua/myapp/init.lua
local M = {}
M.config = require('myapp.config')
M.utils = require('myapp.utils')
M.ui = require('myapp.ui')
function M.setup(opts)
M.config.setup(opts)
M.ui.initialize()
end
return M
-- Usage in init.lua
require('myapp').setup({
theme = 'dark',
font_size = 12
})20.8 Error Handling
20.8.1 Protected Calls
-- pcall: protected call
local success, result = pcall(function()
return risky_operation()
end)
if success then
print("Result:", result)
else
print("Error:", result) -- result contains error message
end
-- Inline pcall
local ok, value = pcall(some_function, arg1, arg2)
-- xpcall: protected call with error handler
local function error_handler(err)
print("Error occurred:", err)
print(debug.traceback())
return err
end
local success, result = xpcall(
function() return risky_operation() end,
error_handler
)20.8.2 Error Throwing and Assertions
-- Throwing errors
function divide(a, b)
if b == 0 then
error("Division by zero!")
end
return a / b
end
-- Error with level (for stack trace)
function validate(value)
if type(value) ~= "number" then
error("Expected number, got " .. type(value), 2) -- level 2
end
end
-- Assert
function process(config)
assert(config, "Config is required")
assert(config.name, "Config must have name")
assert(type(config.timeout) == "number", "Timeout must be number")
-- Process...
end
-- Assert with custom error
local value = assert(get_value(), "Failed to get value")20.9 Practical Lua Patterns for Neovim
20.9.1 Configuration Pattern
-- lua/myconfig/init.lua
local M = {}
M.defaults = {
enable = true,
features = {
lsp = true,
treesitter = true,
completion = true
},
keymaps = {
leader = " ",
toggle = "<leader>t"
}
}
M.options = {}
function M.setup(opts)
-- Merge user options with defaults
M.options = vim.tbl_deep_extend("force", M.defaults, opts or {})
-- Validate
assert(type(M.options.enable) == "boolean", "enable must be boolean")
-- Apply configuration
if M.options.enable then
M.apply()
end
return M.options
end
function M.apply()
-- Apply settings
if M.options.features.lsp then
require('myconfig.lsp').setup()
end
-- Set keymaps
vim.g.mapleader = M.options.keymaps.leader
end
function M.get(key)
local keys = vim.split(key, '.', {plain = true})
local value = M.options
for _, k in ipairs(keys) do
if type(value) ~= 'table' then
return nil
end
value = value[k]
end
return value
end
return M20.9.2 Utility Functions Pattern
-- lua/myconfig/utils.lua
local M = {}
function M.is_empty(tbl)
return next(tbl) == nil
end
function M.tbl_keys(tbl)
local keys = {}
for k in pairs(tbl) do
table.insert(keys, k)
end
return keys
end
function M.tbl_values(tbl)
local values = {}
for _, v in pairs(tbl) do
table.insert(values, v)
end
return values
end
function M.tbl_map(tbl, func)
local result = {}
for k, v in pairs(tbl) do
result[k] = func(v, k)
end
return result
end
function M.tbl_filter(tbl, predicate)
local result = {}
for k, v in pairs(tbl) do
if predicate(v, k) then
result[k] = v
end
end
return result
end
function M.debounce(func, timeout)
local timer_id = nil
return function(...)
local args = {...}
if timer_id then
vim.fn.timer_stop(timer_id)
end
timer_id = vim.fn.timer_start(timeout, function()
func(unpack(args))
timer_id = nil
end)
end
end
return M20.9.3 Plugin Structure Pattern
-- lua/myplugin/init.lua
local M = {}
M.config = require('myplugin.config')
local utils = require('myplugin.utils')
local initialized = false
function M.setup(opts)
if initialized then
utils.warn("Plugin already initialized")
return
end
M.config.setup(opts)
-- Setup submodules
require('myplugin.commands').setup()
require('myplugin.keymaps').setup()
require('myplugin.autocmds').setup()
initialized = true
utils.info("Plugin initialized successfully")
end
function M.do_something()
if not initialized then
error("Plugin not initialized. Call setup() first.")
end
-- Implementation
end
return M✅ End of Chapter 20: Lua Basics for Neovim
You now have a solid foundation in Lua programming tailored for Neovim use. This chapter covered the essential language features, data structures, functions, metatables, modules, error handling, and practical patterns. The next chapter will explore the Neovim Lua API, showing how to interact with Vim functionality using these Lua fundamentals.
Chapter 21: The Neovim Lua API
This chapter explores Neovim’s Lua API, which provides comprehensive access to Vim functionality through Lua. You’ll learn how to interact with buffers, windows, options, commands, keymaps, and autocommands using idiomatic Lua code.
21.1 Introduction to the Neovim Lua API
21.1.1 API Namespaces
Neovim’s Lua API is organized into logical namespaces:
-- Main API namespaces
vim.api -- Core API functions
vim.fn -- Access to VimScript functions
vim.cmd -- Execute Ex commands
vim.opt -- Option manipulation (modern interface)
vim.o -- Global options
vim.bo -- Buffer-local options
vim.wo -- Window-local options
vim.g -- Global variables
vim.b -- Buffer variables
vim.w -- Window variables
vim.t -- Tabpage variables
vim.env -- Environment variables
vim.loop -- libuv event loop (async operations)
vim.lsp -- LSP client
vim.treesitter -- Tree-sitter integration
vim.diagnostic -- Diagnostic API21.1.2 Executing Vim Commands
-- Execute Ex commands
vim.cmd('set number')
vim.cmd('colorscheme gruvbox')
vim.cmd([[
augroup MyGroup
autocmd!
autocmd BufWritePre * :echo "Saving..."
augroup END
]])
-- Execute and capture output
local output = vim.fn.execute('messages')
print(output)
-- Alternative: using vim.api
vim.api.nvim_command('set relativenumber')
-- Multiple commands
vim.cmd([[
set number
set relativenumber
set cursorline
]])21.1.3 Calling VimScript Functions
-- Call VimScript functions via vim.fn
local line_count = vim.fn.line('$')
local current_line = vim.fn.getline('.')
local expanded_path = vim.fn.expand('%:p')
local file_exists = vim.fn.filereadable('file.txt')
-- Functions with multiple arguments
vim.fn.search('pattern', 'nW')
vim.fn.setline(5, 'New line content')
-- Get function result
local cwd = vim.fn.getcwd()
local modified = vim.fn.getbufvar(1, '&modified')
-- Check if function exists
if vim.fn.exists('*SomeFunction') == 1 then
vim.fn.SomeFunction()
end
-- Call user-defined functions
vim.fn['MyCustomFunction']('arg1', 'arg2')21.2 Working with Options
21.2.1 Modern Option Interface (vim.opt)
-- Set options using vim.opt (modern, preferred)
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4
vim.opt.expandtab = true
vim.opt.wrap = false
vim.opt.ignorecase = true
vim.opt.smartcase = true
-- List options (append, prepend, remove)
vim.opt.wildignore:append('*.pyc')
vim.opt.wildignore:append({ '*.o', '*.obj' })
vim.opt.path:prepend('src')
vim.opt.path:remove('/usr/include')
-- Get option value
local tabsize = vim.opt.tabstop:get()
local ignores = vim.opt.wildignore:get()
-- Set multiple values
vim.opt.shortmess:append('c') -- Append to option
vim.opt.completeopt = { 'menu', 'menuone', 'noselect' }
-- String options
vim.opt.colorcolumn = '80'
vim.opt.colorcolumn = { '80', '120' } -- Multiple columns21.2.2 Direct Option Access
-- Global options (vim.o)
vim.o.number = true
vim.o.relativenumber = true
vim.o.hlsearch = false
vim.o.backup = false
vim.o.swapfile = false
-- Buffer-local options (vim.bo)
vim.bo.filetype = 'lua'
vim.bo.tabstop = 2
vim.bo.shiftwidth = 2
vim.bo.expandtab = true
-- Window-local options (vim.wo)
vim.wo.number = true
vim.wo.relativenumber = true
vim.wo.cursorline = true
vim.wo.signcolumn = 'yes'
-- For specific buffer/window
vim.bo[bufnr].filetype = 'python'
vim.wo[winnr].number = true
-- Read option value
local current_ft = vim.bo.filetype
local is_number_set = vim.wo.number21.2.3 Option Scoping
-- Setting options for specific buffer/window
local bufnr = vim.api.nvim_get_current_buf()
local winnr = vim.api.nvim_get_current_win()
-- Buffer options
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'lua')
vim.api.nvim_buf_set_option(bufnr, 'tabstop', 2)
-- Window options
vim.api.nvim_win_set_option(winnr, 'number', true)
vim.api.nvim_win_set_option(winnr, 'cursorline', true)
-- Get options
local ft = vim.api.nvim_buf_get_option(bufnr, 'filetype')
local has_numbers = vim.api.nvim_win_get_option(winnr, 'number')
-- Set global option
vim.api.nvim_set_option('ignorecase', true)21.3 Buffer Management
21.3.1 Buffer Operations
-- Get current buffer
local bufnr = vim.api.nvim_get_current_buf()
-- List all buffers
local buffers = vim.api.nvim_list_bufs()
-- Create new buffer
local new_buf = vim.api.nvim_create_buf(false, true)
-- Args: listed (false = unlisted), scratch (true = scratch buffer)
-- Delete buffer
vim.api.nvim_buf_delete(bufnr, { force = true })
-- Check if buffer is valid
local is_valid = vim.api.nvim_buf_is_valid(bufnr)
-- Check if buffer is loaded
local is_loaded = vim.api.nvim_buf_is_loaded(bufnr)
-- Get buffer name (filepath)
local bufname = vim.api.nvim_buf_get_name(bufnr)
-- Set buffer name
vim.api.nvim_buf_set_name(bufnr, '/path/to/file.txt')
-- Get buffer variable
local var = vim.api.nvim_buf_get_var(bufnr, 'my_variable')
-- Set buffer variable
vim.api.nvim_buf_set_var(bufnr, 'my_variable', 'value')
-- Delete buffer variable
vim.api.nvim_buf_del_var(bufnr, 'my_variable')
-- Alternative: using vim.b
vim.b.my_var = 'value'
local value = vim.b.my_var21.3.2 Reading and Writing Buffer Content
local bufnr = vim.api.nvim_get_current_buf()
-- Get all lines
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-- Args: buffer, start (0-indexed), end (-1 = all), strict_indexing
-- Get specific range
local range = vim.api.nvim_buf_get_lines(bufnr, 5, 10, false)
-- Lines 6-10 (0-indexed start, exclusive end)
-- Get single line
local line = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1]
-- Set all lines
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
'Line 1',
'Line 2',
'Line 3'
})
-- Insert lines at position
vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, {
'Inserted line 1',
'Inserted line 2'
})
-- Replace range
vim.api.nvim_buf_set_lines(bufnr, 2, 5, false, {
'Replacement line'
})
-- Append to end
local line_count = vim.api.nvim_buf_line_count(bufnr)
vim.api.nvim_buf_set_lines(bufnr, line_count, line_count, false, {
'New line at end'
})
-- Get/set text (for character-level operations)
local text = vim.api.nvim_buf_get_text(bufnr, 0, 0, 0, 5, {})
-- Args: buf, start_row, start_col, end_row, end_col, opts
vim.api.nvim_buf_set_text(bufnr, 0, 0, 0, 5, {'Hello'})21.3.3 Buffer Marks and Positions
local bufnr = vim.api.nvim_get_current_buf()
-- Get mark position
local mark = vim.api.nvim_buf_get_mark(bufnr, 'a')
-- Returns: {row, col} (1-indexed row, 0-indexed col)
-- Set mark
vim.api.nvim_buf_set_mark(bufnr, 'a', 10, 5, {})
-- Args: buffer, mark_name, line, col, opts
-- Delete mark
vim.api.nvim_buf_del_mark(bufnr, 'a')
-- Get all marks
local marks = vim.fn.getmarklist(bufnr)
-- Get cursor position
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
-- Set cursor position
vim.api.nvim_win_set_cursor(0, {10, 5})
-- Args: window, {row, col} (1-indexed row, 0-indexed col)21.4 Window Management
21.4.1 Window Operations
-- Get current window
local winnr = vim.api.nvim_get_current_win()
-- List all windows
local windows = vim.api.nvim_list_wins()
-- Get window buffer
local bufnr = vim.api.nvim_win_get_buf(winnr)
-- Set window buffer
vim.api.nvim_win_set_buf(winnr, bufnr)
-- Check if window is valid
local is_valid = vim.api.nvim_win_is_valid(winnr)
-- Get window position
local pos = vim.api.nvim_win_get_position(winnr)
local row, col = pos[1], pos[2]
-- Get window dimensions
local width = vim.api.nvim_win_get_width(winnr)
local height = vim.api.nvim_win_get_height(winnr)
-- Set window dimensions
vim.api.nvim_win_set_width(winnr, 80)
vim.api.nvim_win_set_height(winnr, 24)
-- Close window
vim.api.nvim_win_close(winnr, false) -- force = false
-- Hide window (close but keep buffer)
vim.api.nvim_win_hide(winnr)21.4.2 Creating and Splitting Windows
-- Open new window (split)
vim.cmd('split') -- Horizontal split
vim.cmd('vsplit') -- Vertical split
-- Open buffer in new split
vim.cmd('split | buffer ' .. bufnr)
-- Create floating window
local buf = vim.api.nvim_create_buf(false, true)
local opts = {
relative = 'editor',
width = 50,
height = 10,
col = 10,
row = 5,
style = 'minimal',
border = 'rounded'
}
local win = vim.api.nvim_open_win(buf, true, opts)
-- Relative to cursor
local opts_cursor = {
relative = 'cursor',
width = 40,
height = 8,
col = 0,
row = 1,
style = 'minimal',
border = 'single'
}
-- Relative to window
local opts_win = {
relative = 'win',
win = 0, -- Current window
width = 30,
height = 15,
col = 5,
row = 2
}
-- Update floating window config
vim.api.nvim_win_set_config(win, {
width = 60,
height = 20
})
-- Get window config
local config = vim.api.nvim_win_get_config(win)21.4.3 Window Variables
local winnr = vim.api.nvim_get_current_win()
-- Set window variable
vim.api.nvim_win_set_var(winnr, 'my_var', 'value')
-- Get window variable
local value = vim.api.nvim_win_get_var(winnr, 'my_var')
-- Delete window variable
vim.api.nvim_win_del_var(winnr, 'my_var')
-- Using vim.w
vim.w.my_window_var = 123
local val = vim.w.my_window_var21.5 Keymaps
21.5.1 Setting Keymaps
-- Basic keymap
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save file' })
-- Multiple modes
vim.keymap.set({'n', 'v'}, '<leader>y', '"+y', { desc = 'Yank to clipboard' })
-- With options
vim.keymap.set('n', '<leader>f', ':Telescope find_files<CR>', {
noremap = true, -- Non-recursive (default: true)
silent = true, -- Don't show in command line
desc = 'Find files'
})
-- Call Lua function
vim.keymap.set('n', '<leader>h', function()
print('Hello from keymap!')
end, { desc = 'Print hello' })
-- With expression
vim.keymap.set('i', '<C-j>', function()
return vim.fn.pumvisible() == 1 and '<C-n>' or '<C-j>'
end, { expr = true })
-- Buffer-local keymap
vim.keymap.set('n', '<leader>r', ':!node %<CR>', {
buffer = true, -- Current buffer only
desc = 'Run with Node.js'
})
-- Specific buffer
vim.keymap.set('n', '<leader>r', ':!node %<CR>', {
buffer = bufnr,
desc = 'Run with Node.js'
})
-- Replace existing mapping
vim.keymap.set('n', 'j', 'gj', { remap = true })21.5.2 Getting and Deleting Keymaps
-- Get keymap
local keymaps = vim.api.nvim_get_keymap('n')
-- Get buffer keymaps
local buf_keymaps = vim.api.nvim_buf_get_keymap(0, 'n')
-- Delete keymap
vim.keymap.del('n', '<leader>w')
-- Delete buffer keymap
vim.keymap.del('n', '<leader>r', { buffer = true })
-- Check if keymap exists
local function keymap_exists(mode, lhs)
local keymaps = vim.api.nvim_get_keymap(mode)
for _, keymap in ipairs(keymaps) do
if keymap.lhs == lhs then
return true
end
end
return false
end21.5.3 Common Keymap Patterns
-- Leader key setup
vim.g.mapleader = ' '
vim.g.maplocalleader = ','
-- Save file
vim.keymap.set('n', '<leader>w', '<cmd>w<CR>', { desc = 'Save' })
-- Quit
vim.keymap.set('n', '<leader>q', '<cmd>q<CR>', { desc = 'Quit' })
-- Better window navigation
vim.keymap.set('n', '<C-h>', '<C-w>h', { desc = 'Go to left window' })
vim.keymap.set('n', '<C-j>', '<C-w>j', { desc = 'Go to lower window' })
vim.keymap.set('n', '<C-k>', '<C-w>k', { desc = 'Go to upper window' })
vim.keymap.set('n', '<C-l>', '<C-w>l', { desc = 'Go to right window' })
-- Resize windows
vim.keymap.set('n', '<C-Up>', ':resize +2<CR>', { desc = 'Increase height' })
vim.keymap.set('n', '<C-Down>', ':resize -2<CR>', { desc = 'Decrease height' })
vim.keymap.set('n', '<C-Left>', ':vertical resize -2<CR>', { desc = 'Decrease width' })
vim.keymap.set('n', '<C-Right>', ':vertical resize +2<CR>', { desc = 'Increase width' })
-- Navigate buffers
vim.keymap.set('n', '<S-l>', ':bnext<CR>', { desc = 'Next buffer' })
vim.keymap.set('n', '<S-h>', ':bprevious<CR>', { desc = 'Previous buffer' })
-- Stay in indent mode
vim.keymap.set('v', '<', '<gv', { desc = 'Indent left' })
vim.keymap.set('v', '>', '>gv', { desc = 'Indent right' })
-- Move text up and down
vim.keymap.set('v', '<A-j>', ":m '>+1<CR>gv=gv", { desc = 'Move line down' })
vim.keymap.set('v', '<A-k>', ":m '<-2<CR>gv=gv", { desc = 'Move line up' })
-- Keep paste buffer when pasting over
vim.keymap.set('v', 'p', '"_dP', { desc = 'Paste without yanking' })
-- Clear search highlighting
vim.keymap.set('n', '<Esc>', '<cmd>nohlsearch<CR>', { desc = 'Clear search' })21.6 Autocommands
21.6.1 Creating Autocommands
-- Basic autocommand
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
callback = function()
print('File about to be saved!')
end
})
-- With pattern matching
vim.api.nvim_create_autocmd('BufRead', {
pattern = '*.lua',
callback = function()
vim.bo.tabstop = 2
vim.bo.shiftwidth = 2
end
})
-- Multiple events
vim.api.nvim_create_autocmd({'BufEnter', 'BufWinEnter'}, {
pattern = '*.py',
callback = function()
vim.bo.expandtab = true
end
})
-- Execute command instead of callback
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
command = 'echo "Saving..."'
})
-- With description
vim.api.nvim_create_autocmd('FileType', {
pattern = 'lua',
desc = 'Setup Lua filetype options',
callback = function()
vim.opt_local.tabstop = 2
end
})
-- Buffer-specific autocommand
vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
print('This buffer was saved')
end
})
-- Once (execute only once then delete)
vim.api.nvim_create_autocmd('VimEnter', {
once = true,
callback = function()
print('Neovim started!')
end
})21.6.2 Autocommand Groups
-- Create augroup
local augroup = vim.api.nvim_create_augroup('MyGroup', { clear = true })
-- Add autocommands to group
vim.api.nvim_create_autocmd('BufWritePre', {
group = augroup,
pattern = '*',
callback = function()
-- Remove trailing whitespace
vim.cmd([[%s/\s\+$//e]])
end
})
vim.api.nvim_create_autocmd('BufRead', {
group = augroup,
pattern = '*.lua',
callback = function()
vim.bo.tabstop = 2
end
})
-- Create group and autocommands together
local group = vim.api.nvim_create_augroup('FileTypeSettings', { clear = true })
vim.api.nvim_create_autocmd('FileType', {
group = group,
pattern = 'python',
callback = function()
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
end
})
vim.api.nvim_create_autocmd('FileType', {
group = group,
pattern = 'javascript',
callback = function()
vim.opt_local.tabstop = 2
vim.opt_local.shiftwidth = 2
end
})
-- Clear augroup
vim.api.nvim_clear_autocmds({ group = augroup })
-- Delete specific autocommands
vim.api.nvim_clear_autocmds({
event = 'BufWritePre',
pattern = '*.lua'
})21.6.3 Practical Autocommand Examples
-- Auto-format on save
local format_group = vim.api.nvim_create_augroup('AutoFormat', { clear = true })
vim.api.nvim_create_autocmd('BufWritePre', {
group = format_group,
pattern = { '*.lua', '*.py', '*.js' },
callback = function()
vim.lsp.buf.format({ async = false })
end
})
-- Highlight on yank
vim.api.nvim_create_autocmd('TextYankPost', {
callback = function()
vim.highlight.on_yank({ higroup = 'IncSearch', timeout = 200 })
end
})
-- Auto-save
vim.api.nvim_create_autocmd({ 'FocusLost', 'BufLeave' }, {
pattern = '*',
callback = function()
if vim.bo.modified and not vim.bo.readonly and vim.fn.expand('%') ~= '' then
vim.cmd('silent! write')
end
end
})
-- Restore cursor position
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local line_count = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= line_count then
vim.api.nvim_win_set_cursor(0, mark)
end
end
})
-- Auto-create parent directories
vim.api.nvim_create_autocmd('BufWritePre', {
callback = function(event)
local file = vim.loop.fs_realpath(event.match) or event.match
local dir = vim.fn.fnamemodify(file, ':p:h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end
})
-- Close certain filetypes with 'q'
vim.api.nvim_create_autocmd('FileType', {
pattern = { 'help', 'qf', 'man', 'lspinfo' },
callback = function(event)
vim.bo[event.buf].buflisted = false
vim.keymap.set('n', 'q', '<cmd>close<CR>', { buffer = event.buf })
end
})
-- Terminal settings
vim.api.nvim_create_autocmd('TermOpen', {
callback = function()
vim.wo.number = false
vim.wo.relativenumber = false
vim.cmd('startinsert')
end
})21.7 User Commands
21.7.1 Creating User Commands
-- Basic command
vim.api.nvim_create_user_command('Hello', function()
print('Hello, Neovim!')
end, {})
-- Command with arguments
vim.api.nvim_create_user_command('Greet', function(opts)
print('Hello, ' .. opts.args)
end, { nargs = 1 })
-- Optional arguments
vim.api.nvim_create_user_command('Log', function(opts)
local message = opts.args ~= '' and opts.args or 'Default message'
print('[LOG]', message)
end, { nargs = '?' })
-- Variable arguments
vim.api.nvim_create_user_command('Echo', function(opts)
print(opts.args)
end, { nargs = '*' })
-- Command with range
vim.api.nvim_create_user_command('Count', function(opts)
local lines = vim.api.nvim_buf_get_lines(0, opts.line1 - 1, opts.line2, false)
print('Lines:', #lines)
end, { range = true })
-- Command with completion
vim.api.nvim_create_user_command('Edit', function(opts)
vim.cmd('edit ' .. opts.args)
end, {
nargs = 1,
complete = 'file' -- File completion
})
-- Custom completion
vim.api.nvim_create_user_command('Theme', function(opts)
vim.cmd('colorscheme ' .. opts.args)
end, {
nargs = 1,
complete = function()
return vim.fn.getcompletion('', 'color')
end
})
-- Force command (with bang)
vim.api.nvim_create_user_command('Delete', function(opts)
if opts.bang then
-- Force delete
vim.cmd('bdelete!')
else
vim.cmd('bdelete')
end
end, { bang = true })21.7.2 Command Options and Attributes
-- With description
vim.api.nvim_create_user_command('Format', function()
vim.lsp.buf.format()
end, {
desc = 'Format current buffer'
})
-- Buffer-local command
vim.api.nvim_buf_create_user_command(0, 'BufferCommand', function()
print('This command is buffer-local')
end, {})
-- Command with count
vim.api.nvim_create_user_command('Jump', function(opts)
local count = opts.count ~= -1 and opts.count or 1
vim.cmd('normal! ' .. count .. 'j')
end, { count = true })
-- Register/bar handling
vim.api.nvim_create_user_command('Test', function(opts)
-- opts.reg contains the register if specified
-- opts.args contains arguments
print('Register:', opts.reg)
print('Args:', opts.args)
end, {
nargs = '*',
register = true,
bar = true -- Allow command chaining with |
})
-- Access fargs (argument list)
vim.api.nvim_create_user_command('Multi', function(opts)
for i, arg in ipairs(opts.fargs) do
print(i, arg)
end
end, { nargs = '+' }) -- At least one argument21.7.3 Practical Command Examples
-- Reload configuration
vim.api.nvim_create_user_command('ReloadConfig', function()
for name, _ in pairs(package.loaded) do
if name:match('^user') or name:match('^plugins') then
package.loaded[name] = nil
end
end
dofile(vim.env.MYVIMRC)
print('Configuration reloaded!')
end, { desc = 'Reload Neovim configuration' })
-- Toggle option
vim.api.nvim_create_user_command('ToggleWrap', function()
vim.wo.wrap = not vim.wo.wrap
print('Wrap:', vim.wo.wrap)
end, { desc = 'Toggle line wrapping' })
-- Quick fix list navigation
vim.api.nvim_create_user_command('Cnext', function()
pcall(vim.cmd, 'cnext')
end, { desc = 'Next quickfix item' })
vim.api.nvim_create_user_command('Cprev', function()
pcall(vim.cmd, 'cprevious')
end, { desc = 'Previous quickfix item' })
-- Open file at line
vim.api.nvim_create_user_command('EditLine', function(opts)
local parts = vim.split(opts.args, ':')
local file = parts[1]
local line = tonumber(parts[2]) or 1
vim.cmd('edit ' .. file)
vim.api.nvim_win_set_cursor(0, {line, 0})
end, {
nargs = 1,
complete = 'file',
desc = 'Open file at specific line (file:line)'
})
-- Search and replace in multiple files
vim.api.nvim_create_user_command('Replace', function(opts)
local args = vim.split(opts.args, ' ')
if #args < 3 then
print('Usage: Replace <pattern> <replacement> <files>')
return
end
local pattern = args[1]
local replacement = args[2]
local files = vim.list_slice(args, 3)
vim.cmd('args ' .. table.concat(files, ' '))
vim.cmd('argdo %s/' .. pattern .. '/' .. replacement .. '/ge | update')
end, {
nargs = '+',
desc = 'Replace pattern in multiple files'
})21.8 Global Variables
21.8.1 Setting and Getting Variables
-- Global variables (vim.g)
vim.g.mapleader = ' '
vim.g.maplocalleader = ','
vim.g.my_config = {
theme = 'dark',
font_size = 12
}
-- Get variable
local leader = vim.g.mapleader
local config = vim.g.my_config
-- Buffer variables (vim.b)
vim.b.is_processed = true
vim.b.custom_setting = 'value'
-- Window variables (vim.w)
vim.w.my_window_data = {
created_at = os.time()
}
-- Tabpage variables (vim.t)
vim.t.tab_name = 'Main'
-- Environment variables (vim.env)
local home = vim.env.HOME
local path = vim.env.PATH
vim.env.MY_VAR = 'custom value'
-- Check if variable exists
if vim.g.some_var ~= nil then
-- Variable exists
end
-- Delete variable
vim.g.obsolete_var = nil21.8.2 Using Variables with Plugins
-- Plugin configuration via globals
vim.g.netrw_banner = 0
vim.g.netrw_liststyle = 3
vim.g.loaded_matchparen = 1 -- Disable plugin
-- Conditional plugin loading
if vim.g.vscode == nil then
-- Not in VSCode, load normal plugins
require('plugins.telescope')
else
-- In VSCode, minimal setup
end
-- Feature flags
vim.g.enable_experimental_features = true
if vim.g.enable_experimental_features then
require('experimental.features')
end21.9 Highlights and Colors
21.9.1 Setting Highlights
-- Set highlight group
vim.api.nvim_set_hl(0, 'MyHighlight', {
fg = '#ff0000',
bg = '#000000',
bold = true
})
-- Link highlight groups
vim.api.nvim_set_hl(0, 'MyCustomHL', { link = 'Comment' })
-- Get highlight definition
local hl = vim.api.nvim_get_hl_by_name('Comment', true)
print(vim.inspect(hl))
-- Clear highlight
vim.api.nvim_set_hl(0, 'MyHighlight', {})
-- Common highlight attributes
vim.api.nvim_set_hl(0, 'CustomHL', {
fg = '#ffffff', -- Foreground color
bg = '#000000', -- Background color
sp = '#ff0000', -- Special color (underline, etc.)
blend = 50, -- Transparency (0-100)
bold = true,
italic = true,
underline = true,
undercurl = true,
strikethrough = true,
reverse = true,
standout = true
})
-- Set highlight in namespace
local ns_id = vim.api.nvim_create_namespace('my_namespace')
vim.api.nvim_set_hl(ns_id, 'MyHL', { fg = '#00ff00' })21.9.2 Temporary Highlights
-- Highlight range temporarily
local ns = vim.api.nvim_create_namespace('temp_highlight')
vim.api.nvim_buf_add_highlight(
0, -- buffer
ns, -- namespace
'Search', -- highlight group
0, -- line
0, -- start col
-1 -- end col (-1 = end of line)
)
-- Clear highlights in namespace
vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
-- Set extmark with highlight
vim.api.nvim_buf_set_extmark(0, ns, 0, 0, {
end_row = 0,
end_col = 10,
hl_group = 'IncSearch'
})21.10 Practical API Utilities
21.10.1 Buffer Utilities
local M = {}
-- Get all listed buffers
function M.get_listed_buffers()
local buffers = {}
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[bufnr].buflisted then
table.insert(buffers, bufnr)
end
end
return buffers
end
-- Get buffer by name
function M.get_buffer_by_name(name)
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
local bufname = vim.api.nvim_buf_get_name(bufnr)
if bufname:match(name) then
return bufnr
end
end
return nil
end
-- Check if buffer is empty
function M.is_buffer_empty(bufnr)
bufnr = bufnr or 0
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
return #lines == 1 and lines[1] == ''
end
-- Delete all buffers except current
function M.delete_other_buffers()
local current = vim.api.nvim_get_current_buf()
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if bufnr ~= current and vim.bo[bufnr].buflisted then
vim.api.nvim_buf_delete(bufnr, { force = false })
end
end
end
return M21.10.2 Window Utilities
local M = {}
-- Center window on screen
function M.center_window()
local win_h = vim.api.nvim_win_get_height(0)
local win_pos = vim.api.nvim_win_get_position(0)
local cursor = vim.api.nvim_win_get_cursor(0)
local target_row = math.floor(win_h / 2)
local scroll = cursor[1] - target_row - win_pos[1]
vim.cmd('normal! ' .. scroll .. '\\<C-e>')
end
-- Get visible windows
function M.get_visible_windows()
local windows = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
local config = vim.api.nvim_win_get_config(win)
if config.relative == '' then -- Not floating
table.insert(windows, win)
end
end
return windows
end
-- Get floating windows
function M.get_floating_windows()
local windows = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
local config = vim.api.nvim_win_get_config(win)
if config.relative ~= '' then
table.insert(windows, win)
end
end
return windows
end
-- Close all floating windows
function M.close_all_floating()
for _, win in ipairs(M.get_floating_windows()) do
vim.api.nvim_win_close(win, true)
end
end
return M21.10.3 Notification Utility
local M = {}
-- Show notification
function M.notify(message, level, opts)
opts = opts or {}
level = level or vim.log.levels.INFO
vim.notify(message, level, {
title = opts.title or 'Notification',
timeout = opts.timeout or 3000,
on_open = opts.on_open,
on_close = opts.on_close
})
end
-- Convenience wrappers
function M.info(message, opts)
M.notify(message, vim.log.levels.INFO, opts)
end
function M.warn(message, opts)
M.notify(message, vim.log.levels.WARN, opts)
end
function M.error(message, opts)
M.notify(message, vim.log.levels.ERROR, opts)
end
function M.debug(message, opts)
if vim.g.debug_mode then
M.notify(message, vim.log.levels.DEBUG, opts)
end
end
return M✅ End of Chapter 21: The Neovim Lua API
You now have comprehensive knowledge of Neovim’s Lua API, covering options, buffers, windows, keymaps, autocommands, user commands, variables, highlights, and practical utilities. This chapter provides the foundation for writing powerful Neovim configurations and plugins using idiomatic Lua. The next chapter will explore writing complete Neovim plugins using these API concepts.
Chapter 22: Advanced Lua Patterns in Neovim
This chapter explores advanced Lua programming patterns specifically designed for Neovim plugin development and configuration. You’ll learn about module design, asynchronous programming, event handling, and sophisticated techniques for building robust Neovim extensions.
22.1 Module Organization and Loading
22.1.1 Plugin Structure
-- Standard plugin structure:
-- lua/
-- myplugin/
-- init.lua -- Main entry point
-- config.lua -- Configuration
-- utils.lua -- Utility functions
-- commands.lua -- User commands
-- autocmds.lua -- Autocommands
-- keymaps.lua -- Keybindings
-- health.lua -- Health checks
-- lua/myplugin/init.lua
local M = {}
M.config = {
enabled = true,
debug = false,
options = {}
}
function M.setup(opts)
M.config = vim.tbl_deep_extend('force', M.config, opts or {})
if not M.config.enabled then
return
end
require('myplugin.commands').setup()
require('myplugin.autocmds').setup()
require('myplugin.keymaps').setup()
end
return M22.1.2 Lazy Loading Patterns
-- Autoload pattern
local M = {}
-- Cache loaded modules
local _cache = {}
function M.load(module_name)
if not _cache[module_name] then
_cache[module_name] = require('myplugin.' .. module_name)
end
return _cache[module_name]
end
-- Lazy require
local function lazy_require(module)
return setmetatable({}, {
__index = function(_, key)
return require(module)[key]
end,
__call = function(_, ...)
return require(module)(...)
end
})
end
-- Usage
local utils = lazy_require('myplugin.utils')
utils.some_function() -- Module loaded only when called22.1.3 Configuration Merging
local M = {}
-- Deep merge configurations
function M.merge_config(defaults, user_config)
local config = vim.deepcopy(defaults)
if not user_config then
return config
end
return vim.tbl_deep_extend('force', config, user_config)
end
-- Validate configuration
function M.validate_config(config, schema)
for key, spec in pairs(schema) do
local value = config[key]
-- Check required fields
if spec.required and value == nil then
error(string.format('Missing required config: %s', key))
end
-- Check type
if value ~= nil and spec.type then
local value_type = type(value)
if value_type ~= spec.type then
error(string.format(
'Invalid type for %s: expected %s, got %s',
key, spec.type, value_type
))
end
end
-- Check allowed values
if value ~= nil and spec.values then
local found = false
for _, allowed in ipairs(spec.values) do
if value == allowed then
found = true
break
end
end
if not found then
error(string.format(
'Invalid value for %s: %s not in allowed values',
key, vim.inspect(value)
))
end
end
-- Validate nested tables
if spec.schema and type(value) == 'table' then
M.validate_config(value, spec.schema)
end
end
return true
end
-- Example usage
local schema = {
enabled = { type = 'boolean', required = true },
level = { type = 'string', values = { 'debug', 'info', 'warn', 'error' } },
options = {
type = 'table',
schema = {
timeout = { type = 'number' },
retries = { type = 'number' }
}
}
}
local config = {
enabled = true,
level = 'info',
options = {
timeout = 5000,
retries = 3
}
}
M.validate_config(config, schema)
return M22.2 Asynchronous Programming
22.2.1 Using vim.loop (libuv)
local M = {}
local uv = vim.loop
-- Basic async file read
function M.read_file_async(path, callback)
uv.fs_open(path, 'r', 438, function(err, fd)
if err then
callback(err, nil)
return
end
uv.fs_fstat(fd, function(err, stat)
if err then
uv.fs_close(fd, function() end)
callback(err, nil)
return
end
uv.fs_read(fd, stat.size, 0, function(err, data)
uv.fs_close(fd, function() end)
if err then
callback(err, nil)
else
callback(nil, data)
end
end)
end)
end)
end
-- Promisify pattern
function M.promisify(fn)
return function(...)
local args = {...}
local co = coroutine.running()
table.insert(args, function(err, result)
if co then
coroutine.resume(co, err, result)
end
end)
fn(unpack(args))
if co then
return coroutine.yield()
end
end
end
-- Async/await pattern
function M.async(fn)
return function(...)
local co = coroutine.create(fn)
local function step(...)
local ok, err_or_result = coroutine.resume(co, ...)
if not ok then
error(err_or_result)
end
end
step(...)
end
end
-- Usage example
M.read_file = M.promisify(M.read_file_async)
M.async(function()
local err, content = M.read_file('/path/to/file.txt')
if err then
print('Error:', err)
else
print('Content:', content)
end
end)()
return M22.2.2 Timer and Scheduling
local M = {}
local uv = vim.loop
-- Debounce function calls
function M.debounce(fn, ms)
local timer = uv.new_timer()
return function(...)
local args = {...}
timer:stop()
timer:start(ms, 0, function()
fn(unpack(args))
end)
end
end
-- Throttle function calls
function M.throttle(fn, ms)
local timer = uv.new_timer()
local running = false
return function(...)
if not running then
local args = {...}
running = true
fn(unpack(args))
timer:start(ms, 0, function()
running = false
end)
end
end
end
-- Schedule callback on main thread
function M.schedule(fn, ...)
local args = {...}
vim.schedule(function()
fn(unpack(args))
end)
end
-- Defer execution
function M.defer(fn, ms)
local timer = uv.new_timer()
timer:start(ms or 0, 0, vim.schedule_wrap(function()
timer:close()
fn()
end))
end
-- Interval execution
function M.interval(fn, ms)
local timer = uv.new_timer()
timer:start(ms, ms, vim.schedule_wrap(fn))
return {
stop = function()
timer:stop()
timer:close()
end
}
end
-- Usage examples
local debounced_save = M.debounce(function()
print('Saving...')
end, 500)
local throttled_update = M.throttle(function()
print('Updating...')
end, 1000)
-- Run every 5 seconds
local interval_handle = M.interval(function()
print('Periodic task')
end, 5000)
-- Stop after 30 seconds
M.defer(function()
interval_handle.stop()
end, 30000)
return M22.2.3 Job Control
local M = {}
-- Run external command
function M.run_command(cmd, opts)
opts = opts or {}
local stdout_chunks = {}
local stderr_chunks = {}
local job_id = vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
vim.list_extend(stdout_chunks, data)
end
end,
on_stderr = function(_, data)
if data then
vim.list_extend(stderr_chunks, data)
end
end,
on_exit = function(_, exit_code)
local stdout = table.concat(stdout_chunks, '\n')
local stderr = table.concat(stderr_chunks, '\n')
if opts.on_exit then
vim.schedule(function()
opts.on_exit(exit_code, stdout, stderr)
end)
end
end,
stdout_buffered = true,
stderr_buffered = true,
})
return job_id
end
-- Run with input
function M.run_with_input(cmd, input, opts)
opts = opts or {}
local job_id = M.run_command(cmd, opts)
if input then
vim.fn.chansend(job_id, input)
vim.fn.chanclose(job_id, 'stdin')
end
return job_id
end
-- Run and wait
function M.run_sync(cmd, timeout)
local result = vim.fn.system(cmd)
local exit_code = vim.v.shell_error
return {
exit_code = exit_code,
stdout = result,
success = exit_code == 0
}
end
-- Usage examples
M.run_command({'git', 'status'}, {
on_exit = function(code, stdout, stderr)
if code == 0 then
print('Output:', stdout)
else
print('Error:', stderr)
end
end
})
-- Async command with callback
function M.git_status(callback)
M.run_command({'git', 'status', '--short'}, {
on_exit = function(code, stdout, stderr)
if code == 0 then
local files = vim.split(stdout, '\n', { trimempty = true })
callback(nil, files)
else
callback(stderr, nil)
end
end
})
end
return M22.3 Event System
22.3.1 Custom Event Emitter
local M = {}
function M.new()
local emitter = {
_listeners = {}
}
-- Register event listener
function emitter:on(event, callback)
if not self._listeners[event] then
self._listeners[event] = {}
end
table.insert(self._listeners[event], callback)
-- Return unsubscribe function
return function()
self:off(event, callback)
end
end
-- Register one-time listener
function emitter:once(event, callback)
local function wrapper(...)
callback(...)
self:off(event, wrapper)
end
return self:on(event, wrapper)
end
-- Remove listener
function emitter:off(event, callback)
if not self._listeners[event] then
return
end
for i, listener in ipairs(self._listeners[event]) do
if listener == callback then
table.remove(self._listeners[event], i)
break
end
end
end
-- Emit event
function emitter:emit(event, ...)
if not self._listeners[event] then
return
end
local args = {...}
for _, listener in ipairs(self._listeners[event]) do
vim.schedule(function()
listener(unpack(args))
end)
end
end
-- Remove all listeners for event
function emitter:remove_all_listeners(event)
if event then
self._listeners[event] = nil
else
self._listeners = {}
end
end
-- Get listener count
function emitter:listener_count(event)
if not self._listeners[event] then
return 0
end
return #self._listeners[event]
end
return emitter
end
-- Usage example
local events = M.new()
-- Subscribe to events
events:on('file:saved', function(filename)
print('File saved:', filename)
end)
events:once('plugin:loaded', function()
print('Plugin loaded (once)')
end)
-- Emit events
events:emit('file:saved', 'config.lua')
events:emit('plugin:loaded')
return M22.3.2 Autocmd Event Wrapper
local M = {}
-- Wrap autocommands as events
function M.create_autocmd_events()
local emitter = require('myplugin.events').new()
local augroup = vim.api.nvim_create_augroup('MyPluginEvents', { clear = true })
local event_map = {
buffer_enter = 'BufEnter',
buffer_write_pre = 'BufWritePre',
buffer_write_post = 'BufWritePost',
file_type = 'FileType',
cursor_moved = 'CursorMoved',
insert_enter = 'InsertEnter',
insert_leave = 'InsertLeave',
}
for event_name, autocmd_event in pairs(event_map) do
vim.api.nvim_create_autocmd(autocmd_event, {
group = augroup,
callback = function(args)
emitter:emit(event_name, args)
end
})
end
return emitter
end
-- Usage
local events = M.create_autocmd_events()
events:on('buffer_write_pre', function(args)
print('About to save:', args.file)
end)
events:on('file_type', function(args)
if args.match == 'lua' then
print('Lua file detected')
end
end)
return M22.4 State Management
22.4.1 Simple State Container
local M = {}
function M.create_store(initial_state)
local state = vim.deepcopy(initial_state or {})
local listeners = {}
local store = {}
-- Get state
function store.get_state()
return vim.deepcopy(state)
end
-- Get specific value
function store.get(key)
return vim.deepcopy(state[key])
end
-- Set state
function store.set_state(new_state)
local old_state = vim.deepcopy(state)
state = vim.deepcopy(new_state)
-- Notify listeners
for _, listener in ipairs(listeners) do
vim.schedule(function()
listener(state, old_state)
end)
end
end
-- Update state
function store.update(updates)
local old_state = vim.deepcopy(state)
state = vim.tbl_deep_extend('force', state, updates)
-- Notify listeners
for _, listener in ipairs(listeners) do
vim.schedule(function()
listener(state, old_state)
end)
end
end
-- Subscribe to changes
function store.subscribe(listener)
table.insert(listeners, listener)
-- Return unsubscribe function
return function()
for i, l in ipairs(listeners) do
if l == listener then
table.remove(listeners, i)
break
end
end
end
end
-- Reset to initial state
function store.reset()
store.set_state(initial_state)
end
return store
end
-- Usage example
local store = M.create_store({
count = 0,
name = 'Plugin',
enabled = true
})
-- Subscribe to changes
local unsubscribe = store.subscribe(function(new_state, old_state)
print('State changed:', vim.inspect(new_state))
end)
-- Update state
store.update({ count = store.get('count') + 1 })
store.update({ enabled = false })
-- Get state
local current_state = store.get_state()
print(vim.inspect(current_state))
return M22.4.2 Persistent State
local M = {}
-- Save state to file
function M.save_state(state, filepath)
local content = vim.inspect(state)
local file = io.open(filepath, 'w')
if file then
file:write('return ' .. content)
file:close()
return true
end
return false
end
-- Load state from file
function M.load_state(filepath)
if vim.fn.filereadable(filepath) == 0 then
return nil
end
local ok, state = pcall(dofile, filepath)
if ok then
return state
end
return nil
end
-- Create persistent store
function M.create_persistent_store(name, initial_state)
local data_path = vim.fn.stdpath('data')
local filepath = string.format('%s/%s.lua', data_path, name)
-- Load existing state or use initial
local state = M.load_state(filepath) or initial_state
local store = require('myplugin.state').create_store(state)
-- Wrap update to auto-save
local original_update = store.update
function store.update(updates)
original_update(updates)
M.save_state(store.get_state(), filepath)
end
local original_set_state = store.set_state
function store.set_state(new_state)
original_set_state(new_state)
M.save_state(new_state, filepath)
end
return store
end
-- Usage
local persistent_store = M.create_persistent_store('my_plugin_state', {
last_used = os.time(),
preferences = {}
})
return M22.5 Memoization and Caching
22.5.1 Function Memoization
local M = {}
-- Basic memoization
function M.memoize(fn)
local cache = {}
return function(...)
local key = vim.inspect({...})
if cache[key] == nil then
cache[key] = fn(...)
end
return cache[key]
end
end
-- Memoize with TTL (time to live)
function M.memoize_with_ttl(fn, ttl_ms)
local cache = {}
return function(...)
local key = vim.inspect({...})
local now = vim.loop.now()
if cache[key] then
if now - cache[key].timestamp < ttl_ms then
return cache[key].value
end
end
local value = fn(...)
cache[key] = {
value = value,
timestamp = now
}
return value
end
end
-- Memoize with custom key function
function M.memoize_by(fn, key_fn)
local cache = {}
return function(...)
local key = key_fn(...)
if cache[key] == nil then
cache[key] = fn(...)
end
return cache[key]
end
end
-- LRU Cache
function M.create_lru_cache(max_size)
local cache = {}
local order = {}
local function update_access(key)
for i, k in ipairs(order) do
if k == key then
table.remove(order, i)
break
end
end
table.insert(order, key)
-- Remove oldest if over limit
if #order > max_size then
local oldest = table.remove(order, 1)
cache[oldest] = nil
end
end
return {
get = function(key)
if cache[key] ~= nil then
update_access(key)
return cache[key]
end
return nil
end,
set = function(key, value)
cache[key] = value
update_access(key)
end,
clear = function()
cache = {}
order = {}
end,
size = function()
return #order
end
}
end
-- Usage examples
local expensive_fn = M.memoize(function(n)
-- Simulate expensive computation
vim.wait(100)
return n * n
end)
print(expensive_fn(5)) -- Slow first call
print(expensive_fn(5)) -- Fast cached call
local cached_api_call = M.memoize_with_ttl(function(endpoint)
return vim.fn.system('curl ' .. endpoint)
end, 60000) -- Cache for 1 minute
return M22.5.2 Result Caching Decorator
local M = {}
-- Cache decorator
function M.cached(opts)
opts = opts or {}
local ttl = opts.ttl
local key_fn = opts.key or function(...) return vim.inspect({...}) end
return function(fn)
local cache = {}
return function(...)
local key = key_fn(...)
local now = vim.loop.now()
-- Check cache validity
if cache[key] then
if not ttl or (now - cache[key].timestamp < ttl) then
return unpack(cache[key].value)
end
end
-- Call function and cache result
local result = {fn(...)}
cache[key] = {
value = result,
timestamp = now
}
return unpack(result)
end
end
end
-- Usage with decorator pattern
local get_git_branch = M.cached({ ttl = 5000 })(function()
return vim.fn.system('git rev-parse --abbrev-ref HEAD'):gsub('\n', '')
end)
-- Multiple calls within 5 seconds use cache
print(get_git_branch())
print(get_git_branch()) -- Cached
return M22.6 Error Handling Patterns
22.6.1 Safe Function Execution
local M = {}
-- Safely execute function with error handling
function M.safe_call(fn, ...)
local ok, result = pcall(fn, ...)
if not ok then
vim.notify(
'Error: ' .. tostring(result),
vim.log.levels.ERROR
)
return nil, result
end
return result, nil
end
-- Try-catch pattern
function M.try(fn)
return {
catch = function(self, handler)
local ok, err = pcall(fn)
if not ok and handler then
handler(err)
end
return self
end,
finally = function(self, handler)
if handler then
handler()
end
return self
end
}
end
-- Retry with exponential backoff
function M.retry(fn, max_attempts, initial_delay)
max_attempts = max_attempts or 3
initial_delay = initial_delay or 100
local attempt = 1
local delay = initial_delay
while attempt <= max_attempts do
local ok, result = pcall(fn)
if ok then
return result
end
if attempt < max_attempts then
vim.wait(delay)
delay = delay * 2 -- Exponential backoff
attempt = attempt + 1
else
error('Max retry attempts reached')
end
end
end
-- Usage examples
M.try(function()
error('Something went wrong')
end)
:catch(function(err)
print('Caught error:', err)
end)
:finally(function()
print('Cleanup')
end)
local result = M.retry(function()
-- Potentially failing operation
return api_call()
end, 3, 100)
return M22.6.2 Validation and Assertions
local M = {}
-- Assert with custom message
function M.assert(condition, message, ...)
if not condition then
local msg = string.format(message or 'Assertion failed', ...)
error(msg, 2)
end
return condition
end
-- Type checking
function M.check_type(value, expected_type, name)
local actual_type = type(value)
if actual_type ~= expected_type then
error(string.format(
'%s: expected %s, got %s',
name or 'argument',
expected_type,
actual_type
), 2)
end
return value
end
-- Multiple type checking
function M.check_types(value, expected_types, name)
local actual_type = type(value)
for _, expected in ipairs(expected_types) do
if actual_type == expected then
return value
end
end
error(string.format(
'%s: expected one of %s, got %s',
name or 'argument',
table.concat(expected_types, ', '),
actual_type
), 2)
end
-- Range checking
function M.check_range(value, min, max, name)
M.check_type(value, 'number', name)
if value < min or value > max then
error(string.format(
'%s: value %d out of range [%d, %d]',
name or 'argument',
value,
min,
max
), 2)
end
return value
end
-- Not nil check
function M.check_not_nil(value, name)
if value == nil then
error(string.format(
'%s cannot be nil',
name or 'argument'
), 2)
end
return value
end
-- Function argument validator
function M.validate_args(args, schema)
for i, spec in ipairs(schema) do
local value = args[i]
local name = spec.name or ('arg' .. i)
-- Required check
if spec.required and value == nil then
error(string.format('Missing required argument: %s', name), 2)
end
-- Type check
if value ~= nil and spec.type then
if type(spec.type) == 'table' then
M.check_types(value, spec.type, name)
else
M.check_type(value, spec.type, name)
end
end
-- Custom validator
if value ~= nil and spec.validator then
if not spec.validator(value) then
error(string.format(
'Validation failed for argument: %s',
name
), 2)
end
end
end
end
-- Usage examples
function some_function(name, age, options)
M.validate_args({name, age, options}, {
{ name = 'name', type = 'string', required = true },
{ name = 'age', type = 'number', required = true,
validator = function(v) return v >= 0 and v <= 150 end },
{ name = 'options', type = 'table' }
})
-- Function implementation
end
return M22.7 Functional Programming Patterns
22.7.1 Functional Utilities
local M = {}
-- Map over list
function M.map(list, fn)
local result = {}
for i, value in ipairs(list) do
result[i] = fn(value, i)
end
return result
end
-- Filter list
function M.filter(list, predicate)
local result = {}
for _, value in ipairs(list) do
if predicate(value) then
table.insert(result, value)
end
end
return result
end
-- Reduce list
function M.reduce(list, fn, initial)
local acc = initial
for i, value in ipairs(list) do
if acc == nil and i == 1 then
acc = value
else
acc = fn(acc, value, i)
end
end
return acc
end
-- Find first matching item
function M.find(list, predicate)
for i, value in ipairs(list) do
if predicate(value, i) then
return value, i
end
end
return nil
end
-- Check if all items match
function M.all(list, predicate)
for i, value in ipairs(list) do
if not predicate(value, i) then
return false
end
end
return true
end
-- Check if any item matches
function M.any(list, predicate)
for i, value in ipairs(list) do
if predicate(value, i) then
return true
end
end
return false
end
-- Flatten nested list
function M.flatten(list)
local result = {}
for _, value in ipairs(list) do
if type(value) == 'table' then
vim.list_extend(result, M.flatten(value))
else
table.insert(result, value)
end
end
return result
end
-- Group by key
function M.group_by(list, key_fn)
local result = {}
for _, value in ipairs(list) do
local key = key_fn(value)
if not result[key] then
result[key] = {}
end
table.insert(result[key], value)
end
return result
end
-- Partition into two lists
function M.partition(list, predicate)
local truthy = {}
local falsy = {}
for _, value in ipairs(list) do
if predicate(value) then
table.insert(truthy, value)
else
table.insert(falsy, value)
end
end
return truthy, falsy
end
-- Usage examples
local numbers = {1, 2, 3, 4, 5}
local doubled = M.map(numbers, function(n) return n * 2 end)
-- {2, 4, 6, 8, 10}
local evens = M.filter(numbers, function(n) return n % 2 == 0 end)
-- {2, 4}
local sum = M.reduce(numbers, function(acc, n) return acc + n end, 0)
-- 15
return M22.7.2 Function Composition
local M = {}
-- Compose functions (right to left)
function M.compose(...)
local fns = {...}
return function(...)
local result = {...}
for i = #fns, 1, -1 do
result = {fns[i](unpack(result))}
end
return unpack(result)
end
end
-- Pipe functions (left to right)
function M.pipe(...)
local fns = {...}
return function(...)
local result = {...}
for _, fn in ipairs(fns) do
result = {fn(unpack(result))}
end
return unpack(result)
end
end
-- Partial application
function M.partial(fn, ...)
local bound_args = {...}
return function(...)
local args = vim.deepcopy(bound_args)
vim.list_extend(args, {...})
return fn(unpack(args))
end
end
-- Curry function
function M.curry(fn, arity)
arity = arity or debug.getinfo(fn, 'u').nparams
local function curried(args)
return function(...)
local new_args = vim.deepcopy(args)
vim.list_extend(new_args, {...})
if #new_args >= arity then
return fn(unpack(new_args))
else
return curried(new_args)
end
end
end
return curried({})
end
-- Usage examples
local add = function(a, b) return a + b end
local multiply = function(a, b) return a * b end
local square = function(x) return x * x end
-- Composition
local square_then_double = M.compose(
function(x) return x * 2 end,
square
)
print(square_then_double(3)) -- (3^2) * 2 = 18
-- Pipe
local double_then_square = M.pipe(
function(x) return x * 2 end,
square
)
print(double_then_square(3)) -- (3 * 2)^2 = 36
-- Partial application
local add5 = M.partial(add, 5)
print(add5(3)) -- 8
-- Currying
local curried_add = M.curry(add)
print(curried_add(5)(3)) -- 8
return M22.8 Plugin Development Patterns
22.8.1 Health Check System
-- lua/myplugin/health.lua
local M = {}
local health = vim.health or require('health')
function M.check()
health.report_start('My Plugin')
-- Check Neovim version
local required_version = {0, 8, 0}
if vim.fn.has('nvim-' .. table.concat(required_version, '.')) == 1 then
health.report_ok('Neovim version >= ' .. table.concat(required_version, '.'))
else
health.report_error(
'Neovim version too old',
'Upgrade to >= ' .. table.concat(required_version, '.')
)
end
-- Check for required plugins
local has_telescope = pcall(require, 'telescope')
if has_telescope then
health.report_ok('telescope.nvim is installed')
else
health.report_warn(
'telescope.nvim not found',
'Some features will be unavailable'
)
end
-- Check for external dependencies
if vim.fn.executable('rg') == 1 then
health.report_ok('ripgrep is installed')
else
health.report_error(
'ripgrep not found in PATH',
'Install ripgrep for search functionality'
)
end
-- Check configuration
local config = require('myplugin').config
if config.api_key then
health.report_ok('API key is configured')
else
health.report_info('API key not set (optional)')
end
-- Check file permissions
local data_dir = vim.fn.stdpath('data') .. '/myplugin'
if vim.fn.isdirectory(data_dir) == 1 then
if vim.fn.filewritable(data_dir) == 2 then
health.report_ok('Data directory is writable')
else
health.report_error(
'Data directory is not writable: ' .. data_dir
)
end
else
health.report_info('Data directory will be created on first use')
end
end
return M22.8.2 Command Builder Pattern
local M = {}
function M.create_command_builder(name)
local builder = {
_name = name,
_callback = nil,
_opts = {}
}
function builder:callback(fn)
self._callback = fn
return self
end
function builder:nargs(value)
self._opts.nargs = value
return self
end
function builder:range(value)
self._opts.range = value or true
return self
end
function builder:bang(value)
self._opts.bang = value or true
return self
end
function builder:complete(fn)
self._opts.complete = fn
return self
end
function builder:desc(text)
self._opts.desc = text
return self
end
function builder:buffer(bufnr)
self._opts.buffer = bufnr
return self
end
function builder:build()
if self._opts.buffer then
vim.api.nvim_buf_create_user_command(
self._opts.buffer,
self._name,
self._callback,
self._opts
)
else
vim.api.nvim_create_user_command(
self._name,
self._callback,
self._opts
)
end
end
return builder
end
-- Usage
M.create_command_builder('MyCommand')
:callback(function(opts)
print('Args:', opts.args)
end)
:nargs('*')
:desc('My custom command')
:complete('file')
:build()
return M22.8.3 Telescope Integration Pattern
local M = {}
function M.create_telescope_picker(opts)
local pickers = require('telescope.pickers')
local finders = require('telescope.finders')
local conf = require('telescope.config').values
local actions = require('telescope.actions')
local action_state = require('telescope.actions.state')
opts = opts or {}
return function(custom_opts)
custom_opts = custom_opts or {}
pickers.new(custom_opts, {
prompt_title = opts.prompt_title or 'Picker',
finder = finders.new_table {
results = opts.results or {},
entry_maker = opts.entry_maker or function(entry)
return {
value = entry,
display = entry,
ordinal = entry,
}
end
},
sorter = conf.generic_sorter(custom_opts),
attach_mappings = function(prompt_bufnr, map)
actions.select_default:replace(function()
local selection = action_state.get_selected_entry()
actions.close(prompt_bufnr)
if opts.on_select then
opts.on_select(selection.value)
end
end)
if opts.mappings then
for mode, mode_mappings in pairs(opts.mappings) do
for key, action in pairs(mode_mappings) do
map(mode, key, action)
end
end
end
return true
end,
}):find()
end
end
-- Usage example
local my_picker = M.create_telescope_picker({
prompt_title = 'My Custom Picker',
results = {'Option 1', 'Option 2', 'Option 3'},
on_select = function(item)
print('Selected:', item)
end,
mappings = {
i = {
['<C-d>'] = function(bufnr)
-- Custom action
print('Custom action triggered')
end
}
}
})
-- Call picker
my_picker()
return M✅ End of Chapter 22: Advanced Lua Patterns in Neovim
You now have comprehensive knowledge of advanced Lua programming patterns for Neovim, including module organization, asynchronous programming, event systems, state management, memoization, error handling, functional programming, and plugin development patterns. These techniques will help you build robust, maintainable, and performant Neovim plugins and configurations. The next chapter will explore LSP (Language Server Protocol) integration and custom language server configurations.
Chapter 23: Writing Neovim Plugins in Lua
This chapter guides you through the complete process of creating professional Neovim plugins using Lua. You’ll learn plugin architecture, best practices, testing strategies, documentation, and distribution methods.
23.1 Plugin Architecture Fundamentals
23.1.1 Standard Plugin Structure
-- Recommended directory structure:
-- my-plugin.nvim/
-- ├── lua/
-- │ └── my-plugin/
-- │ ├── init.lua -- Main entry point
-- │ ├── config.lua -- Configuration handling
-- │ ├── core.lua -- Core functionality
-- │ ├── ui.lua -- UI components
-- │ ├── utils.lua -- Utility functions
-- │ └── commands.lua -- User commands
-- ├── plugin/
-- │ └── my-plugin.lua -- Auto-load setup
-- ├── doc/
-- │ └── my-plugin.txt -- Help documentation
-- ├── README.md
-- └── LICENSE
-- lua/my-plugin/init.lua
local M = {}
-- Private state
local _state = {
initialized = false,
config = {}
}
-- Default configuration
M.defaults = {
enabled = true,
debug = false,
keymaps = {
enable = true,
prefix = '<leader>mp'
},
ui = {
border = 'rounded',
width = 80,
height = 20
}
}
-- Setup function (main API)
function M.setup(opts)
if _state.initialized then
vim.notify('my-plugin already initialized', vim.log.levels.WARN)
return
end
-- Merge user config with defaults
_state.config = vim.tbl_deep_extend('force', M.defaults, opts or {})
if not _state.config.enabled then
return
end
-- Initialize components
require('my-plugin.commands').setup(_state.config)
if _state.config.keymaps.enable then
require('my-plugin.keymaps').setup(_state.config)
end
_state.initialized = true
if _state.config.debug then
vim.notify('my-plugin initialized', vim.log.levels.INFO)
end
end
-- Public API functions
function M.do_something(arg)
if not _state.initialized then
vim.notify('my-plugin not initialized', vim.log.levels.ERROR)
return
end
require('my-plugin.core').do_something(arg, _state.config)
end
-- Get current configuration
function M.get_config()
return vim.deepcopy(_state.config)
end
-- Check if initialized
function M.is_initialized()
return _state.initialized
end
return M23.1.2 Lazy Loading Setup
-- plugin/my-plugin.lua
-- This file is auto-sourced by Neovim
if vim.g.loaded_my_plugin then
return
end
vim.g.loaded_my_plugin = 1
-- Create commands that lazy-load the plugin
vim.api.nvim_create_user_command('MyPlugin', function(opts)
require('my-plugin').do_something(opts.args)
end, {
nargs = '*',
desc = 'My plugin command'
})
-- Auto-load on specific events
vim.api.nvim_create_autocmd('FileType', {
pattern = {'lua', 'vim'},
callback = function()
-- Lazy load plugin
require('my-plugin').setup()
end,
once = true
})23.1.3 Configuration Management
-- lua/my-plugin/config.lua
local M = {}
-- Configuration schema for validation
M.schema = {
enabled = { type = 'boolean', default = true },
debug = { type = 'boolean', default = false },
log_level = {
type = 'string',
default = 'info',
enum = { 'debug', 'info', 'warn', 'error' }
},
keymaps = {
type = 'table',
default = {},
schema = {
enable = { type = 'boolean', default = true },
prefix = { type = 'string', default = '<leader>mp' }
}
},
ui = {
type = 'table',
default = {},
schema = {
border = {
type = 'string',
default = 'rounded',
enum = { 'none', 'single', 'double', 'rounded', 'solid', 'shadow' }
},
width = {
type = 'number',
default = 80,
validator = function(v) return v > 0 and v <= 200 end
},
height = {
type = 'number',
default = 20,
validator = function(v) return v > 0 and v <= 100 end
}
}
}
}
-- Validate configuration against schema
function M.validate(config, schema)
schema = schema or M.schema
for key, spec in pairs(schema) do
local value = config[key]
-- Use default if not provided
if value == nil then
if spec.default ~= nil then
config[key] = vim.deepcopy(spec.default)
end
value = config[key]
end
-- Type validation
if value ~= nil and spec.type then
local actual_type = type(value)
if actual_type ~= spec.type then
return false, string.format(
'Invalid type for "%s": expected %s, got %s',
key, spec.type, actual_type
)
end
end
-- Enum validation
if value ~= nil and spec.enum then
local valid = false
for _, allowed in ipairs(spec.enum) do
if value == allowed then
valid = true
break
end
end
if not valid then
return false, string.format(
'Invalid value for "%s": must be one of %s',
key, vim.inspect(spec.enum)
)
end
end
-- Custom validator
if value ~= nil and spec.validator then
if not spec.validator(value) then
return false, string.format(
'Validation failed for "%s"',
key
)
end
end
-- Nested validation
if spec.schema and type(value) == 'table' then
local ok, err = M.validate(value, spec.schema)
if not ok then
return false, key .. '.' .. err
end
end
end
return true, nil
end
-- Get configuration with validation
function M.get(user_config)
local config = vim.deepcopy(user_config or {})
local ok, err = M.validate(config)
if not ok then
error('Configuration error: ' .. err)
end
return config
end
-- Update configuration at runtime
function M.update(current_config, updates)
local new_config = vim.tbl_deep_extend('force', current_config, updates)
local ok, err = M.validate(new_config)
if not ok then
return nil, err
end
return new_config, nil
end
return M23.2 Core Functionality Implementation
23.2.1 Buffer Management
-- lua/my-plugin/buffer.lua
local M = {}
-- Create a scratch buffer
function M.create_scratch_buffer(opts)
opts = opts or {}
local buf = vim.api.nvim_create_buf(false, true)
-- Set buffer options
vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(buf, 'bufhidden', opts.bufhidden or 'wipe')
vim.api.nvim_buf_set_option(buf, 'swapfile', false)
vim.api.nvim_buf_set_option(buf, 'filetype', opts.filetype or 'my-plugin')
if opts.name then
vim.api.nvim_buf_set_name(buf, opts.name)
end
-- Set buffer local keymaps
if opts.keymaps then
for mode, mappings in pairs(opts.keymaps) do
for lhs, rhs in pairs(mappings) do
vim.api.nvim_buf_set_keymap(buf, mode, lhs, rhs, {
noremap = true,
silent = true,
nowait = true
})
end
end
end
return buf
end
-- Manage buffer content
function M.set_content(buf, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_buf_set_option(buf, 'modified', false)
end
-- Append to buffer
function M.append(buf, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
local line_count = vim.api.nvim_buf_line_count(buf)
vim.api.nvim_buf_set_lines(buf, line_count, line_count, false, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
end
-- Clear buffer
function M.clear(buf)
M.set_content(buf, {})
end
-- Add highlights to buffer
function M.add_highlight(buf, ns_id, hl_group, line, col_start, col_end)
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
hl_group,
line,
col_start,
col_end or -1
)
end
-- Create namespace for highlights
function M.create_namespace(name)
return vim.api.nvim_create_namespace(name)
end
-- Clear namespace highlights
function M.clear_namespace(buf, ns_id)
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
end
-- Check if buffer is valid
function M.is_valid(buf)
return buf and vim.api.nvim_buf_is_valid(buf)
end
-- Delete buffer safely
function M.delete(buf)
if M.is_valid(buf) then
vim.api.nvim_buf_delete(buf, { force = true })
end
end
return M23.2.2 Window Management
-- lua/my-plugin/window.lua
local M = {}
-- Create a floating window
function M.create_float(buf, opts)
opts = opts or {}
-- Calculate dimensions
local width = opts.width or 80
local height = opts.height or 20
-- Handle percentage values
if width < 1 then
width = math.floor(vim.o.columns * width)
end
if height < 1 then
height = math.floor(vim.o.lines * height)
end
-- Calculate position
local row = opts.row or math.floor((vim.o.lines - height) / 2)
local col = opts.col or math.floor((vim.o.columns - width) / 2)
-- Window configuration
local win_opts = {
relative = opts.relative or 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = opts.border or 'rounded',
focusable = opts.focusable ~= false,
zindex = opts.zindex or 50
}
if opts.title then
win_opts.title = opts.title
win_opts.title_pos = opts.title_pos or 'center'
end
-- Create window
local win = vim.api.nvim_open_win(buf, opts.enter ~= false, win_opts)
-- Set window options
if opts.wrap ~= nil then
vim.api.nvim_win_set_option(win, 'wrap', opts.wrap)
end
if opts.cursorline ~= nil then
vim.api.nvim_win_set_option(win, 'cursorline', opts.cursorline)
end
-- Set window local keymaps
if opts.keymaps then
for mode, mappings in pairs(opts.keymaps) do
for lhs, rhs in pairs(mappings) do
vim.api.nvim_buf_set_keymap(buf, mode, lhs, rhs, {
noremap = true,
silent = true,
nowait = true
})
end
end
end
return win
end
-- Create a split window
function M.create_split(buf, opts)
opts = opts or {}
-- Create split
local cmd = opts.vertical and 'vsplit' or 'split'
vim.cmd(cmd)
-- Get the new window
local win = vim.api.nvim_get_current_win()
-- Set buffer in window
vim.api.nvim_win_set_buf(win, buf)
-- Set dimensions
if opts.size then
if opts.vertical then
vim.api.nvim_win_set_width(win, opts.size)
else
vim.api.nvim_win_set_height(win, opts.size)
end
end
return win
end
-- Close window safely
function M.close(win)
if win and vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
end
-- Center cursor in window
function M.center_cursor(win)
if not vim.api.nvim_win_is_valid(win) then
return
end
local buf = vim.api.nvim_win_get_buf(win)
local line_count = vim.api.nvim_buf_line_count(buf)
local height = vim.api.nvim_win_get_height(win)
local target_line = math.floor(line_count / 2)
vim.api.nvim_win_set_cursor(win, { target_line, 0 })
vim.api.nvim_win_call(win, function()
vim.cmd('normal! zz')
end)
end
-- Make window manager class
function M.create_manager()
local manager = {
windows = {},
buffers = {}
}
function manager:create_window(opts)
local buf = require('my-plugin.buffer').create_scratch_buffer(opts.buffer)
local win = M.create_float(buf, opts.window)
table.insert(self.windows, win)
table.insert(self.buffers, buf)
return win, buf
end
function manager:close_all()
for _, win in ipairs(self.windows) do
M.close(win)
end
for _, buf in ipairs(self.buffers) do
require('my-plugin.buffer').delete(buf)
end
self.windows = {}
self.buffers = {}
end
return manager
end
return M23.2.3 User Interface Components
-- lua/my-plugin/ui.lua
local M = {}
local buffer = require('my-plugin.buffer')
local window = require('my-plugin.window')
-- Create a selection menu
function M.create_menu(items, opts)
opts = opts or {}
-- Create buffer
local buf = buffer.create_scratch_buffer({
filetype = 'my-plugin-menu'
})
-- Format items
local lines = {}
for i, item in ipairs(items) do
local line = string.format('%d. %s', i, item.text or tostring(item))
table.insert(lines, line)
end
buffer.set_content(buf, lines)
-- Create window
local win = window.create_float(buf, {
width = opts.width or 60,
height = math.min(#items + 2, opts.max_height or 20),
title = opts.title or 'Select Item',
border = opts.border or 'rounded',
cursorline = true
})
-- Add selection callback
vim.api.nvim_buf_set_keymap(buf, 'n', '<CR>', '', {
noremap = true,
silent = true,
callback = function()
local cursor = vim.api.nvim_win_get_cursor(win)
local line = cursor[1]
window.close(win)
buffer.delete(buf)
if opts.on_select and items[line] then
opts.on_select(items[line], line)
end
end
})
-- Add close keymaps
for _, key in ipairs({'q', '<Esc>'}) do
vim.api.nvim_buf_set_keymap(buf, 'n', key, '', {
noremap = true,
silent = true,
callback = function()
window.close(win)
buffer.delete(buf)
if opts.on_close then
opts.on_close()
end
end
})
end
return win, buf
end
-- Create an input dialog
function M.create_input(opts)
opts = opts or {}
vim.ui.input({
prompt = opts.prompt or 'Input: ',
default = opts.default or '',
}, function(input)
if input and opts.on_submit then
opts.on_submit(input)
elseif opts.on_cancel then
opts.on_cancel()
end
end)
end
-- Create a confirmation dialog
function M.create_confirm(message, opts)
opts = opts or {}
local choices = opts.choices or {'Yes', 'No'}
vim.ui.select(choices, {
prompt = message,
}, function(choice, idx)
if choice and opts.on_confirm then
opts.on_confirm(idx == 1, choice, idx)
end
end)
end
-- Create a progress indicator
function M.create_progress(opts)
opts = opts or {}
local progress = {
_current = 0,
_total = opts.total or 100,
_title = opts.title or 'Progress',
_buf = nil,
_win = nil,
_ns = buffer.create_namespace('my-plugin-progress')
}
function progress:start()
self._buf = buffer.create_scratch_buffer({
filetype = 'my-plugin-progress'
})
self._win = window.create_float(self._buf, {
width = 50,
height = 3,
title = self._title,
border = 'rounded'
})
self:_render()
end
function progress:update(current, message)
self._current = current
self._message = message
self:_render()
end
function progress:_render()
if not buffer.is_valid(self._buf) then
return
end
local percentage = math.floor((self._current / self._total) * 100)
local bar_width = 40
local filled = math.floor((percentage / 100) * bar_width)
local bar = string.rep('█', filled) .. string.rep('░', bar_width - filled)
local lines = {
string.format('%s %d%%', bar, percentage),
}
if self._message then
table.insert(lines, self._message)
end
buffer.set_content(self._buf, lines)
end
function progress:finish()
if self._win then
vim.defer_fn(function()
window.close(self._win)
buffer.delete(self._buf)
end, 500)
end
end
return progress
end
-- Create a notification
function M.notify(message, level, opts)
opts = opts or {}
vim.notify(message, level or vim.log.levels.INFO, {
title = opts.title or 'My Plugin',
timeout = opts.timeout or 3000,
on_open = opts.on_open,
on_close = opts.on_close
})
end
return M23.3 Command System
23.3.1 User Command Registration
-- lua/my-plugin/commands.lua
local M = {}
-- Command registry
local _commands = {}
-- Register a command
function M.register(name, handler, opts)
opts = opts or {}
_commands[name] = {
handler = handler,
opts = opts
}
vim.api.nvim_create_user_command(name, function(cmd_opts)
-- Parse arguments
local args = {}
if cmd_opts.args and cmd_opts.args ~= '' then
args = vim.split(cmd_opts.args, '%s+')
end
-- Execute handler
local ok, err = pcall(handler, {
args = args,
bang = cmd_opts.bang,
line1 = cmd_opts.line1,
line2 = cmd_opts.line2,
range = cmd_opts.range,
count = cmd_opts.count,
mods = cmd_opts.mods,
fargs = cmd_opts.fargs
})
if not ok then
vim.notify(
string.format('Command error: %s', err),
vim.log.levels.ERROR
)
end
end, opts)
end
-- Unregister a command
function M.unregister(name)
if _commands[name] then
pcall(vim.api.nvim_del_user_command, name)
_commands[name] = nil
end
end
-- Setup default commands
function M.setup(config)
-- Main command
M.register('MyPlugin', function(opts)
local subcommand = opts.args[1]
if not subcommand then
require('my-plugin.ui').notify('No subcommand specified', vim.log.levels.WARN)
return
end
-- Route to subcommand
local cmd_name = 'MyPlugin' .. subcommand:sub(1,1):upper() .. subcommand:sub(2)
if _commands[cmd_name] then
-- Remove subcommand from args
table.remove(opts.args, 1)
_commands[cmd_name].handler(opts)
else
require('my-plugin.ui').notify(
string.format('Unknown subcommand: %s', subcommand),
vim.log.levels.ERROR
)
end
end, {
nargs = '*',
desc = 'My plugin main command',
complete = function(arg_lead, cmd_line, cursor_pos)
local subcommands = {
'open', 'close', 'toggle', 'status', 'config'
}
return vim.tbl_filter(function(cmd)
return vim.startswith(cmd, arg_lead)
end, subcommands)
end
})
-- Subcommands
M.register('MyPluginOpen', function(opts)
require('my-plugin.core').open()
end, {
desc = 'Open my plugin interface'
})
M.register('MyPluginClose', function(opts)
require('my-plugin.core').close()
end, {
desc = 'Close my plugin interface'
})
M.register('MyPluginToggle', function(opts)
require('my-plugin.core').toggle()
end, {
desc = 'Toggle my plugin interface'
})
M.register('MyPluginStatus', function(opts)
local status = require('my-plugin').get_status()
print(vim.inspect(status))
end, {
desc = 'Show my plugin status'
})
M.register('MyPluginConfig', function(opts)
local action = opts.args[1]
if action == 'show' then
print(vim.inspect(require('my-plugin').get_config()))
elseif action == 'edit' then
vim.cmd('edit ' .. vim.fn.stdpath('config') .. '/lua/my-plugin/config.lua')
else
require('my-plugin.ui').notify('Usage: MyPluginConfig [show|edit]')
end
end, {
nargs = '?',
desc = 'Manage my plugin configuration',
complete = function(arg_lead)
return vim.tbl_filter(function(opt)
return vim.startswith(opt, arg_lead)
end, {'show', 'edit'})
end
})
end
return M23.3.2 Keymap Management
-- lua/my-plugin/keymaps.lua
local M = {}
-- Keymap registry
local _keymaps = {}
-- Register a keymap
function M.register(mode, lhs, rhs, opts)
opts = opts or {}
-- Store for later removal
table.insert(_keymaps, {
mode = mode,
lhs = lhs,
opts = opts
})
-- Set keymap
vim.keymap.set(mode, lhs, rhs, opts)
end
-- Unregister all keymaps
function M.unregister_all()
for _, map in ipairs(_keymaps) do
pcall(vim.keymap.del, map.mode, map.lhs, map.opts)
end
_keymaps = {}
end
-- Setup default keymaps
function M.setup(config)
local prefix = config.keymaps.prefix or '<leader>mp'
M.register('n', prefix .. 'o', function()
require('my-plugin.core').open()
end, {
desc = 'Open my plugin'
})
M.register('n', prefix .. 'c', function()
require('my-plugin.core').close()
end, {
desc = 'Close my plugin'
})
M.register('n', prefix .. 't', function()
require('my-plugin.core').toggle()
end, {
desc = 'Toggle my plugin'
})
M.register('n', prefix .. 's', function()
local status = require('my-plugin').get_status()
require('my-plugin.ui').notify(vim.inspect(status))
end, {
desc = 'Show my plugin status'
})
-- Visual mode keymaps
M.register('v', prefix .. 'f', function()
local start_pos = vim.fn.getpos("'<")
local end_pos = vim.fn.getpos("'>")
local lines = vim.api.nvim_buf_get_lines(
0,
start_pos[2] - 1,
end_pos[2],
false
)
require('my-plugin.core').process_selection(lines)
end, {
desc = 'Process selection with my plugin'
})
end
return M23.4 Autocommands and Events
23.4.1 Autocommand Management
-- lua/my-plugin/autocmds.lua
local M = {}
-- Autocommand group
local _augroup = nil
-- Setup autocommands
function M.setup(config)
_augroup = vim.api.nvim_create_augroup('MyPlugin', { clear = true })
-- File type specific setup
vim.api.nvim_create_autocmd('FileType', {
group = _augroup,
pattern = config.filetypes or {'lua', 'vim'},
callback = function(args)
require('my-plugin.core').setup_buffer(args.buf)
end
})
-- Buffer enter event
vim.api.nvim_create_autocmd('BufEnter', {
group = _augroup,
callback = function(args)
require('my-plugin.core').on_buffer_enter(args.buf)
end
})
-- Before save
vim.api.nvim_create_autocmd('BufWritePre', {
group = _augroup,
callback = function(args)
if config.auto_format then
require('my-plugin.core').format_buffer(args.buf)
end
end
})
-- After save
vim.api.nvim_create_autocmd('BufWritePost', {
group = _augroup,
callback = function(args)
require('my-plugin.core').on_buffer_save(args.buf)
end
})
-- Cursor hold (for auto-update)
if config.auto_update then
vim.api.nvim_create_autocmd('CursorHold', {
group = _augroup,
callback = function()
require('my-plugin.core').update()
end
})
end
-- Vim leave (cleanup)
vim.api.nvim_create_autocmd('VimLeavePre', {
group = _augroup,
callback = function()
require('my-plugin.core').cleanup()
end
})
end
-- Clear all autocommands
function M.clear()
if _augroup then
vim.api.nvim_clear_autocmds({ group = _augroup })
end
end
return M23.4.2 Custom Event System
-- lua/my-plugin/events.lua
local M = {}
-- Event emitter instance
local _emitter = nil
-- Initialize event system
function M.init()
if not _emitter then
_emitter = require('my-plugin.lib.event-emitter').new()
end
return _emitter
end
-- Emit plugin events
function M.emit(event, data)
if _emitter then
_emitter:emit(event, data)
end
end
-- Subscribe to plugin events
function M.on(event, callback)
M.init()
return _emitter:on(event, callback)
end
-- One-time subscription
function M.once(event, callback)
M.init()
return _emitter:once(event, callback)
end
-- Unsubscribe
function M.off(event, callback)
if _emitter then
_emitter:off(event, callback)
end
end
-- Setup event bridges
function M.setup()
M.init()
local augroup = vim.api.nvim_create_augroup('MyPluginEventBridge', { clear = true })
-- Bridge Neovim autocommands to plugin events
vim.api.nvim_create_autocmd('BufEnter', {
group = augroup,
callback = function(args)
M.emit('buffer:enter', {
buf = args.buf,
file = args.file
})
end
})
vim.api.nvim_create_autocmd('BufWritePost', {
group = augroup,
callback = function(args)
M.emit('buffer:save', {
buf = args.buf,
file = args.file
})
end
})
vim.api.nvim_create_autocmd('CursorMoved', {
group = augroup,
callback = function()
M.emit('cursor:moved', {
pos = vim.api.nvim_win_get_cursor(0)
})
end
})
end
-- Example usage in plugin:
-- events.on('buffer:save', function(data)
-- print('Buffer saved:', data.file)
-- end)
return M23.5 Testing Your Plugin
23.5.1 Unit Testing Setup
-- tests/minimal_init.lua
-- Minimal init for testing
-- Add plugin to runtime path
vim.cmd([[set runtimepath=$VIMRUNTIME]])
vim.cmd([[set packpath=/tmp/nvim/site]])
local package_root = '/tmp/nvim/site/pack'
local install_path = package_root .. '/packer/start/packer.nvim'
local function load_plugins()
require('packer').startup({
function(use)
use 'wbthomason/packer.nvim'
use 'nvim-lua/plenary.nvim' -- For testing
-- Your plugin
use { '/path/to/your/plugin', as = 'my-plugin' }
end,
config = {
package_root = package_root,
compile_path = install_path .. '/plugin/packer_compiled.lua'
}
})
end
if vim.fn.isdirectory(install_path) == 0 then
vim.fn.system({
'git', 'clone', 'https://github.com/wbthomason/packer.nvim', install_path
})
end
load_plugins()
require('packer').sync()23.5.2 Writing Tests with Plenary
-- tests/my-plugin_spec.lua
local plugin = require('my-plugin')
describe('my-plugin', function()
before_each(function()
-- Reset plugin state
plugin.reset()
end)
after_each(function()
-- Cleanup
plugin.cleanup()
end)
describe('setup', function()
it('should initialize with default config', function()
plugin.setup()
assert.is_true(plugin.is_initialized())
end)
it('should merge user config with defaults', function()
plugin.setup({ debug = true })
local config = plugin.get_config()
assert.is_true(config.debug)
assert.is_not_nil(config.enabled)
end)
it('should validate configuration', function()
assert.has_error(function()
plugin.setup({ invalid_option = 'test' })
end)
end)
end)
describe('core functionality', function()
before_each(function()
plugin.setup()
end)
it('should open interface', function()
plugin.open()
assert.is_true(plugin.is_open())
end)
it('should close interface', function()
plugin.open()
plugin.close()
assert.is_false(plugin.is_open())
end)
it('should toggle interface', function()
local initial_state = plugin.is_open()
plugin.toggle()
assert.are_not.equal(initial_state, plugin.is_open())
end)
end)
describe('buffer management', function()
it('should create scratch buffer', function()
local buf = require('my-plugin.buffer').create_scratch_buffer()
assert.is_true(vim.api.nvim_buf_is_valid(buf))
end)
it('should set buffer content', function()
local buf = require('my-plugin.buffer').create_scratch_buffer()
local content = {'line1', 'line2', 'line3'}
require('my-plugin.buffer').set_content(buf, content)
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
assert.are.same(content, lines)
end)
end)
describe('event system', function()
it('should emit and receive events', function()
local events = require('my-plugin.events')
local called = false
local received_data = nil
events.on('test:event', function(data)
called = true
received_data = data
end)
events.emit('test:event', { value = 123 })
assert.is_true(called)
assert.are.equal(123, received_data.value)
end)
it('should handle one-time events', function()
local events = require('my-plugin.events')
local call_count = 0
events.once('test:once', function()
call_count = call_count + 1
end)
events.emit('test:once')
events.emit('test:once')
assert.are.equal(1, call_count)
end)
end)
end)23.5.3 Running Tests
#!/bin/bash
# tests/run_tests.sh
# Run tests with minimal init
nvim --headless --noplugin -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"
-- Makefile
.PHONY: test test-watch lint format
test:
@nvim --headless --noplugin -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"
test-watch:
@echo "Watching for changes..."
@while true; do \
inotifywait -r -e modify lua/ tests/; \
make test; \
done
lint:
@luacheck lua/ --globals vim
format:
@stylua lua/ tests/23.6 Documentation
23.6.1 Help File Format
*my-plugin.txt* Description of my plugin
Author: Your Name <email@example.com>
License: MIT
Version: 1.0.0
==============================================================================
CONTENTS *my-plugin-contents*
1. Introduction ...................... |my-plugin-introduction|
2. Requirements ...................... |my-plugin-requirements|
3. Installation ...................... |my-plugin-installation|
4. Configuration ..................... |my-plugin-configuration|
5. Usage ............................. |my-plugin-usage|
6. Commands .......................... |my-plugin-commands|
7. API ............................... |my-plugin-api|
8. Highlights ........................ |my-plugin-highlights|
9. FAQ ............................... |my-plugin-faq|
10. Contributing ...................... |my-plugin-contributing|
==============================================================================
INTRODUCTION *my-plugin-introduction*
My Plugin is a Neovim plugin that does amazing things. It provides a
seamless interface for managing your workflow efficiently.
Features:~
• Feature one with detailed description
• Feature two with benefits
• Feature three with use cases
==============================================================================
REQUIREMENTS *my-plugin-requirements*
• Neovim >= 0.8.0
• Optional: ripgrep for search functionality
• Optional: telescope.nvim for picker integration
==============================================================================
INSTALLATION *my-plugin-installation*
Using lazy.nvim:~
>lua
{
'username/my-plugin.nvim',
dependencies = { 'nvim-lua/plenary.nvim' },
config = function()
require('my-plugin').setup({
-- your configuration
})
end
}
<
Using packer.nvim:~
>lua
use {
'username/my-plugin.nvim',
requires = { 'nvim-lua/plenary.nvim' },
config = function()
require('my-plugin').setup()
end
}
<
==============================================================================
CONFIGURATION *my-plugin-configuration*
*my-plugin.setup()*
setup({opts})
Configure the plugin. This must be called to initialize the plugin.
Parameters:~
{opts} (table|nil) Configuration options
Valid keys for {opts}:~
• {enabled} (boolean, default: true)
Enable or disable the plugin
• {debug} (boolean, default: false)
Enable debug mode
• {log_level} (string, default: 'info')
Logging level: 'debug', 'info', 'warn', 'error'
• {keymaps} (table)
Keymap configuration
• {enable} (boolean, default: true)
• {prefix} (string, default: '<leader>mp')
• {ui} (table)
UI configuration
• {border} (string, default: 'rounded')
Border style: 'none', 'single', 'double', 'rounded'
• {width} (number, default: 80)
• {height} (number, default: 20)
Example:~
>lua
require('my-plugin').setup({
debug = true,
keymaps = {
prefix = '<leader>p'
},
ui = {
border = 'double',
width = 100
}
})
<
==============================================================================
USAGE *my-plugin-usage*
Basic usage:~
1. Open the interface with |:MyPluginOpen|
2. Navigate using standard Vim motions
3. Press <CR> to select an item
4. Press <Esc> or 'q' to close
Keymaps:~
Default prefix: `<leader>mp`
• {prefix}o - Open interface
• {prefix}c - Close interface
• {prefix}t - Toggle interface
• {prefix}s - Show status
==============================================================================
COMMANDS *my-plugin-commands*
:MyPlugin {subcommand} *:MyPlugin*
Main command for the plugin. Available subcommands:
• open - Open the plugin interface
• close - Close the plugin interface
• toggle - Toggle the plugin interface
• status - Show plugin status
• config - Manage configuration
:MyPluginOpen *:MyPluginOpen*
Open the plugin interface.
:MyPluginClose *:MyPluginClose*
Close the plugin interface.
:MyPluginToggle *:MyPluginToggle*
Toggle the plugin interface.
:MyPluginStatus *:MyPluginStatus*
Display current plugin status.
:MyPluginConfig {action} *:MyPluginConfig*
Manage plugin configuration.
Actions:~
• show - Display current configuration
• edit - Open configuration file
==============================================================================
API *my-plugin-api*
*my-plugin.open()*
open()
Open the plugin interface.
Usage:~
>lua
require('my-plugin').open()
<
*my-plugin.close()*
close()
Close the plugin interface.
*my-plugin.toggle()*
toggle()
Toggle the plugin interface.
*my-plugin.get_config()*
get_config()
Get current configuration.
Returns:~
(table) Current configuration
*my-plugin.get_status()*
get_status()
Get plugin status information.
Returns:~
(table) Status information containing:
• {initialized} (boolean)
• {open} (boolean)
• {version} (string)
==============================================================================
HIGHLIGHTS *my-plugin-highlights*
MyPluginNormal *hl-MyPluginNormal*
Normal text in plugin interface.
Default: links to |hl-Normal|
MyPluginBorder *hl-MyPluginBorder*
Border of floating windows.
Default: links to |hl-FloatBorder|
MyPluginTitle *hl-MyPluginTitle*
Title text in plugin interface.
Default: links to |hl-Title|
MyPluginSelected *hl-MyPluginSelected*
Selected item highlight.
Default: links to |hl-Visual|
To customize:~
>vim
highlight MyPluginNormal guifg=#abb2bf guibg=#282c34
highlight MyPluginBorder guifg=#61afef
<
==============================================================================
FAQ *my-plugin-faq*
Q: The plugin doesn't work after installation
A: Make sure you've called |my-plugin.setup()| in your configuration.
Q: How do I change the default keymaps?
A: Pass a custom prefix in the setup configuration:
>lua
require('my-plugin').setup({
keymaps = { prefix = '<leader>p' }
})
<
Q: Can I disable certain features?
A: Yes, see |my-plugin-configuration| for all available options.
==============================================================================
CONTRIBUTING *my-plugin-contributing*
Contributions are welcome! Please visit:
https://github.com/username/my-plugin.nvim
Bug reports and feature requests:
https://github.com/username/my-plugin.nvim/issues
==============================================================================
vim:tw=78:ts=8:ft=help:norl:
23.6.2 README Structure
# my-plugin.nvim
> A powerful Neovim plugin for [purpose]
[](https://github.com/username/my-plugin.nvim/actions)
[](LICENSE)
## ✨ Features
- 🚀 Feature one with emoji
- ⚡ Feature two showing benefits
- 🎯 Feature three with use case
- 📦 Zero dependencies (or list them)
## 📋 Requirements
- Neovim >= 0.8.0
- Optional: [ripgrep](https://github.com/BurntSushi/ripgrep) for search
- Optional: [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)
## 📦 Installation
### [lazy.nvim](https://github.com/folke/lazy.nvim)
```lua
{
'username/my-plugin.nvim',
dependencies = { 'nvim-lua/plenary.nvim' },
config = function()
require('my-plugin').setup({
-- your configuration
})
end,
keys = {
{ '<leader>mp', '<cmd>MyPluginToggle<cr>', desc = 'Toggle My Plugin' }
}
}
### [packer.nvim](https://github.com/wbthomason/packer.nvim)
lua
use {
'username/my-plugin.nvim',
requires = { 'nvim-lua/plenary.nvim' },
config = function()
require('my-plugin').setup()
end
}
## ⚙️ Configuration
Default configuration:
lua
require('my-plugin').setup({
enabled = true,
debug = false,
log_level = 'info',
keymaps = {
enable = true,
prefix = '<leader>mp'
},
ui = {
border = 'rounded',
width = 80,
height = 20
}
})
## 🚀 Usage
### Basic Usage
1. Open the interface: `:MyPluginOpen` or `<leader>mpo`
2. Navigate with `j`/`k`
3. Select with `<CR>`
4. Close with `q` or `<Esc>`
### Commands
- `:MyPlugin {subcommand}` - Main command
- `:MyPluginOpen` - Open interface
- `:MyPluginClose` - Close interface
- `:MyPluginToggle` - Toggle interface
- `:MyPluginStatus` - Show status
### Lua API
lua
local plugin = require('my-plugin')
-- Open interface
plugin.open()
-- Close interface
plugin.close()
-- Toggle interface
plugin.toggle()
-- Get configuration
local config = plugin.get_config()
-- Get status
local status = plugin.get_status()
## 🎨 Customization
### Highlight Groups
vim
" Customize colors
highlight MyPluginNormal guifg=#abb2bf guibg=#282c34
highlight MyPluginBorder guifg=#61afef
highlight MyPluginTitle guifg=#e5c07b gui=bold
highlight MyPluginSelected guifg=#282c34 guibg=#61afef
### Custom Keymaps
lua
require('my-plugin').setup({
keymaps = {
prefix = '<leader>p',
-- Disable default keymaps
enable = false
}
})
-- Define your own keymaps
vim.keymap.set('n', '<leader>po', '<cmd>MyPluginOpen<cr>')
vim.keymap.set('n', '<leader>pc', '<cmd>MyPluginClose<cr>')
## 🔧 Advanced Usage
### Integration with Other Plugins
lua
-- Telescope integration
require('my-plugin').setup({
telescope = {
enable = true,
layout_strategy = 'vertical'
}
})
-- LSP integration
require('my-plugin').setup({
lsp = {
enable = true,
servers = { 'lua_ls', 'tsserver' }
}
})
### Events
lua
-- Listen to plugin events
local events = require('my-plugin.events')
events.on('buffer:save', function(data)
print('Buffer saved:', data.file)
end)
events.on('interface:open', function()
print('Interface opened')
end)
## 🧪 Development
### Running Tests
bash
make test
### Linting
bash
make lint
### Formatting
bash
make format
## 🤝 Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md).
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing`)
5. Open a Pull Request
## 📝 License
[MIT](LICENSE) © Your Name
## 🙏 Acknowledgments
- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) for utilities
- [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) for inspiration
- Community contributors
## 📚 Resources
- [Documentation](doc/my-plugin.txt)
- [Wiki](https://github.com/username/my-plugin.nvim/wiki)
- [Issue Tracker](https://github.com/username/my-plugin.nvim/issues)
- [Discussions](https://github.com/username/my-plugin.nvim/discussions)
## 23.7 Publishing and Distribution
### 23.7.1 Release Checklist
```markdown
# Release Checklist
## Pre-release
- [ ] All tests passing
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
- [ ] Version bumped in all files
- [ ] No debug code or console.log statements
- [ ] All TODOs addressed or documented
- [ ] Screenshots/GIFs updated if UI changed
## Testing
- [ ] Manual testing on Linux
- [ ] Manual testing on macOS
- [ ] Manual testing on Windows (if applicable)
- [ ] Tested with minimal config
- [ ] Tested with various plugin managers
## Documentation
- [ ] README.md complete and accurate
- [ ] Help file (doc/*.txt) up to date
- [ ] API documentation complete
- [ ] Examples working
- [ ] Migration guide (if breaking changes)
## Release
- [ ] Tag created (vX.Y.Z)
- [ ] GitHub release created
- [ ] Announcement posted
- [ ] Added to plugin lists/registries23.7.2 Version Management
-- lua/my-plugin/version.lua
local M = {}
M.VERSION = '1.0.0'
M.VERSION_MAJOR = 1
M.VERSION_MINOR = 0
M.VERSION_PATCH = 0
function M.compare(version_string)
local parts = vim.split(version_string, '%.')
local major = tonumber(parts[1]) or 0
local minor = tonumber(parts[2]) or 0
local patch = tonumber(parts[3]) or 0
if M.VERSION_MAJOR ~= major then
return M.VERSION_MAJOR > major and 1 or -1
end
if M.VERSION_MINOR ~= minor then
return M.VERSION_MINOR > minor and 1 or -1
end
if M.VERSION_PATCH ~= patch then
return M.VERSION_PATCH > patch and 1 or -1
end
return 0
end
function M.is_compatible(required_version)
return M.compare(required_version) >= 0
end
function M.check_nvim_version(required)
if not vim.fn.has('nvim-' .. required) then
vim.notify(
string.format(
'my-plugin requires Neovim >= %s, but you have %s',
required,
vim.fn.execute('version'):match('NVIM v(%S+)')
),
vim.log.levels.ERROR
)
return false
end
return true
end
return M23.7.3 GitHub Actions CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
nvim_version: ['v0.8.0', 'v0.9.0', 'nightly']
steps:
- uses: actions/checkout@v3
- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: ${{ matrix.nvim_version }}
- name: Install dependencies
run: |
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim \
~/.local/share/nvim/site/pack/vendor/start/plenary.nvim
ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start
- name: Run tests
run: |
nvim --version
make test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Lua
uses: leafo/gh-actions-lua@v9
with:
luaVersion: "5.1"
- name: Setup Luarocks
uses: leafo/gh-actions-luarocks@v4
- name: Install luacheck
run: luarocks install luacheck
- name: Run luacheck
run: make lint
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Stylua
uses: JohnnyMorganz/stylua-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check lua/✅ End of Chapter 23: Writing Neovim Plugins in Lua
You now have comprehensive knowledge of creating professional Neovim plugins, from architecture and core functionality to testing, documentation, and distribution. This chapter covered the complete plugin development lifecycle, including buffer/window management, UI components, command systems, event handling, testing strategies, and publishing workflows. The next chapter will explore LSP integration and creating custom language server configurations for your plugins.
Chapter 24: Syntax Highlighting
This chapter explores Vim and Neovim’s syntax highlighting systems, from traditional Vim syntax to modern Tree-sitter integration. You’ll learn how to create custom syntax definitions, work with highlight groups, and leverage Tree-sitter for advanced highlighting.
24.1 Understanding Syntax Highlighting Fundamentals
24.1.1 How Syntax Highlighting Works
" Syntax highlighting operates in layers:
" 1. Syntax items define patterns (syntax match, keyword, region)
" 2. Highlight groups define visual appearance
" 3. Links connect syntax items to highlight groups
" Basic flow:
" Pattern Match → Syntax Group → Highlight Group → Visual Style
24.1.2 Checking Current Syntax State
" Show syntax group under cursor
:echo synIDattr(synID(line('.'), col('.'), 1), 'name')
" Show linked highlight group
:echo synIDattr(synIDtrans(synID(line('.'), col('.'), 1)), 'name')
" Show full syntax stack
:echo map(synstack(line('.'), col('.')), 'synIDattr(v:val, "name")')
" Function to show complete syntax info
function! SyntaxInfo()
let stack = synstack(line('.'), col('.'))
let info = []
for id in stack
let name = synIDattr(id, 'name')
let trans = synIDattr(synIDtrans(id), 'name')
call add(info, name . ' -> ' . trans)
endfor
echo join(info, ' | ')
endfunction
nnoremap <F10> :call SyntaxInfo()<CR>
24.1.3 Built-in Highlight Groups
" Standard highlight groups (partial list)
:highlight Comment " Comments
:highlight Constant " Constants (strings, numbers, booleans)
:highlight String " String constants
:highlight Number " Number constants
:highlight Boolean " Boolean constants
:highlight Identifier " Variable names
:highlight Function " Function names
:highlight Statement " Keywords (if, for, while)
:highlight Conditional " if, else, switch
:highlight Repeat " for, while, repeat
:highlight Label " case, default
:highlight Operator " +, -, *, /
:highlight Keyword " Other keywords
:highlight Exception " try, catch, throw
:highlight PreProc " Preprocessor directives
:highlight Include " #include, import, use
:highlight Define " #define
:highlight Macro " Macros
:highlight Type " int, char, class
:highlight StorageClass " static, const, volatile
:highlight Structure " struct, union, enum
:highlight Typedef " typedef
:highlight Special " Special symbols
:highlight SpecialChar " Escape sequences
:highlight Tag " HTML/XML tags
:highlight Delimiter " Brackets, braces
:highlight SpecialComment " Special comments
:highlight Debug " Debug statements
:highlight Underlined " Underlined text
:highlight Ignore " Invisible text
:highlight Error " Error highlights
:highlight Todo " TODO, FIXME, XXX
" View all highlight groups
:so $VIMRUNTIME/syntax/hitest.vim
24.2 Creating Custom Vim Syntax Files
24.2.1 Basic Syntax File Structure
" syntax/myfiletype.vim
" Vim syntax file
" Language: My Custom Language
" Maintainer: Your Name
" Latest Revision: 2025-10-19
if exists("b:current_syntax")
finish
endif
" Keywords
syntax keyword myKeyword if else while for return
syntax keyword myBoolean true false
syntax keyword myNull null undefined
" Operators
syntax match myOperator "\(+\|-\|\*\|/\|=\|<\|>\)"
" Numbers
syntax match myNumber "\<\d\+\>"
syntax match myFloat "\<\d\+\.\d\+\>"
" Strings
syntax region myString start='"' end='"' contains=myEscape
syntax region myString start="'" end="'" contains=myEscape
syntax match myEscape "\\." contained
" Comments
syntax keyword myTodo TODO FIXME XXX NOTE contained
syntax match myComment "//.*$" contains=myTodo
syntax region myComment start="/\*" end="\*/" contains=myTodo
" Functions
syntax match myFunction "\w\+\s*("me=e-1
" Highlight links
highlight default link myKeyword Keyword
highlight default link myBoolean Boolean
highlight default link myNull Constant
highlight default link myOperator Operator
highlight default link myNumber Number
highlight default link myFloat Float
highlight default link myString String
highlight default link myEscape SpecialChar
highlight default link myComment Comment
highlight default link myTodo Todo
highlight default link myFunction Function
let b:current_syntax = "myfiletype"
24.2.2 Advanced Syntax Patterns
" syntax/advanced.vim
" Case-insensitive matching
syntax case ignore
syntax keyword myKeyword SELECT FROM WHERE
syntax case match
" Nextgroup and containment
syntax match myType "\<\(int\|float\|string\)\>" nextgroup=myIdentifier skipwhite
syntax match myIdentifier "\w\+" contained
" Clusters for grouping
syntax cluster myExpressions contains=myNumber,myString,myVariable
syntax region myParens start="(" end=")" contains=@myExpressions
" Regions with skip patterns
syntax region myString start=+"+ skip=+\\"+ end=+"+ contains=myEscape
" Multi-line regions
syntax region myBlock start="{" end="}" fold transparent contains=ALL
" Transparent groups (don't affect highlighting)
syntax region myTransparent start="(" end=")" transparent contains=ALL
" Synchronization for large files
syntax sync minlines=50 maxlines=500
syntax sync match mySync grouphere myBlock "{"
syntax sync match mySync groupthere myBlock "}"
" Folding integration
syntax region myFunction start="function" end="endfunction" fold
setlocal foldmethod=syntax
" Concealing (hide/replace characters)
syntax match myArrow "->" conceal cchar=→
syntax match myLambda "lambda" conceal cchar=λ
setlocal conceallevel=2
24.2.3 Complex Syntax Example: Custom Config Language
" syntax/customconfig.vim
" Syntax for a YAML-like configuration language
if exists("b:current_syntax")
finish
endif
" Comments
syntax match configComment "#.*$" contains=configTodo
syntax keyword configTodo TODO FIXME XXX NOTE contained
" Keys (before colon)
syntax match configKey "^\s*\w\+\ze:" contains=configSpecialKey
syntax keyword configSpecialKey version name port host contained
" Values
syntax match configString "\w\+" contained
syntax region configQuotedString start='"' end='"' skip='\\"'
syntax region configQuotedString start="'" end="'" skip="\\'"
" Numbers and Booleans
syntax match configNumber "\<\d\+\>" contained
syntax match configFloat "\<\d\+\.\d\+\>" contained
syntax keyword configBoolean true false yes no on off contained
" Special values
syntax keyword configNull null nil contained
" Arrays
syntax region configArray start="\[" end="\]" contains=configNumber,configString,configQuotedString,configBoolean,configNull
" Nested structures (indentation-based)
syntax match configIndent "^\s\+"
" Environment variables
syntax match configEnvVar "\$\w\+" contained
syntax match configEnvVar "\${\w\+}" contained
" Key-value pairs with value types
syntax region configValue start=":" end="$" contained contains=configNumber,configFloat,configBoolean,configNull,configQuotedString,configEnvVar,configArray
" Complete line pattern
syntax match configLine "^\s*\w\+:.*$" contains=configKey,configValue
" Special sections
syntax region configSection start="^\w\+:$" end="^\w" contains=configKey fold
" Highlighting
highlight default link configComment Comment
highlight default link configTodo Todo
highlight default link configKey Identifier
highlight default link configSpecialKey Keyword
highlight default link configString String
highlight default link configQuotedString String
highlight default link configNumber Number
highlight default link configFloat Float
highlight default link configBoolean Boolean
highlight default link configNull Constant
highlight default link configEnvVar PreProc
let b:current_syntax = "customconfig"
24.3 Color Schemes and Highlight Customization
24.3.1 Creating a Custom Color Scheme
" colors/myscheme.vim
" A custom color scheme
" Initialize
highlight clear
if exists("syntax_on")
syntax reset
endif
let g:colors_name = "myscheme"
" Helper function for setting highlights
function! s:HL(group, fg, bg, attr)
let l:cmd = 'highlight ' . a:group
if !empty(a:fg)
let l:cmd .= ' guifg=' . a:fg . ' ctermfg=' . a:fg
endif
if !empty(a:bg)
let l:cmd .= ' guibg=' . a:bg . ' ctermbg=' . a:bg
endif
if !empty(a:attr)
let l:cmd .= ' gui=' . a:attr . ' cterm=' . a:attr
endif
execute l:cmd
endfunction
" Color palette
let s:black = '#282c34'
let s:white = '#abb2bf'
let s:red = '#e06c75'
let s:green = '#98c379'
let s:yellow = '#e5c07b'
let s:blue = '#61afef'
let s:magenta = '#c678dd'
let s:cyan = '#56b6c2'
let s:gray = '#5c6370'
let s:light_gray = '#3e4452'
" UI Elements
call s:HL('Normal', s:white, s:black, '')
call s:HL('LineNr', s:gray, s:black, '')
call s:HL('CursorLine', '', s:light_gray, 'none')
call s:HL('CursorLineNr', s:yellow, s:light_gray, 'bold')
call s:HL('Visual', '', s:light_gray, '')
call s:HL('MatchParen', s:cyan, s:light_gray, 'bold')
" Syntax Groups
call s:HL('Comment', s:gray, '', 'italic')
call s:HL('Constant', s:cyan, '', '')
call s:HL('String', s:green, '', '')
call s:HL('Number', s:yellow, '', '')
call s:HL('Boolean', s:yellow, '', '')
call s:HL('Identifier', s:red, '', '')
call s:HL('Function', s:blue, '', '')
call s:HL('Statement', s:magenta, '', '')
call s:HL('Keyword', s:magenta, '', '')
call s:HL('Type', s:yellow, '', '')
call s:HL('Special', s:cyan, '', '')
call s:HL('PreProc', s:yellow, '', '')
" Git diff
call s:HL('DiffAdd', s:green, s:light_gray, '')
call s:HL('DiffChange', s:yellow, s:light_gray, '')
call s:HL('DiffDelete', s:red, s:light_gray, '')
call s:HL('DiffText', s:blue, s:light_gray, 'bold')
" Cleanup
delfunction s:HL
24.3.2 Dynamic Highlight Modification
" Modify highlights at runtime
function! AdjustHighlights()
" Make comments more subtle
highlight Comment guifg=#5c6370 ctermfg=59 gui=italic
" Bold keywords
highlight Statement gui=bold cterm=bold
" Custom colors for specific groups
highlight Todo guifg=#e5c07b guibg=#3e4452 gui=bold
" Link groups
highlight link pythonFunction Function
highlight link luaFunction Function
endfunction
autocmd ColorScheme * call AdjustHighlights()
" Toggle between light and dark variants
function! ToggleBackground()
if &background ==# 'dark'
set background=light
highlight Normal guibg=#ffffff guifg=#000000
else
set background=dark
highlight Normal guibg=#282c34 guifg=#abb2bf
endif
endfunction
nnoremap <leader>tb :call ToggleBackground()<CR>
24.3.3 Highlight Group Utilities
-- lua/highlight_utils.lua
local M = {}
-- Get highlight group attributes
function M.get_hl(group)
local hl = vim.api.nvim_get_hl_by_name(group, true)
return {
fg = hl.foreground and string.format('#%06x', hl.foreground),
bg = hl.background and string.format('#%06x', hl.background),
bold = hl.bold,
italic = hl.italic,
underline = hl.underline,
undercurl = hl.undercurl,
strikethrough = hl.strikethrough
}
end
-- Set highlight group
function M.set_hl(group, opts)
vim.api.nvim_set_hl(0, group, opts)
end
-- Copy highlight from one group to another
function M.copy_hl(from, to)
local hl = M.get_hl(from)
M.set_hl(to, hl)
end
-- Blend two colors
function M.blend_colors(color1, color2, ratio)
local r1 = tonumber(color1:sub(2, 3), 16)
local g1 = tonumber(color1:sub(4, 5), 16)
local b1 = tonumber(color1:sub(6, 7), 16)
local r2 = tonumber(color2:sub(2, 3), 16)
local g2 = tonumber(color2:sub(4, 5), 16)
local b2 = tonumber(color2:sub(6, 7), 16)
local r = math.floor(r1 * ratio + r2 * (1 - ratio))
local g = math.floor(g1 * ratio + g2 * (1 - ratio))
local b = math.floor(b1 * ratio + b2 * (1 - ratio))
return string.format('#%02x%02x%02x', r, g, b)
end
-- Darken a color
function M.darken(color, amount)
return M.blend_colors(color, '#000000', 1 - amount)
end
-- Lighten a color
function M.lighten(color, amount)
return M.blend_colors(color, '#ffffff', 1 - amount)
end
-- Create gradient of highlight groups
function M.create_gradient(base_group, prefix, steps)
local base_hl = M.get_hl(base_group)
for i = 1, steps do
local ratio = i / steps
local new_fg = base_hl.fg and M.blend_colors(base_hl.fg, '#ffffff', ratio)
local new_bg = base_hl.bg and M.blend_colors(base_hl.bg, '#000000', ratio)
M.set_hl(prefix .. i, {
fg = new_fg,
bg = new_bg,
bold = base_hl.bold,
italic = base_hl.italic
})
end
end
-- Apply highlight to buffer range
function M.highlight_range(buf, ns, group, start_line, end_line)
for line = start_line, end_line do
vim.api.nvim_buf_add_highlight(buf, ns, group, line, 0, -1)
end
end
return M24.4 Tree-sitter Integration
24.4.1 Understanding Tree-sitter
-- Tree-sitter provides:
-- 1. Fast, incremental parsing
-- 2. Accurate syntax tree
-- 3. Language-agnostic queries
-- 4. Better performance than regex-based syntax
-- Check if Tree-sitter is available
if vim.fn.has('nvim-0.8') == 1 then
local ts_available, _ = pcall(require, 'nvim-treesitter')
if ts_available then
print('Tree-sitter is available')
end
end24.4.2 Basic Tree-sitter Configuration
-- Using nvim-treesitter plugin
require('nvim-treesitter.configs').setup({
-- Install parsers
ensure_installed = {
'lua', 'vim', 'vimdoc',
'python', 'javascript', 'typescript',
'rust', 'go', 'c', 'cpp',
'html', 'css', 'json', 'yaml'
},
-- Auto-install missing parsers
auto_install = true,
-- Highlighting
highlight = {
enable = true,
-- Disable for large files
disable = function(lang, buf)
local max_filesize = 100 * 1024 -- 100 KB
local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(buf))
if ok and stats and stats.size > max_filesize then
return true
end
end,
-- Use Vim regex highlighting in addition to Tree-sitter
additional_vim_regex_highlighting = false
},
-- Incremental selection
incremental_selection = {
enable = true,
keymaps = {
init_selection = '<CR>',
node_incremental = '<CR>',
scope_incremental = '<S-CR>',
node_decremental = '<BS>'
}
},
-- Indentation
indent = {
enable = true,
disable = { 'python', 'yaml' } -- Problematic for some languages
},
-- Folding
fold = {
enable = true
}
})
-- Enable Tree-sitter folding
vim.opt.foldmethod = 'expr'
vim.opt.foldexpr = 'nvim_treesitter#foldexpr()'24.4.3 Custom Tree-sitter Queries
;; queries/lua/highlights.scm
;; Custom Tree-sitter highlight queries for Lua
; Function calls
(function_call
name: (identifier) @function.call)
(function_call
name: (dot_index_expression
field: (identifier) @function.method.call))
; Module imports
(function_call
name: (identifier) @keyword.import (#eq? @keyword.import "require"))
; String interpolation
(string content: (string_content) @string)
; Table fields
(field
name: (identifier) @field)
; Custom keywords
(identifier) @custom.keyword (#any-of? @custom.keyword "self" "cls")
; Constants
(variable_declaration
(assignment_statement
(variable_list
name: (identifier) @constant
(#match? @constant "^[A-Z][A-Z_0-9]*$"))))
; Documentation comments
(comment) @comment.documentation
(#match? @comment.documentation "^---")
-- lua/treesitter_custom.lua
local M = {}
-- Set custom highlight groups for Tree-sitter
function M.setup_highlights()
vim.api.nvim_set_hl(0, '@function.call', { link = 'Function' })
vim.api.nvim_set_hl(0, '@function.method.call', { link = 'Function' })
vim.api.nvim_set_hl(0, '@keyword.import', { fg = '#c678dd', bold = true })
vim.api.nvim_set_hl(0, '@field', { fg = '#e06c75' })
vim.api.nvim_set_hl(0, '@custom.keyword', { fg = '#d19a66', italic = true })
vim.api.nvim_set_hl(0, '@constant', { fg = '#56b6c2', bold = true })
vim.api.nvim_set_hl(0, '@comment.documentation', { fg = '#5c6370', italic = true })
end
-- Get syntax tree
function M.get_tree()
local parser = vim.treesitter.get_parser()
if not parser then
return nil
end
local tree = parser:parse()[1]
return tree
end
-- Get node at cursor
function M.get_node_at_cursor()
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1] - 1
local col = cursor[2]
local tree = M.get_tree()
if not tree then
return nil
end
return tree:root():named_descendant_for_range(row, col, row, col)
end
-- Get node text
function M.get_node_text(node, bufnr)
bufnr = bufnr or 0
return vim.treesitter.get_node_text(node, bufnr)
end
-- Print node info
function M.inspect_node()
local node = M.get_node_at_cursor()
if not node then
print('No node found')
return
end
local info = {
type = node:type(),
text = M.get_node_text(node),
start_row = node:start(),
end_row = node:end_(),
parent = node:parent() and node:parent():type() or 'none'
}
print(vim.inspect(info))
end
-- Navigate to parent node
function M.goto_parent_node()
local node = M.get_node_at_cursor()
if not node then
return
end
local parent = node:parent()
if parent then
local start_row, start_col = parent:start()
vim.api.nvim_win_set_cursor(0, { start_row + 1, start_col })
end
end
-- Navigate to next sibling
function M.goto_next_sibling()
local node = M.get_node_at_cursor()
if not node then
return
end
local sibling = node:next_named_sibling()
if sibling then
local start_row, start_col = sibling:start()
vim.api.nvim_win_set_cursor(0, { start_row + 1, start_col })
end
end
-- Query nodes
function M.query_nodes(query_string, bufnr)
bufnr = bufnr or 0
local parser = vim.treesitter.get_parser(bufnr)
local tree = parser:parse()[1]
local root = tree:root()
local lang = parser:lang()
local query = vim.treesitter.query.parse(lang, query_string)
local results = {}
for id, node in query:iter_captures(root, bufnr) do
local name = query.captures[id]
table.insert(results, {
name = name,
node = node,
text = M.get_node_text(node, bufnr)
})
end
return results
end
-- Find all functions in buffer
function M.find_functions(bufnr)
bufnr = bufnr or 0
local query_string = [[
(function_declaration
name: (identifier) @function.name)
(function_definition
name: (identifier) @function.name)
]]
return M.query_nodes(query_string, bufnr)
end
return M24.4.4 Advanced Tree-sitter Usage
-- lua/treesitter_advanced.lua
local M = {}
local ts_utils = require('nvim-treesitter.ts_utils')
-- Smart selection expansion
function M.smart_select()
local node = ts_utils.get_node_at_cursor()
if not node then
return
end
-- Expand selection to current node
ts_utils.update_selection(0, node)
end
-- Swap with next sibling
function M.swap_next()
local node = ts_utils.get_node_at_cursor()
if not node then
return
end
local next_sibling = node:next_named_sibling()
if not next_sibling then
return
end
-- Swap nodes
local node_text = vim.treesitter.get_node_text(node, 0)
local sibling_text = vim.treesitter.get_node_text(next_sibling, 0)
-- Replace text
local node_range = { node:range() }
local sibling_range = { next_sibling:range() }
vim.api.nvim_buf_set_text(0,
sibling_range[1], sibling_range[2],
sibling_range[3], sibling_range[4],
vim.split(node_text, '\n'))
vim.api.nvim_buf_set_text(0,
node_range[1], node_range[2],
node_range[3], node_range[4],
vim.split(sibling_text, '\n'))
end
-- Jump to definition using Tree-sitter
function M.goto_definition()
local node = ts_utils.get_node_at_cursor()
if not node then
return
end
-- Get identifier
while node and node:type() ~= 'identifier' do
node = node:parent()
end
if not node then
return
end
local identifier = vim.treesitter.get_node_text(node, 0)
-- Search for definition
local query_string = string.format([[
(function_declaration
name: (identifier) @name (#eq? @name "%s"))
(variable_declaration
(assignment_statement
(variable_list
name: (identifier) @name (#eq? @name "%s"))))
]], identifier, identifier)
local results = require('treesitter_custom').query_nodes(query_string)
if #results > 0 then
local def_node = results[1].node
local row, col = def_node:start()
vim.api.nvim_win_set_cursor(0, { row + 1, col })
end
end
-- Highlight matching nodes
function M.highlight_matching(pattern)
local bufnr = 0
local ns = vim.api.nvim_create_namespace('ts_highlight')
-- Clear previous highlights
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
-- Query matching nodes
local results = require('treesitter_custom').query_nodes(pattern, bufnr)
-- Highlight each match
for _, result in ipairs(results) do
local start_row, start_col, end_row, end_col = result.node:range()
vim.highlight.range(
bufnr,
ns,
'Search',
{ start_row, start_col },
{ end_row, end_col }
)
end
end
-- Text objects using Tree-sitter
function M.setup_textobjects()
require('nvim-treesitter.configs').setup({
textobjects = {
select = {
enable = true,
lookahead = true,
keymaps = {
['af'] = '@function.outer',
['if'] = '@function.inner',
['ac'] = '@class.outer',
['ic'] = '@class.inner',
['ab'] = '@block.outer',
['ib'] = '@block.inner',
['aa'] = '@parameter.outer',
['ia'] = '@parameter.inner',
}
},
move = {
enable = true,
set_jumps = true,
goto_next_start = {
[']f'] = '@function.outer',
[']c'] = '@class.outer',
},
goto_next_end = {
[']F'] = '@function.outer',
[']C'] = '@class.outer',
},
goto_previous_start = {
['[f'] = '@function.outer',
['[c'] = '@class.outer',
},
goto_previous_end = {
['[F'] = '@function.outer',
['[C'] = '@class.outer',
},
},
swap = {
enable = true,
swap_next = {
['<leader>sn'] = '@parameter.inner',
},
swap_previous = {
['<leader>sp'] = '@parameter.inner',
},
},
}
})
end
return M24.5 Semantic Highlighting
24.5.1 LSP-based Semantic Tokens
-- lua/semantic_highlighting.lua
local M = {}
-- Enable semantic highlighting
function M.setup()
-- Enable semantic tokens if LSP supports it
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client.server_capabilities.semanticTokensProvider then
vim.lsp.semantic_tokens.start(args.buf, client.id)
end
end
})
end
-- Custom semantic token highlights
function M.setup_highlights()
-- Semantic token types
vim.api.nvim_set_hl(0, '@lsp.type.class', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.decorator', { link = 'Function' })
vim.api.nvim_set_hl(0, '@lsp.type.enum', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.enumMember', { link = 'Constant' })
vim.api.nvim_set_hl(0, '@lsp.type.function', { link = 'Function' })
vim.api.nvim_set_hl(0, '@lsp.type.interface', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.macro', { link = 'Macro' })
vim.api.nvim_set_hl(0, '@lsp.type.method', { link = 'Function' })
vim.api.nvim_set_hl(0, '@lsp.type.namespace', { link = 'Include' })
vim.api.nvim_set_hl(0, '@lsp.type.parameter', { link = 'Identifier' })
vim.api.nvim_set_hl(0, '@lsp.type.property', { link = 'Identifier' })
vim.api.nvim_set_hl(0, '@lsp.type.struct', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.type', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.typeParameter', { link = 'Type' })
vim.api.nvim_set_hl(0, '@lsp.type.variable', { link = 'Identifier' })
-- Semantic token modifiers
vim.api.nvim_set_hl(0, '@lsp.mod.readonly', { italic = true })
vim.api.nvim_set_hl(0, '@lsp.mod.deprecated', { strikethrough = true })
vim.api.nvim_set_hl(0, '@lsp.mod.static', { bold = true })
-- Combined type and modifier
vim.api.nvim_set_hl(0, '@lsp.typemod.function.readonly', {
fg = '#61afef',
italic = true
})
vim.api.nvim_set_hl(0, '@lsp.typemod.variable.readonly', {
fg = '#e06c75',
italic = true
})
end
-- Toggle semantic highlighting
function M.toggle()
local bufnr = vim.api.nvim_get_current_buf()
local clients = vim.lsp.get_active_clients({ bufnr = bufnr })
for _, client in ipairs(clients) do
if client.server_capabilities.semanticTokensProvider then
if vim.b[bufnr].semantic_tokens_enabled then
vim.lsp.semantic_tokens.stop(bufnr, client.id)
vim.b[bufnr].semantic_tokens_enabled = false
print('Semantic highlighting disabled')
else
vim.lsp.semantic_tokens.start(bufnr, client.id)
vim.b[bufnr].semantic_tokens_enabled = true
print('Semantic highlighting enabled')
end
end
end
end
return M24.5.2 Custom Semantic Highlighting
-- lua/custom_semantic.lua
local M = {}
-- Namespace for custom highlights
local ns = vim.api.nvim_create_namespace('custom_semantic')
-- Highlight specific patterns with semantic meaning
function M.highlight_constants(bufnr)
bufnr = bufnr or 0
-- Clear existing highlights
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
for line_num, line in ipairs(lines) do
-- Highlight UPPER_CASE as constants
for match in line:gmatch('%f[%w]([A-Z][A-Z_0-9]+)%f[%W]') do
local start_col = line:find(match, 1, true)
if start_col then
vim.api.nvim_buf_add_highlight(
bufnr,
ns,
'Constant',
line_num - 1,
start_col - 1,
start_col - 1 + #match
)
end
end
-- Highlight numbers with units
for match in line:gmatch('%d+%s*[a-z]+') do
local start_col = line:find(match, 1, true)
if start_col then
vim.api.nvim_buf_add_highlight(
bufnr,
ns,
'Number',
line_num - 1,
start_col - 1,
start_col - 1 + #match
)
end
end
end
end
-- Auto-update on buffer changes
function M.setup_autocmds(bufnr)
bufnr = bufnr or 0
local group = vim.api.nvim_create_augroup('CustomSemantic', { clear = true })
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
group = group,
buffer = bufnr,
callback = function()
vim.defer_fn(function()
M.highlight_constants(bufnr)
end, 100)
end
})
end
return M24.6 Performance Optimization
24.6.1 Lazy Syntax Loading
" Defer syntax loading for large files
augroup FastSyntax
autocmd!
autocmd BufReadPre * if getfsize(expand("%")) > 1000000 | syntax clear | endif
autocmd BufReadPost * if getfsize(expand("%")) > 1000000 | setlocal syntax=OFF | endif
augroup END
" Progressive syntax loading
function! ProgressiveSyntax()
if line('$') > 5000
" Load syntax in chunks
syntax sync minlines=100 maxlines=200
else
syntax sync fromstart
endif
endfunction
autocmd BufEnter * call ProgressiveSyntax()
24.6.2 Optimized Highlighting
-- lua/optimized_highlighting.lua
local M = {}
-- Debounced highlighting update
function M.debounced_highlight(callback, delay)
local timer = vim.loop.new_timer()
return function()
timer:stop()
timer:start(
delay,
0,
vim.schedule_wrap(callback)
)
end
end
-- Highlight visible range only
function M.highlight_visible_range(bufnr, callback)
bufnr = bufnr or 0
local win = vim.fn.bufwinid(bufnr)
if win == -1 then
return
end
local top_line = vim.fn.line('w0', win)
local bottom_line = vim.fn.line('w$', win)
callback(bufnr, top_line - 1, bottom_line)
end
-- Cached highlighting
local highlight_cache = {}
function M.cached_highlight(key, compute_fn)
if highlight_cache[key] then
return highlight_cache[key]
end
local result = compute_fn()
highlight_cache[key] = result
-- Clear cache after delay
vim.defer_fn(function()
highlight_cache[key] = nil
end, 5000)
return result
end
return M24.6.3 Profiling Syntax Performance
" Profile syntax highlighting
function! ProfileSyntax()
syntime on
" Perform some operations
redraw!
syntime report
syntime clear
endfunction
command! ProfileSyntax call ProfileSyntax()
✅ End of Chapter 24: Syntax Highlighting
You now have comprehensive knowledge of Vim and Neovim’s syntax highlighting systems, from traditional Vim syntax to modern Tree-sitter integration. This chapter covered creating custom syntax files, color schemes, Tree-sitter queries, semantic highlighting via LSP, and performance optimization techniques for highlighting large files efficiently.
Chapter 25: Colorschemes and Themes
This chapter explores the art and science of colorschemes in Vim and Neovim. You’ll learn how to create, customize, and manage colorschemes, understand color theory principles, work with different terminal environments, and build dynamic, adaptive themes.
25.1 Understanding Colorscheme Fundamentals
25.1.1 Color Basics
" Color specification methods in Vim/Neovim:
" 1. GUI colors (24-bit RGB)
highlight Normal guifg=#abb2bf guibg=#282c34
" 2. Terminal colors (256-color palette)
highlight Normal ctermfg=249 ctermbg=235
" 3. Basic 16-color names
highlight Normal ctermfg=white ctermbg=black
" 4. Attributes
highlight Statement gui=bold cterm=bold
highlight Comment gui=italic cterm=italic
highlight Error gui=underline,bold cterm=underline,bold
" Available attributes:
" - bold
" - italic
" - underline
" - undercurl (wavy underline, GUI only)
" - strikethrough
" - reverse (swap fg/bg)
" - standout
" - NONE (clear all attributes)
25.1.2 Color Code Formats
" Hexadecimal RGB (most common)
" Format: #RRGGBB
highlight Normal guifg=#ffffff guibg=#000000
" Short hex format (automatically expanded)
" #RGB -> #RRGGBB
highlight Comment guifg=#888 " Expands to #888888
" Named colors (X11 color names)
highlight Error guifg=red guibg=black
highlight Todo guifg=yellow guibg=blue
" Terminal color codes (0-255)
" 0-15: Basic colors
" 16-231: 216-color cube (6x6x6)
" 232-255: Grayscale ramp
highlight Normal ctermfg=15 ctermbg=0
highlight Comment ctermfg=244 " Gray
25.1.3 Checking Color Support
" Check terminal capabilities
function! CheckColorSupport()
echo "GUI running: " . has('gui_running')
echo "Termguicolors support: " . has('termguicolors')
echo "256 colors: " . (&t_Co == 256)
echo "RGB colors: " . has('gui_running') || has('termguicolors')
if exists('+termguicolors')
echo "Termguicolors setting: " . &termguicolors
endif
if exists('$COLORTERM')
echo "COLORTERM: " . $COLORTERM
endif
endfunction
command! CheckColors call CheckColorSupport()
-- lua/color_support.lua
local M = {}
function M.check_support()
local info = {
gui_running = vim.fn.has('gui_running') == 1,
termguicolors = vim.fn.has('termguicolors') == 1,
t_Co = vim.o.t_Co,
rgb_support = vim.fn.has('gui_running') == 1 or
(vim.fn.has('termguicolors') == 1 and vim.o.termguicolors),
colorterm = vim.env.COLORTERM
}
return info
end
function M.enable_true_color()
if vim.fn.has('termguicolors') == 1 then
vim.o.termguicolors = true
end
end
return M25.2 Creating a Complete Colorscheme
25.2.1 Basic Colorscheme Structure
" colors/myscheme.vim
" Vim color scheme
" Name: myscheme
" Author: Your Name
" License: MIT
" Initialization {{{
if version > 580
hi clear
if exists("syntax_on")
syntax reset
endif
endif
let g:colors_name = "myscheme"
" Set background (optional)
set background=dark
" }}}
" Color Palette {{{
let s:palette = {}
" Base colors
let s:palette.bg0 = ['#1d2021', 234]
let s:palette.bg1 = ['#3c3836', 237]
let s:palette.bg2 = ['#504945', 239]
let s:palette.bg3 = ['#665c54', 241]
let s:palette.bg4 = ['#7c6f64', 243]
let s:palette.fg0 = ['#fbf1c7', 229]
let s:palette.fg1 = ['#ebdbb2', 223]
let s:palette.fg2 = ['#d5c4a1', 250]
let s:palette.fg3 = ['#bdae93', 248]
let s:palette.fg4 = ['#a89984', 246]
" Accent colors
let s:palette.red = ['#fb4934', 167]
let s:palette.green = ['#b8bb26', 142]
let s:palette.yellow = ['#fabd2f', 214]
let s:palette.blue = ['#83a598', 109]
let s:palette.purple = ['#d3869b', 175]
let s:palette.aqua = ['#8ec07c', 108]
let s:palette.orange = ['#fe8019', 208]
" Dimmed variants
let s:palette.red_dim = ['#cc2412', 124]
let s:palette.green_dim = ['#98971a', 106]
let s:palette.yellow_dim = ['#d79921', 172]
let s:palette.blue_dim = ['#458588', 66]
let s:palette.purple_dim = ['#b16286', 132]
let s:palette.aqua_dim = ['#689d6a', 72]
let s:palette.orange_dim = ['#d65d0e', 166]
" }}}
" Helper Functions {{{
function! s:HL(group, fg, bg, attr)
let l:fg = get(s:palette, a:fg, ['NONE', 'NONE'])
let l:bg = get(s:palette, a:bg, ['NONE', 'NONE'])
let l:cmd = 'highlight ' . a:group
if l:fg[0] != 'NONE'
let l:cmd .= ' guifg=' . l:fg[0]
endif
if l:fg[1] != 'NONE'
let l:cmd .= ' ctermfg=' . l:fg[1]
endif
if l:bg[0] != 'NONE'
let l:cmd .= ' guibg=' . l:bg[0]
endif
if l:bg[1] != 'NONE'
let l:cmd .= ' ctermbg=' . l:bg[1]
endif
if a:attr != ''
let l:cmd .= ' gui=' . a:attr . ' cterm=' . a:attr
endif
execute l:cmd
endfunction
function! s:Link(from, to)
execute 'highlight! link ' . a:from . ' ' . a:to
endfunction
" }}}
" UI Elements {{{
call s:HL('Normal', 'fg1', 'bg0', '')
call s:HL('NormalFloat', 'fg1', 'bg1', '')
call s:HL('NormalNC', 'fg1', 'bg0', '')
call s:HL('Cursor', 'bg0', 'fg1', '')
call s:HL('CursorLine', '', 'bg1', '')
call s:HL('CursorColumn', '', 'bg1', '')
call s:HL('CursorLineNr', 'yellow', 'bg1', 'bold')
call s:HL('LineNr', 'bg4', '', '')
call s:HL('SignColumn', '', 'bg0', '')
call s:HL('ColorColumn', '', 'bg1', '')
call s:HL('Visual', '', 'bg2', '')
call s:HL('VisualNOS', '', 'bg2', '')
call s:HL('Search', 'bg0', 'yellow', '')
call s:HL('IncSearch', 'bg0', 'orange', '')
call s:HL('CurSearch', 'bg0', 'red', '')
call s:HL('MatchParen', 'orange', 'bg2', 'bold')
call s:HL('Pmenu', 'fg1', 'bg2', '')
call s:HL('PmenuSel', 'bg0', 'blue', 'bold')
call s:HL('PmenuSbar', '', 'bg2', '')
call s:HL('PmenuThumb', '', 'bg4', '')
call s:HL('StatusLine', 'fg1', 'bg2', '')
call s:HL('StatusLineNC', 'fg4', 'bg1', '')
call s:HL('TabLine', 'fg4', 'bg1', '')
call s:HL('TabLineFill', 'fg4', 'bg1', '')
call s:HL('TabLineSel', 'fg1', 'bg2', 'bold')
call s:HL('VertSplit', 'bg3', 'bg0', '')
call s:HL('WinSeparator', 'bg3', 'bg0', '')
call s:HL('Folded', 'fg4', 'bg1', 'italic')
call s:HL('FoldColumn', 'fg4', 'bg0', '')
call s:HL('DiffAdd', 'green', 'bg1', '')
call s:HL('DiffChange', 'yellow', 'bg1', '')
call s:HL('DiffDelete', 'red', 'bg1', '')
call s:HL('DiffText', 'yellow', 'bg2', 'bold')
call s:HL('SpellBad', 'red', '', 'undercurl')
call s:HL('SpellCap', 'blue', '', 'undercurl')
call s:HL('SpellLocal', 'aqua', '', 'undercurl')
call s:HL('SpellRare', 'purple', '', 'undercurl')
call s:HL('ErrorMsg', 'red', 'bg0', 'bold')
call s:HL('WarningMsg', 'yellow', 'bg0', 'bold')
call s:HL('ModeMsg', 'fg1', '', 'bold')
call s:HL('MoreMsg', 'green', '', 'bold')
call s:HL('Question', 'orange', '', 'bold')
call s:HL('Directory', 'blue', '', 'bold')
call s:HL('Title', 'green', '', 'bold')
call s:HL('SpecialKey', 'fg4', '', '')
call s:HL('NonText', 'bg4', '', '')
call s:HL('Whitespace', 'bg2', '', '')
call s:HL('Conceal', 'blue', '', '')
" }}}
" Syntax Highlighting {{{
call s:HL('Comment', 'fg4', '', 'italic')
call s:HL('SpecialComment', 'fg3', '', 'italic')
call s:HL('Todo', 'fg0', 'yellow', 'bold')
call s:HL('Constant', 'purple', '', '')
call s:HL('String', 'green', '', '')
call s:HL('Character', 'purple', '', '')
call s:HL('Number', 'purple', '', '')
call s:HL('Boolean', 'purple', '', '')
call s:HL('Float', 'purple', '', '')
call s:HL('Identifier', 'blue', '', '')
call s:HL('Function', 'green', '', 'bold')
call s:HL('Statement', 'red', '', '')
call s:HL('Conditional', 'red', '', '')
call s:HL('Repeat', 'red', '', '')
call s:HL('Label', 'red', '', '')
call s:HL('Operator', 'orange', '', '')
call s:HL('Keyword', 'red', '', '')
call s:HL('Exception', 'red', '', '')
call s:HL('PreProc', 'aqua', '', '')
call s:HL('Include', 'aqua', '', '')
call s:HL('Define', 'aqua', '', '')
call s:HL('Macro', 'aqua', '', '')
call s:HL('PreCondit', 'aqua', '', '')
call s:HL('Type', 'yellow', '', '')
call s:HL('StorageClass', 'orange', '', '')
call s:HL('Structure', 'aqua', '', '')
call s:HL('Typedef', 'yellow', '', '')
call s:HL('Special', 'orange', '', '')
call s:HL('SpecialChar', 'orange', '', '')
call s:HL('Tag', 'orange', '', '')
call s:HL('Delimiter', 'fg1', '', '')
call s:HL('Debug', 'red', '', '')
call s:HL('Underlined', 'blue', '', 'underline')
call s:HL('Ignore', 'fg0', '', '')
call s:HL('Error', 'red', 'bg0', 'bold,underline')
" }}}
" Language-Specific {{{
" Vim
call s:Link('vimCommand', 'Statement')
call s:Link('vimVar', 'Identifier')
call s:Link('vimFuncName', 'Function')
call s:Link('vimOption', 'Constant')
" Lua
call s:Link('luaFunction', 'Keyword')
call s:Link('luaTable', 'Structure')
" Python
call s:Link('pythonBuiltin', 'Function')
call s:Link('pythonDecorator', 'PreProc')
" JavaScript
call s:Link('javaScriptFunction', 'Keyword')
call s:Link('javaScriptIdentifier', 'Statement')
" HTML
call s:Link('htmlTag', 'Tag')
call s:Link('htmlEndTag', 'Tag')
call s:Link('htmlArg', 'Identifier')
" Markdown
call s:HL('markdownH1', 'red', '', 'bold')
call s:HL('markdownH2', 'orange', '', 'bold')
call s:HL('markdownH3', 'yellow', '', 'bold')
call s:HL('markdownH4', 'green', '', 'bold')
call s:HL('markdownH5', 'blue', '', 'bold')
call s:HL('markdownH6', 'purple', '', 'bold')
call s:HL('markdownCode', 'aqua', '', '')
call s:HL('markdownCodeBlock', 'aqua', '', '')
call s:HL('markdownUrl', 'blue', '', 'underline')
" }}}
" Plugin Support {{{
" GitGutter / Signify
call s:HL('GitGutterAdd', 'green', '', '')
call s:HL('GitGutterChange', 'yellow', '', '')
call s:HL('GitGutterDelete', 'red', '', '')
call s:HL('SignifySignAdd', 'green', '', '')
call s:HL('SignifySignChange', 'yellow', '', '')
call s:HL('SignifySignDelete', 'red', '', '')
" ALE
call s:HL('ALEError', 'red', '', 'underline')
call s:HL('ALEWarning', 'yellow', '', 'underline')
call s:HL('ALEInfo', 'blue', '', 'underline')
" LSP
call s:HL('DiagnosticError', 'red', '', '')
call s:HL('DiagnosticWarn', 'yellow', '', '')
call s:HL('DiagnosticInfo', 'blue', '', '')
call s:HL('DiagnosticHint', 'aqua', '', '')
" Telescope
call s:HL('TelescopeBorder', 'bg3', 'bg0', '')
call s:HL('TelescopeSelection', 'fg1', 'bg2', 'bold')
call s:HL('TelescopeMatching', 'orange', '', 'bold')
" NvimTree
call s:HL('NvimTreeFolderName', 'blue', '', 'bold')
call s:HL('NvimTreeOpenedFolderName', 'green', '', 'bold')
call s:HL('NvimTreeGitDirty', 'yellow', '', '')
call s:HL('NvimTreeGitNew', 'green', '', '')
call s:HL('NvimTreeGitDeleted', 'red', '', '')
" }}}
" Cleanup {{{
delfunction s:HL
delfunction s:Link
unlet s:palette
" }}}
" vim: set sw=2 ts=2 sts=2 et tw=80 ft=vim fdm=marker:
25.2.2 Lua-based Colorscheme
-- colors/myscheme.lua
-- Modern Neovim colorscheme in Lua
-- Clear existing highlights
vim.cmd('highlight clear')
if vim.fn.exists('syntax_on') then
vim.cmd('syntax reset')
end
vim.g.colors_name = 'myscheme'
vim.o.background = 'dark'
-- Enable true color support
if vim.fn.has('termguicolors') == 1 then
vim.o.termguicolors = true
end
-- Color palette
local colors = {
-- Base colors
bg0 = '#1d2021',
bg1 = '#3c3836',
bg2 = '#504945',
bg3 = '#665c54',
bg4 = '#7c6f64',
fg0 = '#fbf1c7',
fg1 = '#ebdbb2',
fg2 = '#d5c4a1',
fg3 = '#bdae93',
fg4 = '#a89984',
-- Accent colors
red = '#fb4934',
green = '#b8bb26',
yellow = '#fabd2f',
blue = '#83a598',
purple = '#d3869b',
aqua = '#8ec07c',
orange = '#fe8019',
-- Dimmed variants
red_dim = '#cc2412',
green_dim = '#98971a',
yellow_dim = '#d79921',
blue_dim = '#458588',
purple_dim = '#b16286',
aqua_dim = '#689d6a',
orange_dim = '#d65d0e',
-- Special
none = 'NONE'
}
-- Helper function to set highlights
local function hl(group, opts)
opts = opts or {}
local cmd = { 'highlight', group }
if opts.fg then table.insert(cmd, 'guifg=' .. opts.fg) end
if opts.bg then table.insert(cmd, 'guibg=' .. opts.bg) end
if opts.sp then table.insert(cmd, 'guisp=' .. opts.sp) end
if opts.bold then table.insert(cmd, 'gui=bold cterm=bold')
elseif opts.italic then table.insert(cmd, 'gui=italic cterm=italic')
elseif opts.underline then table.insert(cmd, 'gui=underline cterm=underline')
elseif opts.undercurl then table.insert(cmd, 'gui=undercurl')
elseif opts.strikethrough then table.insert(cmd, 'gui=strikethrough')
elseif opts.reverse then table.insert(cmd, 'gui=reverse cterm=reverse')
elseif opts.standout then table.insert(cmd, 'gui=standout cterm=standout')
elseif opts.attr then table.insert(cmd, 'gui=' .. opts.attr .. ' cterm=' .. opts.attr)
end
vim.cmd(table.concat(cmd, ' '))
end
-- Link one highlight group to another
local function link(from, to)
vim.cmd(string.format('highlight! link %s %s', from, to))
end
-- UI Highlights
local ui_groups = {
Normal = { fg = colors.fg1, bg = colors.bg0 },
NormalFloat = { fg = colors.fg1, bg = colors.bg1 },
NormalNC = { fg = colors.fg1, bg = colors.bg0 },
Cursor = { fg = colors.bg0, bg = colors.fg1 },
CursorLine = { bg = colors.bg1 },
CursorColumn = { bg = colors.bg1 },
CursorLineNr = { fg = colors.yellow, bg = colors.bg1, bold = true },
LineNr = { fg = colors.bg4 },
SignColumn = { bg = colors.bg0 },
ColorColumn = { bg = colors.bg1 },
Visual = { bg = colors.bg2 },
VisualNOS = { bg = colors.bg2 },
Search = { fg = colors.bg0, bg = colors.yellow },
IncSearch = { fg = colors.bg0, bg = colors.orange },
CurSearch = { fg = colors.bg0, bg = colors.red },
MatchParen = { fg = colors.orange, bg = colors.bg2, bold = true },
Pmenu = { fg = colors.fg1, bg = colors.bg2 },
PmenuSel = { fg = colors.bg0, bg = colors.blue, bold = true },
PmenuSbar = { bg = colors.bg2 },
PmenuThumb = { bg = colors.bg4 },
StatusLine = { fg = colors.fg1, bg = colors.bg2 },
StatusLineNC = { fg = colors.fg4, bg = colors.bg1 },
TabLine = { fg = colors.fg4, bg = colors.bg1 },
TabLineFill = { fg = colors.fg4, bg = colors.bg1 },
TabLineSel = { fg = colors.fg1, bg = colors.bg2, bold = true },
VertSplit = { fg = colors.bg3, bg = colors.bg0 },
WinSeparator = { fg = colors.bg3, bg = colors.bg0 },
Folded = { fg = colors.fg4, bg = colors.bg1, italic = true },
FoldColumn = { fg = colors.fg4, bg = colors.bg0 },
DiffAdd = { fg = colors.green, bg = colors.bg1 },
DiffChange = { fg = colors.yellow, bg = colors.bg1 },
DiffDelete = { fg = colors.red, bg = colors.bg1 },
DiffText = { fg = colors.yellow, bg = colors.bg2, bold = true },
SpellBad = { fg = colors.red, undercurl = true, sp = colors.red },
SpellCap = { fg = colors.blue, undercurl = true, sp = colors.blue },
SpellLocal = { fg = colors.aqua, undercurl = true, sp = colors.aqua },
SpellRare = { fg = colors.purple, undercurl = true, sp = colors.purple },
ErrorMsg = { fg = colors.red, bg = colors.bg0, bold = true },
WarningMsg = { fg = colors.yellow, bg = colors.bg0, bold = true },
ModeMsg = { fg = colors.fg1, bold = true },
MoreMsg = { fg = colors.green, bold = true },
Question = { fg = colors.orange, bold = true },
Directory = { fg = colors.blue, bold = true },
Title = { fg = colors.green, bold = true },
SpecialKey = { fg = colors.fg4 },
NonText = { fg = colors.bg4 },
Whitespace = { fg = colors.bg2 },
Conceal = { fg = colors.blue },
}
-- Syntax Highlights
local syntax_groups = {
Comment = { fg = colors.fg4, italic = true },
SpecialComment = { fg = colors.fg3, italic = true },
Todo = { fg = colors.fg0, bg = colors.yellow, bold = true },
Constant = { fg = colors.purple },
String = { fg = colors.green },
Character = { fg = colors.purple },
Number = { fg = colors.purple },
Boolean = { fg = colors.purple },
Float = { fg = colors.purple },
Identifier = { fg = colors.blue },
Function = { fg = colors.green, bold = true },
Statement = { fg = colors.red },
Conditional = { fg = colors.red },
Repeat = { fg = colors.red },
Label = { fg = colors.red },
Operator = { fg = colors.orange },
Keyword = { fg = colors.red },
Exception = { fg = colors.red },
PreProc = { fg = colors.aqua },
Include = { fg = colors.aqua },
Define = { fg = colors.aqua },
Macro = { fg = colors.aqua },
PreCondit = { fg = colors.aqua },
Type = { fg = colors.yellow },
StorageClass = { fg = colors.orange },
Structure = { fg = colors.aqua },
Typedef = { fg = colors.yellow },
Special = { fg = colors.orange },
SpecialChar = { fg = colors.orange },
Tag = { fg = colors.orange },
Delimiter = { fg = colors.fg1 },
Debug = { fg = colors.red },
Underlined = { fg = colors.blue, underline = true },
Ignore = { fg = colors.fg0 },
Error = { fg = colors.red, bg = colors.bg0, bold = true, underline = true },
}
-- Apply UI highlights
for group, opts in pairs(ui_groups) do
hl(group, opts)
end
-- Apply syntax highlights
for group, opts in pairs(syntax_groups) do
hl(group, opts)
end
-- Tree-sitter highlights
local treesitter_groups = {
['@variable'] = { fg = colors.blue },
['@variable.builtin'] = { fg = colors.purple },
['@variable.parameter'] = { fg = colors.fg1 },
['@variable.member'] = { fg = colors.blue },
['@constant'] = { fg = colors.purple },
['@constant.builtin'] = { fg = colors.purple },
['@constant.macro'] = { fg = colors.aqua },
['@string'] = { fg = colors.green },
['@string.escape'] = { fg = colors.orange },
['@string.special'] = { fg = colors.orange },
['@character'] = { fg = colors.purple },
['@number'] = { fg = colors.purple },
['@boolean'] = { fg = colors.purple },
['@float'] = { fg = colors.purple },
['@function'] = { fg = colors.green, bold = true },
['@function.builtin'] = { fg = colors.yellow },
['@function.macro'] = { fg = colors.aqua },
['@function.method'] = { fg = colors.green },
['@constructor'] = { fg = colors.yellow },
['@keyword'] = { fg = colors.red },
['@keyword.function'] = { fg = colors.red },
['@keyword.operator'] = { fg = colors.red },
['@keyword.return'] = { fg = colors.red },
['@conditional'] = { fg = colors.red },
['@repeat'] = { fg = colors.red },
['@label'] = { fg = colors.red },
['@operator'] = { fg = colors.orange },
['@exception'] = { fg = colors.red },
['@type'] = { fg = colors.yellow },
['@type.builtin'] = { fg = colors.yellow },
['@type.definition'] = { fg = colors.yellow },
['@attribute'] = { fg = colors.aqua },
['@property'] = { fg = colors.blue },
['@field'] = { fg = colors.blue },
['@comment'] = { fg = colors.fg4, italic = true },
['@comment.todo'] = { fg = colors.fg0, bg = colors.yellow, bold = true },
['@comment.warning'] = { fg = colors.fg0, bg = colors.orange, bold = true },
['@comment.error'] = { fg = colors.fg0, bg = colors.red, bold = true },
['@punctuation.delimiter'] = { fg = colors.fg1 },
['@punctuation.bracket'] = { fg = colors.fg1 },
['@punctuation.special'] = { fg = colors.orange },
['@tag'] = { fg = colors.orange },
['@tag.attribute'] = { fg = colors.blue },
['@tag.delimiter'] = { fg = colors.fg1 },
}
for group, opts in pairs(treesitter_groups) do
hl(group, opts)
end
-- LSP Semantic tokens
local lsp_groups = {
['@lsp.type.class'] = { link = 'Type' },
['@lsp.type.decorator'] = { link = 'Function' },
['@lsp.type.enum'] = { link = 'Type' },
['@lsp.type.enumMember'] = { link = 'Constant' },
['@lsp.type.function'] = { link = 'Function' },
['@lsp.type.interface'] = { link = 'Type' },
['@lsp.type.macro'] = { link = 'Macro' },
['@lsp.type.method'] = { link = 'Function' },
['@lsp.type.namespace'] = { link = 'Include' },
['@lsp.type.parameter'] = { link = 'Identifier' },
['@lsp.type.property'] = { link = 'Identifier' },
['@lsp.type.struct'] = { link = 'Type' },
['@lsp.type.type'] = { link = 'Type' },
['@lsp.type.typeParameter'] = { link = 'Type' },
['@lsp.type.variable'] = { link = 'Identifier' },
}
for group, opts in pairs(lsp_groups) do
if opts.link then
link(group, opts.link)
else
hl(group, opts)
end
end
-- Diagnostic highlights
hl('DiagnosticError', { fg = colors.red })
hl('DiagnosticWarn', { fg = colors.yellow })
hl('DiagnosticInfo', { fg = colors.blue })
hl('DiagnosticHint', { fg = colors.aqua })
hl('DiagnosticUnderlineError', { undercurl = true, sp = colors.red })
hl('DiagnosticUnderlineWarn', { undercurl = true, sp = colors.yellow })
hl('DiagnosticUnderlineInfo', { undercurl = true, sp = colors.blue })
hl('DiagnosticUnderlineHint', { undercurl = true, sp = colors.aqua })
-- Plugin-specific highlights
-- GitSigns
hl('GitSignsAdd', { fg = colors.green })
hl('GitSignsChange', { fg = colors.yellow })
hl('GitSignsDelete', { fg = colors.red })
-- Telescope
hl('TelescopeBorder', { fg = colors.bg3, bg = colors.bg0 })
hl('TelescopeSelection', { fg = colors.fg1, bg = colors.bg2, bold = true })
hl('TelescopeMatching', { fg = colors.orange, bold = true })
-- NvimTree
hl('NvimTreeFolderName', { fg = colors.blue, bold = true })
hl('NvimTreeOpenedFolderName', { fg = colors.green, bold = true })
hl('NvimTreeGitDirty', { fg = colors.yellow })
hl('NvimTreeGitNew', { fg = colors.green })
hl('NvimTreeGitDeleted', { fg = colors.red })
-- Indent Blankline
hl('IndentBlanklineChar', { fg = colors.bg2 })
hl('IndentBlanklineContextChar', { fg = colors.bg3 })
-- Which-key
hl('WhichKey', { fg = colors.red })
hl('WhichKeyGroup', { fg = colors.blue })
hl('WhichKeyDesc', { fg = colors.fg1 })
hl('WhichKeySeparator', { fg = colors.fg4 })
-- Dashboard
hl('DashboardHeader', { fg = colors.blue })
hl('DashboardCenter', { fg = colors.green })
hl('DashboardFooter', { fg = colors.fg4, italic = true })25.3 Advanced Colorscheme Techniques
25.3.1 Dynamic Color Adjustment
-- lua/colorscheme/dynamic.lua
local M = {}
-- Convert hex to RGB
function M.hex_to_rgb(hex)
hex = hex:gsub('#', '')
return {
r = tonumber(hex:sub(1, 2), 16),
g = tonumber(hex:sub(3, 4), 16),
b = tonumber(hex:sub(5, 6), 16)
}
end
-- Convert RGB to hex
function M.rgb_to_hex(rgb)
return string.format('#%02x%02x%02x',
math.floor(rgb.r),
math.floor(rgb.g),
math.floor(rgb.b)
)
end
-- Blend two colors
function M.blend(color1, color2, ratio)
local c1 = M.hex_to_rgb(color1)
local c2 = M.hex_to_rgb(color2)
return M.rgb_to_hex({
r = c1.r * ratio + c2.r * (1 - ratio),
g = c1.g * ratio + c2.g * (1 - ratio),
b = c1.b * ratio + c2.b * (1 - ratio)
})
end
-- Lighten a color
function M.lighten(color, amount)
return M.blend(color, '#ffffff', 1 - amount)
end
-- Darken a color
function M.darken(color, amount)
return M.blend(color, '#000000', 1 - amount)
end
-- Adjust saturation
function M.saturate(color, amount)
local rgb = M.hex_to_rgb(color)
-- Convert to HSL
local max = math.max(rgb.r, rgb.g, rgb.b)
local min = math.min(rgb.r, rgb.g, rgb.b)
local l = (max + min) / 2 / 255
if max == min then
return color -- Grayscale
end
local d = max - min
local s
if l > 0.5 then
s = d / (2 * 255 - max - min)
else
s = d / (max + min)
end
-- Adjust saturation
s = math.max(0, math.min(1, s + amount))
-- Convert back to RGB (simplified)
local function hue_to_rgb(p, q, t)
if t < 0 then t = t + 1 end
if t > 1 then t = t - 1 end
if t < 1/6 then return p + (q - p) * 6 * t end
if t < 1/2 then return q end
if t < 2/3 then return p + (q - p) * (2/3 - t) * 6 end
return p
end
local q = l < 0.5 and l * (1 + s) or l + s - l * s
local p = 2 * l - q
-- Calculate hue
local h
if rgb.r == max then
h = (rgb.g - rgb.b) / d + (rgb.g < rgb.b and 6 or 0)
elseif rgb.g == max then
h = (rgb.b - rgb.r) / d + 2
else
h = (rgb.r - rgb.g) / d + 4
end
h = h / 6
return M.rgb_to_hex({
r = hue_to_rgb(p, q, h + 1/3) * 255,
g = hue_to_rgb(p, q, h) * 255,
b = hue_to_rgb(p, q, h - 1/3) * 255
})
end
-- Create color variations
function M.create_palette(base_color, options)
options = options or {}
local steps = options.steps or 5
local darken_amount = options.darken or 0.2
local lighten_amount = options.lighten or 0.2
local palette = { [steps] = base_color }
-- Create darker variants
for i = steps - 1, 1, -1 do
local amount = (steps - i) * darken_amount / (steps - 1)
palette[i] = M.darken(base_color, amount)
end
-- Create lighter variants
for i = steps + 1, steps * 2 - 1 do
local amount = (i - steps) * lighten_amount / (steps - 1)
palette[i] = M.lighten(base_color, amount)
end
return palette
end
-- Generate complementary colors
function M.complementary(color)
local rgb = M.hex_to_rgb(color)
return M.rgb_to_hex({
r = 255 - rgb.r,
g = 255 - rgb.g,
b = 255 - rgb.b
})
end
-- Generate analogous colors
function M.analogous(color, angle)
angle = angle or 30
local rgb = M.hex_to_rgb(color)
-- Simplified rotation (not accurate HSL rotation)
local function rotate(c, deg)
local rad = math.rad(deg)
return {
r = math.max(0, math.min(255, c.r * math.cos(rad) - c.g * math.sin(rad))),
g = math.max(0, math.min(255, c.r * math.sin(rad) + c.g * math.cos(rad))),
b = c.b
}
end
return {
M.rgb_to_hex(rotate(rgb, -angle)),
color,
M.rgb_to_hex(rotate(rgb, angle))
}
end
-- Adjust contrast
function M.ensure_contrast(fg, bg, min_ratio)
min_ratio = min_ratio or 4.5 -- WCAG AA standard
local function relative_luminance(color)
local rgb = M.hex_to_rgb(color)
local function adjust(val)
val = val / 255
return val <= 0.03928 and val / 12.92 or ((val + 0.055) / 1.055) ^ 2.4
end
return 0.2126 * adjust(rgb.r) + 0.7152 * adjust(rgb.g) + 0.0722 * adjust(rgb.b)
end
local function contrast_ratio(c1, c2)
local l1 = relative_luminance(c1)
local l2 = relative_luminance(c2)
local lighter = math.max(l1, l2)
local darker = math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
end
local ratio = contrast_ratio(fg, bg)
if ratio >= min_ratio then
return fg
end
-- Adjust foreground
local step = 0.1
local attempts = 0
local adjusted_fg = fg
while ratio < min_ratio and attempts < 20 do
if relative_luminance(bg) > 0.5 then
adjusted_fg = M.darken(adjusted_fg, step)
else
adjusted_fg = M.lighten(adjusted_fg, step)
end
ratio = contrast_ratio(adjusted_fg, bg)
attempts = attempts + 1
end
return adjusted_fg
end
return M25.3.2 Time-based Color Adjustment
-- lua/colorscheme/adaptive.lua
local M = {}
local dynamic = require('colorscheme.dynamic')
-- Get time-based brightness adjustment
function M.get_time_adjustment()
local hour = tonumber(os.date('%H'))
-- Night: 22-6 (darker)
-- Morning: 6-10 (medium)
-- Day: 10-18 (brighter)
-- Evening: 18-22 (medium-dark)
if hour >= 22 or hour < 6 then
return { brightness = -0.2, warmth = 0.1 }
elseif hour >= 6 and hour < 10 then
return { brightness = 0, warmth = 0.05 }
elseif hour >= 10 and hour < 18 then
return { brightness = 0.1, warmth = 0 }
else
return { brightness = -0.1, warmth = 0.05 }
end
end
-- Adjust colorscheme based on time
function M.adjust_by_time(colors)
local adjustment = M.get_time_adjustment()
local adjusted = {}
for name, color in pairs(colors) do
if adjustment.brightness ~= 0 then
if adjustment.brightness > 0 then
color = dynamic.lighten(color, adjustment.brightness)
else
color = dynamic.darken(color, -adjustment.brightness)
end
end
if adjustment.warmth ~= 0 then
-- Add warmth by increasing red/yellow
local rgb = dynamic.hex_to_rgb(color)
rgb.r = math.min(255, rgb.r + adjustment.warmth * 50)
rgb.g = math.min(255, rgb.g + adjustment.warmth * 30)
color = dynamic.rgb_to_hex(rgb)
end
adjusted[name] = color
end
return adjusted
end
-- Auto-adjust every hour
function M.setup_auto_adjust()
local timer = vim.loop.new_timer()
local function apply_adjustment()
-- Reload colorscheme with adjustments
vim.cmd('colorscheme ' .. vim.g.colors_name)
end
-- Check every hour
timer:start(0, 3600000, vim.schedule_wrap(apply_adjustment))
return timer
end
-- Detect system dark mode
function M.is_system_dark_mode()
-- macOS
if vim.fn.has('mac') == 1 then
local handle = io.popen('defaults read -g AppleInterfaceStyle 2>/dev/null')
local result = handle:read('*a')
handle:close()
return result:match('Dark') ~= nil
end
-- Linux with gsettings (GNOME)
if vim.fn.executable('gsettings') == 1 then
local handle = io.popen("gsettings get org.gnome.desktop.interface gtk-theme 2>/dev/null")
local result = handle:read('*a')
handle:close()
return result:match('[Dd]ark') ~= nil
end
-- Fallback to time-based detection
local hour = tonumber(os.date('%H'))
return hour < 6 or hour >= 18
end
-- Auto-switch based on system
function M.auto_switch()
local is_dark = M.is_system_dark_mode()
if is_dark then
vim.o.background = 'dark'
else
vim.o.background = 'light'
end
-- Trigger ColorScheme autocmd
vim.cmd('doautocmd ColorScheme')
end
return M25.3.3 Colorscheme Builder Pattern
-- lua/colorscheme/builder.lua
local M = {}
M.Builder = {}
M.Builder.__index = M.Builder
function M.new(name)
local self = setmetatable({}, M.Builder)
self.name = name
self.palette = {}
self.highlights = {}
self.links = {}
self.options = {
background = 'dark',
terminal_colors = true,
italic_comments = true,
italic_keywords = false,
transparent_background = false
}
return self
end
function M.Builder:set_palette(palette)
self.palette = palette
return self
end
function M.Builder:set_options(options)
for k, v in pairs(options) do
self.options[k] = v
end
return self
end
function M.Builder:add_highlight(group, opts)
self.highlights[group] = opts
return self
end
function M.Builder:add_link(from, to)
self.links[from] = to
return self
end
function M.Builder:add_ui_highlights()
local c = self.palette
local transparent = self.options.transparent_background
self:add_highlight('Normal', {
fg = c.fg,
bg = transparent and 'NONE' or c.bg
})
self:add_highlight('CursorLine', {
bg = transparent and 'NONE' or c.bg_highlight
})
self:add_highlight('Comment', {
fg = c.comment,
italic = self.options.italic_comments
})
return self
end
function M.Builder:add_syntax_highlights()
local c = self.palette
self:add_highlight('String', { fg = c.green })
self:add_highlight('Number', { fg = c.orange })
self:add_highlight('Boolean', { fg = c.orange })
self:add_highlight('Function', { fg = c.blue })
self:add_highlight('Keyword', {
fg = c.purple,
italic = self.options.italic_keywords
})
return self
end
function M.Builder:add_treesitter_highlights()
self:add_link('@variable', 'Identifier')
self:add_link('@function', 'Function')
self:add_link('@keyword', 'Keyword')
self:add_link('@string', 'String')
self:add_link('@number', 'Number')
return self
end
function M.Builder:add_lsp_highlights()
local c = self.palette
self:add_highlight('DiagnosticError', { fg = c.red })
self:add_highlight('DiagnosticWarn', { fg = c.yellow })
self:add_highlight('DiagnosticInfo', { fg = c.blue })
self:add_highlight('DiagnosticHint', { fg = c.cyan })
return self
end
function M.Builder:set_terminal_colors()
if not self.options.terminal_colors then
return self
end
local c = self.palette
vim.g.terminal_color_0 = c.black
vim.g.terminal_color_1 = c.red
vim.g.terminal_color_2 = c.green
vim.g.terminal_color_3 = c.yellow
vim.g.terminal_color_4 = c.blue
vim.g.terminal_color_5 = c.magenta
vim.g.terminal_color_6 = c.cyan
vim.g.terminal_color_7 = c.white
vim.g.terminal_color_8 = c.bright_black
vim.g.terminal_color_9 = c.bright_red
vim.g.terminal_color_10 = c.bright_green
vim.g.terminal_color_11 = c.bright_yellow
vim.g.terminal_color_12 = c.bright_blue
vim.g.terminal_color_13 = c.bright_magenta
vim.g.terminal_color_14 = c.bright_cyan
vim.g.terminal_color_15 = c.bright_white
return self
end
function M.Builder:apply()
-- Clear existing highlights
vim.cmd('highlight clear')
if vim.fn.exists('syntax_on') then
vim.cmd('syntax reset')
end
-- Set colorscheme name
vim.g.colors_name = self.name
-- Set background
vim.o.background = self.options.background
-- Apply highlights
for group, opts in pairs(self.highlights) do
local cmd = 'highlight ' .. group
if opts.fg then cmd = cmd .. ' guifg=' .. opts.fg end
if opts.bg then cmd = cmd .. ' guibg=' .. opts.bg end
if opts.sp then cmd = cmd .. ' guisp=' .. opts.sp end
local attrs = {}
if opts.bold then table.insert(attrs, 'bold') end
if opts.italic then table.insert(attrs, 'italic') end
if opts.underline then table.insert(attrs, 'underline') end
if opts.undercurl then table.insert(attrs, 'undercurl') end
if opts.strikethrough then table.insert(attrs, 'strikethrough') end
if opts.reverse then table.insert(attrs, 'reverse') end
if #attrs > 0 then
local attr_str = table.concat(attrs, ',')
cmd = cmd .. ' gui=' .. attr_str .. ' cterm=' .. attr_str
end
vim.cmd(cmd)
end
-- Apply links
for from, to in pairs(self.links) do
vim.cmd(string.format('highlight! link %s %s', from, to))
end
-- Set terminal colors
self:set_terminal_colors()
end
function M.Builder:build()
self:add_ui_highlights()
:add_syntax_highlights()
:add_treesitter_highlights()
:add_lsp_highlights()
:apply()
end
-- Example usage:
--[[
local builder = require('colorscheme.builder')
local palette = {
bg = '#282c34',
fg = '#abb2bf',
red = '#e06c75',
green = '#98c379',
yellow = '#e5c07b',
blue = '#61afef',
purple = '#c678dd',
cyan = '#56b6c2',
orange = '#d19a66',
-- ... more colors
}
builder.new('myscheme')
:set_palette(palette)
:set_options({ italic_comments = true })
:build()
]]
return M25.4 Terminal Color Management
25.4.1 Terminal Color Detection
-- lua/colorscheme/terminal.lua
local M = {}
-- Detect terminal emulator
function M.detect_terminal()
local term_program = vim.env.TERM_PROGRAM
local term = vim.env.TERM
if term_program == 'iTerm.app' then
return 'iterm2'
elseif term_program == 'Apple_Terminal' then
return 'apple_terminal'
elseif vim.env.ALACRITTY_SOCKET then
return 'alacritty'
elseif vim.env.KITTY_WINDOW_ID then
return 'kitty'
elseif term:match('tmux') then
return 'tmux'
elseif term:match('screen') then
return 'screen'
elseif term:match('xterm') then
return 'xterm'
else
return 'unknown'
end
end
-- Check true color support
function M.supports_true_color()
if vim.fn.has('gui_running') == 1 then
return true
end
if vim.fn.has('termguicolors') == 0 then
return false
end
local colorterm = vim.env.COLORTERM
if colorterm == 'truecolor' or colorterm == '24bit' then
return true
end
local term = M.detect_terminal()
local true_color_terms = {
iterm2 = true,
kitty = true,
alacritty = true
}
return true_color_terms[term] or false
end
-- Get color count
function M.get_color_count()
if M.supports_true_color() then
return 16777216 -- 24-bit
elseif vim.o.t_Co == 256 then
return 256
else
return 16
end
end
-- Convert 24-bit color to 256-color
function M.rgb_to_256(hex)
hex = hex:gsub('#', '')
local r = tonumber(hex:sub(1, 2), 16)
local g = tonumber(hex:sub(3, 4), 16)
local b = tonumber(hex:sub(5, 6), 16)
-- Grayscale
if r == g and g == b then
if r < 8 then
return 16
elseif r > 248 then
return 231
else
return math.floor(((r - 8) / 10)) + 232
end
end
-- Color cube
local function to_6(val)
if val < 48 then return 0
elseif val < 115 then return 1
else return math.floor((val - 35) / 40)
end
end
return 16 + 36 * to_6(r) + 6 * to_6(g) + to_6(b)
end
-- Setup terminal colors based on capabilities
function M.setup_optimal_colors()
if M.supports_true_color() then
vim.o.termguicolors = true
return '24bit'
elseif vim.o.t_Co >= 256 then
vim.o.termguicolors = false
vim.o.t_Co = 256
return '256color'
else
vim.o.termguicolors = false
vim.o.t_Co = 16
return '16color'
end
end
return M25.4.2 Fallback Color Schemes
-- lua/colorscheme/fallback.lua
local M = {}
local terminal = require('colorscheme.terminal')
-- Provide fallback colors for limited terminals
function M.get_fallback_palette()
local color_count = terminal.get_color_count()
if color_count >= 256 then
return {
black = 0,
red = 1,
green = 2,
yellow = 3,
blue = 4,
magenta = 5,
cyan = 6,
white = 7,
bright_black = 8,
bright_red = 9,
bright_green = 10,
bright_yellow = 11,
bright_blue = 12,
bright_magenta = 13,
bright_cyan = 14,
bright_white = 15,
-- Extended 256-color codes
gray = 244,
dark_red = 124,
dark_green = 28,
dark_yellow = 136,
dark_blue = 24,
dark_magenta = 90,
dark_cyan = 23,
orange = 208,
}
else
return {
black = 0,
red = 1,
green = 2,
yellow = 3,
blue = 4,
magenta = 5,
cyan = 6,
white = 7,
bright_black = 8,
bright_red = 9,
bright_green = 10,
bright_yellow = 11,
bright_blue = 12,
bright_magenta = 13,
bright_cyan = 14,
bright_white = 15
}
end
end
-- Apply colorscheme with fallbacks
function M.apply_with_fallback(gui_colors, term_colors)
local supports_true = terminal.supports_true_color()
for group, opts in pairs(gui_colors) do
local cmd = 'highlight ' .. group
if supports_true then
if opts.fg then cmd = cmd .. ' guifg=' .. opts.fg end
if opts.bg then cmd = cmd .. ' guibg=' .. opts.bg end
end
-- Always provide terminal fallback
if term_colors[group] then
local term_opts = term_colors[group]
if term_opts.fg then cmd = cmd .. ' ctermfg=' .. term_opts.fg end
if term_opts.bg then cmd = cmd .. ' ctermbg=' .. term_opts.bg end
end
if opts.attr then
cmd = cmd .. ' gui=' .. opts.attr .. ' cterm=' .. opts.attr
end
vim.cmd(cmd)
end
end
return M25.5 Colorscheme Management
25.5.1 Colorscheme Switcher
-- lua/colorscheme/switcher.lua
local M = {}
M.colorschemes = {}
M.current_index = 1
-- Register colorschemes
function M.register(schemes)
M.colorschemes = schemes
end
-- Switch to next colorscheme
function M.next()
M.current_index = M.current_index + 1
if M.current_index > #M.colorschemes then
M.current_index = 1
end
local scheme = M.colorschemes[M.current_index]
vim.cmd('colorscheme ' .. scheme)
print('Colorscheme: ' .. scheme)
end
-- Switch to previous colorscheme
function M.prev()
M.current_index = M.current_index - 1
if M.current_index < 1 then
M.current_index = #M.colorschemes
end
local scheme = M.colorschemes[M.current_index]
vim.cmd('colorscheme ' .. scheme)
print('Colorscheme: ' .. scheme)
end
-- Random colorscheme
function M.random()
math.randomseed(os.time())
M.current_index = math.random(1, #M.colorschemes)
local scheme = M.colorschemes[M.current_index]
vim.cmd('colorscheme ' .. scheme)
print('Colorscheme: ' .. scheme)
end
-- Select from menu
function M.select()
vim.ui.select(M.colorschemes, {
prompt = 'Select colorscheme:',
}, function(choice, idx)
if choice then
M.current_index = idx
vim.cmd('colorscheme ' .. choice)
end
end)
end
-- Setup keymaps
function M.setup_keymaps()
vim.keymap.set('n', '<leader>cn', M.next, { desc = 'Next colorscheme' })
vim.keymap.set('n', '<leader>cp', M.prev, { desc = 'Previous colorscheme' })
vim.keymap.set('n', '<leader>cr', M.random, { desc = 'Random colorscheme' })
vim.keymap.set('n', '<leader>cs', M.select, { desc = 'Select colorscheme' })
end
-- Example usage:
--[[
require('colorscheme.switcher').register({
'gruvbox',
'tokyonight',
'catppuccin',
'nord',
'onedark'
})
]]
return M25.5.2 Colorscheme Persistence
-- lua/colorscheme/persistence.lua
local M = {}
M.config_file = vim.fn.stdpath('data') .. '/colorscheme.json'
-- Save current colorscheme
function M.save()
local data = {
name = vim.g.colors_name,
background = vim.o.background,
timestamp = os.time()
}
local file = io.open(M.config_file, 'w')
if file then
file:write(vim.json.encode(data))
file:close()
return true
end
return false
end
-- Load saved colorscheme
function M.load()
local file = io.open(M.config_file, 'r')
if not file then
return nil
end
local content = file:read('*a')
file:close()
local ok, data = pcall(vim.json.decode, content)
if not ok or not data then
return nil
end
return data
end
-- Apply saved colorscheme
function M.apply()
local data = M.load()
if not data then
return false
end
pcall(function()
vim.o.background = data.background
vim.cmd('colorscheme ' .. data.name)
end)
return true
end
-- Auto-save on colorscheme change
function M.setup_autosave()
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
vim.defer_fn(M.save, 100)
end
})
end
return M25.5.3 Preview System
-- lua/colorscheme/preview.lua
local M = {}
-- Create preview window
function M.create_preview_window()
local buf = vim.api.nvim_create_buf(false, true)
-- Sample code for preview
local lines = {
'-- Lua code sample',
'local M = {}',
'',
'function M.greet(name)',
' -- Print greeting',
' print("Hello, " .. name .. "!")',
' return true',
'end',
'',
'local numbers = {1, 2, 3, 4, 5}',
'local result = 0',
'',
'for i, num in ipairs(numbers) do',
' result = result + num',
'end',
'',
'return M',
}
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(buf, 'filetype', 'lua')
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
-- Create floating window
local width = 60
local height = #lines + 2
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = (vim.o.columns - width) / 2,
row = (vim.o.lines - height) / 2,
style = 'minimal',
border = 'rounded',
title = ' Colorscheme Preview ',
title_pos = 'center'
})
return buf, win
end
-- Preview colorscheme
function M.preview(colorscheme)
local original = vim.g.colors_name
local buf, win = M.create_preview_window()
-- Apply colorscheme
pcall(vim.cmd, 'colorscheme ' .. colorscheme)
-- Keymap to close and revert
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
pcall(vim.cmd, 'colorscheme ' .. original)
end, { buffer = buf })
-- Keymap to accept
vim.keymap.set('n', '<CR>', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf })
end
-- Preview all available colorschemes
function M.preview_all()
local schemes = vim.fn.getcompletion('', 'color')
local current_idx = 1
local buf, win = M.create_preview_window()
local function apply_scheme()
if current_idx >= 1 and current_idx <= #schemes then
pcall(vim.cmd, 'colorscheme ' .. schemes[current_idx])
vim.api.nvim_buf_set_name(buf, 'Preview: ' .. schemes[current_idx])
end
end
apply_scheme()
-- Navigation keymaps
vim.keymap.set('n', 'n', function()
current_idx = current_idx + 1
if current_idx > #schemes then current_idx = 1 end
apply_scheme()
end, { buffer = buf })
vim.keymap.set('n', 'p', function()
current_idx = current_idx - 1
if current_idx < 1 then current_idx = #schemes end
apply_scheme()
end, { buffer = buf })
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf })
end
return M✅ End of Chapter 25: Colorschemes and Themes
You now have comprehensive knowledge of creating, managing, and optimizing colorschemes in Vim and Neovim. This chapter covered fundamental color theory, building complete colorschemes in both VimScript and Lua, dynamic color adjustment techniques, terminal color management, and advanced features like adaptive themes, colorscheme switching, and preview systems.
Chapter #26: Statusline and Tabline
The statusline and tabline are essential UI components in Vim/Neovim that provide visual feedback about your editing session. This chapter covers everything from basic configuration to building custom, feature-rich statuslines and tablines using both Vimscript and Lua.
Understanding the Statusline
The statusline appears at the bottom of each window, displaying information about the current buffer, file status, cursor position, and more.
Basic Statusline Configuration
The simplest way to configure a statusline is using the
statusline option:
" Show basic file information
set statusline=%f\ %m%r%h%w
Statusline Format Syntax
Vim uses special % items to display dynamic
information:
| Item | Description |
|---|---|
%f |
Relative file path |
%F |
Full file path |
%t |
Filename (tail) only |
%m |
Modified flag [+] |
%r |
Readonly flag [RO] |
%h |
Help buffer flag |
%w |
Preview window flag |
%y |
Filetype [vim] |
%l |
Current line number |
%L |
Total lines |
%c |
Column number |
%p |
Percentage through file |
%= |
Separation point (left/right alignment) |
Building a Custom Statusline
Here’s a more comprehensive statusline configuration:
set statusline=
set statusline+=%#PmenuSel# " Highlight group
set statusline+=\ %f\ " Filename
set statusline+=%#LineNr# " Change highlight
set statusline+=\ %m%r%h%w " Flags
set statusline+=%= " Right align
set statusline+=%#CursorColumn# " Highlight group
set statusline+=\ %y " Filetype
set statusline+=\ %{&fileencoding?&fileencoding:&encoding}
set statusline+=\[%{&fileformat}\] " File format
set statusline+=\ %p%% " Percentage
set statusline+=\ %l:%c " Line:Column
set statusline+=\
Conditional Display
Use ternary operators for conditional elements:
" Show modified indicator only when buffer is modified
set statusline+=%{&modified?'[+]':''}
" Show readonly indicator
set statusline+=%{&readonly?'[RO]':''}
" Show paste mode
set statusline+=%{&paste?'[PASTE]':''}
Advanced Statusline with Functions
For complex logic, define Vimscript functions:
function! GitBranch()
let l:branch = system("git branch --show-current 2>/dev/null | tr -d '\n'")
return l:branch != '' ? ' ' . l:branch : ''
endfunction
function! LspStatus() abort
if luaeval('#vim.lsp.buf_get_clients() > 0')
return ' LSP'
endif
return ''
endfunction
function! StatuslineMode()
let l:mode = mode()
let l:mode_map = {
\ 'n': 'NORMAL',
\ 'i': 'INSERT',
\ 'v': 'VISUAL',
\ 'V': 'V-LINE',
\ "\<C-v>": 'V-BLOCK',
\ 'c': 'COMMAND',
\ 'R': 'REPLACE'
\ }
return get(l:mode_map, l:mode, l:mode)
endfunction
set statusline=%{StatuslineMode()}\
set statusline+=%f\ %m%r%h%w
set statusline+=%{GitBranch()}
set statusline+=%=
set statusline+=%{LspStatus()}
set statusline+=\ %y\ %l:%c\ %p%%
Lua-Based Statusline
Neovim’s Lua API provides more flexibility and better performance:
-- Basic Lua statusline
local function statusline()
local parts = {}
-- Mode
local mode_map = {
n = 'NORMAL',
i = 'INSERT',
v = 'VISUAL',
V = 'V-LINE',
['\22'] = 'V-BLOCK', -- Ctrl-V
c = 'COMMAND',
R = 'REPLACE',
}
local mode = vim.api.nvim_get_mode().mode
table.insert(parts, mode_map[mode] or mode)
-- Filename with modified flag
local filename = vim.fn.expand('%:t')
if filename == '' then filename = '[No Name]' end
if vim.bo.modified then filename = filename .. ' [+]' end
table.insert(parts, filename)
-- Separator
table.insert(parts, '%=')
-- Filetype
if vim.bo.filetype ~= '' then
table.insert(parts, vim.bo.filetype)
end
-- Position
local line = vim.fn.line('.')
local col = vim.fn.col('.')
local total = vim.fn.line('$')
table.insert(parts, string.format('%d:%d/%d', line, col, total))
return table.concat(parts, ' | ')
end
vim.opt.statusline = '%!v:lua.require("statusline").render()'Modular Lua Statusline
Create a proper module structure:
-- lua/statusline.lua
local M = {}
-- Module components
M.components = {}
function M.components.mode()
local mode_map = {
n = { label = 'NORMAL', hl = 'StatusLineNormal' },
i = { label = 'INSERT', hl = 'StatusLineInsert' },
v = { label = 'VISUAL', hl = 'StatusLineVisual' },
V = { label = 'V-LINE', hl = 'StatusLineVisual' },
['\22'] = { label = 'V-BLOCK', hl = 'StatusLineVisual' },
c = { label = 'COMMAND', hl = 'StatusLineCommand' },
R = { label = 'REPLACE', hl = 'StatusLineReplace' },
t = { label = 'TERMINAL', hl = 'StatusLineTerminal' },
}
local mode = vim.api.nvim_get_mode().mode
local mode_info = mode_map[mode] or { label = mode, hl = 'StatusLine' }
return string.format('%%#%s# %s %%*', mode_info.hl, mode_info.label)
end
function M.components.filename()
local filename = vim.fn.expand('%:t')
if filename == '' then filename = '[No Name]' end
local modified = vim.bo.modified and ' [+]' or ''
local readonly = vim.bo.readonly and ' [RO]' or ''
return string.format(' %s%s%s ', filename, modified, readonly)
end
function M.components.git_branch()
-- Using vim.fn.system for git info
local handle = io.popen('git branch --show-current 2>/dev/null')
if not handle then return '' end
local branch = handle:read('*a'):gsub('\n', '')
handle:close()
if branch ~= '' then
return string.format(' %s ', branch)
end
return ''
end
function M.components.lsp_status()
local clients = vim.lsp.get_active_clients({ bufnr = 0 })
if #clients > 0 then
local client_names = {}
for _, client in ipairs(clients) do
table.insert(client_names, client.name)
end
return string.format(' LSP[%s] ', table.concat(client_names, ','))
end
return ''
end
function M.components.diagnostics()
if not vim.diagnostic then return '' end
local counts = {
errors = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.ERROR }),
warnings = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.WARN }),
info = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.INFO }),
hints = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.HINT }),
}
local parts = {}
if counts.errors > 0 then
table.insert(parts, string.format('%%#DiagnosticError# E:%d%%*', counts.errors))
end
if counts.warnings > 0 then
table.insert(parts, string.format('%%#DiagnosticWarn# W:%d%%*', counts.warnings))
end
if counts.info > 0 then
table.insert(parts, string.format('%%#DiagnosticInfo# I:%d%%*', counts.info))
end
if counts.hints > 0 then
table.insert(parts, string.format('%%#DiagnosticHint# H:%d%%*', counts.hints))
end
if #parts > 0 then
return ' ' .. table.concat(parts, ' ') .. ' '
end
return ''
end
function M.components.filetype()
local ft = vim.bo.filetype
if ft ~= '' then
return string.format(' %s ', ft)
end
return ''
end
function M.components.encoding()
local encoding = vim.opt.fileencoding:get()
if encoding == '' then
encoding = vim.opt.encoding:get()
end
return string.format(' %s ', encoding)
end
function M.components.position()
local line = vim.fn.line('.')
local col = vim.fn.col('.')
local total = vim.fn.line('$')
local percent = math.floor((line / total) * 100)
return string.format(' %d:%d %d%%%% ', line, col, percent)
end
function M.render()
local left = {
M.components.mode(),
M.components.filename(),
M.components.git_branch(),
}
local right = {
M.components.diagnostics(),
M.components.lsp_status(),
M.components.filetype(),
M.components.encoding(),
M.components.position(),
}
return table.concat(left, '') .. '%=' .. table.concat(right, '')
end
-- Setup function
function M.setup()
-- Define custom highlight groups
vim.api.nvim_set_hl(0, 'StatusLineNormal', { bg = '#5e81ac', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'StatusLineInsert', { bg = '#a3be8c', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'StatusLineVisual', { bg = '#b48ead', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'StatusLineCommand', { bg = '#ebcb8b', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'StatusLineReplace', { bg = '#bf616a', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'StatusLineTerminal', { bg = '#88c0d0', fg = '#2e3440', bold = true })
-- Set the statusline
vim.opt.statusline = '%!v:lua.require("statusline").render()'
-- Refresh on certain events
local group = vim.api.nvim_create_augroup('StatuslineRefresh', { clear = true })
vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufEnter', 'DiagnosticChanged' }, {
group = group,
callback = function()
vim.cmd('redrawstatus')
end,
})
end
return MUsage:
-- In your init.lua
require('statusline').setup()Performance Optimization
Caching Expensive Operations
local M = {}
-- Cache with TTL
M.cache = {}
function M.cached_call(key, fn, ttl)
ttl = ttl or 1000 -- Default 1 second
local now = vim.loop.now()
local cached = M.cache[key]
if cached and (now - cached.time) < ttl then
return cached.value
end
local value = fn()
M.cache[key] = { value = value, time = now }
return value
end
-- Use in components
function M.components.git_branch()
return M.cached_call('git_branch', function()
local handle = io.popen('git branch --show-current 2>/dev/null')
if not handle then return '' end
local branch = handle:read('*a'):gsub('\n', '')
handle:close()
return branch ~= '' and string.format(' %s ', branch) or ''
end, 5000) -- Cache for 5 seconds
endAsync Git Status
function M.components.git_branch_async()
local cached = M.cache.git_branch or ''
-- Update in background
vim.fn.jobstart('git branch --show-current 2>/dev/null', {
stdout_buffered = true,
on_stdout = function(_, data, _)
if data and data[1] and data[1] ~= '' then
M.cache.git_branch = string.format(' %s ', data[1])
vim.cmd('redrawstatus')
end
end,
})
return cached
endUnderstanding the Tabline
The tabline appears at the top of the screen, showing open tabs and their windows.
Basic Tabline Configuration
" Enable tabline
set showtabline=2 " Always show tabline
" Simple tabline
set tabline=%!MyTabLine()
function! MyTabLine()
let s = ''
for i in range(tabpagenr('$'))
" Select highlighting
if i + 1 == tabpagenr()
let s .= '%#TabLineSel#'
else
let s .= '%#TabLine#'
endif
" Set the tab page number (for mouse clicks)
let s .= '%' . (i + 1) . 'T'
" The label
let s .= ' %{MyTabLabel(' . (i + 1) . ')} '
endfor
" After the last tab fill with TabLineFill and reset tab page nr
let s .= '%#TabLineFill#%T'
return s
endfunction
function! MyTabLabel(n)
let buflist = tabpagebuflist(a:n)
let winnr = tabpagewinnr(a:n)
let bufname = bufname(buflist[winnr - 1])
" Show buffer name or [No Name]
if bufname == ''
return '[No Name]'
else
return fnamemodify(bufname, ':t')
endif
endfunction
Lua-Based Tabline
-- lua/tabline.lua
local M = {}
function M.get_tab_label(tab_nr)
local buflist = vim.fn.tabpagebuflist(tab_nr)
local winnr = vim.fn.tabpagewinnr(tab_nr)
local bufnr = buflist[winnr]
local bufname = vim.api.nvim_buf_get_name(bufnr)
if bufname == '' then
return '[No Name]'
else
return vim.fn.fnamemodify(bufname, ':t')
end
end
function M.render()
local tabs = {}
local current_tab = vim.fn.tabpagenr()
local total_tabs = vim.fn.tabpagenr('$')
for i = 1, total_tabs do
local hl = (i == current_tab) and '%#TabLineSel#' or '%#TabLine#'
local label = M.get_tab_label(i)
-- Modified indicator
local buflist = vim.fn.tabpagebuflist(i)
local has_modified = false
for _, bufnr in ipairs(buflist) do
if vim.api.nvim_buf_get_option(bufnr, 'modified') then
has_modified = true
break
end
end
local modified = has_modified and ' [+]' or ''
table.insert(tabs, string.format('%s %d: %s%s ', hl, i, label, modified))
end
return table.concat(tabs, '') .. '%#TabLineFill#'
end
function M.setup()
vim.opt.showtabline = 2
vim.opt.tabline = '%!v:lua.require("tabline").render()'
end
return MAdvanced Tabline with Icons
-- lua/tabline.lua (enhanced)
local M = {}
-- Devicons integration
local has_devicons, devicons = pcall(require, 'nvim-web-devicons')
function M.get_icon(filename)
if not has_devicons then
return ''
end
local extension = vim.fn.fnamemodify(filename, ':e')
local icon, _ = devicons.get_icon(filename, extension, { default = true })
return icon and (icon .. ' ') or ''
end
function M.get_tab_info(tab_nr)
local buflist = vim.fn.tabpagebuflist(tab_nr)
local winnr = vim.fn.tabpagewinnr(tab_nr)
local bufnr = buflist[winnr]
local bufname = vim.api.nvim_buf_get_name(bufnr)
local filename = bufname == '' and '[No Name]' or vim.fn.fnamemodify(bufname, ':t')
local icon = M.get_icon(filename)
-- Check for modified buffers
local has_modified = false
for _, buf in ipairs(buflist) do
if vim.api.nvim_buf_get_option(buf, 'modified') then
has_modified = true
break
end
end
return {
filename = filename,
icon = icon,
modified = has_modified,
num_windows = #buflist,
}
end
function M.render()
local tabs = {}
local current_tab = vim.fn.tabpagenr()
local total_tabs = vim.fn.tabpagenr('$')
for i = 1, total_tabs do
local hl = (i == current_tab) and '%#TabLineSel#' or '%#TabLine#'
local info = M.get_tab_info(i)
local modified = info.modified and '● ' or ''
local windows = info.num_windows > 1 and string.format('[%d] ', info.num_windows) or ''
local label = string.format(
'%s %d %s%s%s%s ',
hl,
i,
windows,
info.icon,
info.filename,
modified
)
-- Add click handler
label = string.format('%%%dT%s', i, label)
table.insert(tabs, label)
end
-- Add close button
table.insert(tabs, '%#TabLine# %999X × ')
return table.concat(tabs, '') .. '%#TabLineFill#'
end
function M.setup()
-- Set up highlight groups
vim.api.nvim_set_hl(0, 'TabLine', { bg = '#3b4252', fg = '#d8dee9' })
vim.api.nvim_set_hl(0, 'TabLineSel', { bg = '#5e81ac', fg = '#2e3440', bold = true })
vim.api.nvim_set_hl(0, 'TabLineFill', { bg = '#2e3440' })
vim.opt.showtabline = 2
vim.opt.tabline = '%!v:lua.require("tabline").render()'
end
return MWinbar (Neovim 0.8+)
The winbar is similar to the statusline but appears at the top of each window:
-- lua/winbar.lua
local M = {}
function M.get_location()
local breadcrumbs = {}
-- Get file path components
local filepath = vim.fn.expand('%:~:.')
if filepath ~= '' then
local parts = vim.split(filepath, '/')
for i, part in ipairs(parts) do
if i == #parts then
table.insert(breadcrumbs, string.format('%%#WinBarFilename#%s%%*', part))
else
table.insert(breadcrumbs, part)
end
end
end
return table.concat(breadcrumbs, ' ' .. string.rep('>', 1) .. ' ')
end
function M.get_symbol()
-- Integration with LSP document symbols (simplified)
local clients = vim.lsp.get_active_clients({ bufnr = 0 })
if #clients == 0 then return '' end
-- This would require additional implementation with textDocument/documentSymbol
return ''
end
function M.render()
local parts = {}
local location = M.get_location()
if location ~= '' then
table.insert(parts, location)
end
local symbol = M.get_symbol()
if symbol ~= '' then
table.insert(parts, symbol)
end
return table.concat(parts, ' ')
end
function M.setup()
vim.api.nvim_set_hl(0, 'WinBar', { bg = 'NONE', fg = '#d8dee9' })
vim.api.nvim_set_hl(0, 'WinBarFilename', { bg = 'NONE', fg = '#88c0d0', bold = true })
vim.opt.winbar = '%!v:lua.require("winbar").render()'
end
return MIntegration with keyd and tmux
Based on the provided manpages, here’s how statusline/tabline integrate with your workflow:
Detecting tmux Sessions
function M.components.tmux_info()
local tmux_session = vim.env.TMUX
if tmux_session then
-- Extract session name from TMUX environment variable
local session = vim.fn.system("tmux display-message -p '#S'"):gsub('\n', '')
return string.format(' [tmux:%s] ', session)
end
return ''
endKeyboard Layout Detection (keyd)
Since keyd operates at the system level, you can show the current keyboard state:
function M.components.keyboard_layout()
-- This would require custom integration with keyd
-- Example: reading from a state file or socket
local handle = io.popen('setxkbmap -query 2>/dev/null | grep layout | cut -d: -f2')
if not handle then return '' end
local layout = handle:read('*a'):gsub('%s+', '')
handle:close()
if layout ~= '' then
return string.format(' [%s] ', layout:upper())
end
return ''
endComplete Example Configuration
Here’s a full configuration bringing everything together:
-- lua/ui/init.lua
local M = {}
function M.setup()
require('ui.statusline').setup()
require('ui.tabline').setup()
-- Optional: winbar for Neovim 0.8+
if vim.fn.has('nvim-0.8') == 1 then
require('ui.winbar').setup()
end
end
return M
-- In your init.lua
require('ui').setup()Popular Statusline Plugins
While custom statuslines are powerful, consider these popular alternatives:
lualine.nvim
require('lualine').setup {
options = {
theme = 'auto',
section_separators = { left = '', right = '' },
component_separators = { left = '', right = '' },
},
sections = {
lualine_a = {'mode'},
lualine_b = {'branch', 'diff', 'diagnostics'},
lualine_c = {'filename'},
lualine_x = {'encoding', 'fileformat', 'filetype'},
lualine_y = {'progress'},
lualine_z = {'location'}
},
}heirline.nvim
A more flexible, configuration-based approach:
local conditions = require("heirline.conditions")
local utils = require("heirline.utils")
local ViMode = {
init = function(self)
self.mode = vim.fn.mode(1)
end,
static = {
mode_names = {
n = "N",
i = "I",
v = "V",
V = "V-L",
["\22"] = "V-B",
c = "C",
R = "R",
},
},
provider = function(self)
return " %2("..self.mode_names[self.mode].."%)"
end,
hl = function(self)
local mode = self.mode:sub(1, 1)
return { bg = self:mode_color(), bold = true }
end,
}
require('heirline').setup({
statusline = { ViMode, ... },
tabline = { ... },
winbar = { ... },
})Testing and Debugging
Debug Statusline Rendering
-- Add debug component
function M.components.debug()
if vim.g.statusline_debug then
local time = os.date('%H:%M:%S')
return string.format(' [%s] ', time)
end
return ''
end
-- Toggle debug mode
vim.keymap.set('n', '<leader>sd', function()
vim.g.statusline_debug = not vim.g.statusline_debug
vim.cmd('redrawstatus')
end, { desc = 'Toggle statusline debug' })Performance Profiling
function M.profile()
local start = vim.loop.hrtime()
local result = M.render()
local duration = (vim.loop.hrtime() - start) / 1e6
print(string.format('Statusline render took %.2fms', duration))
return result
endBest Practices
Keep it Fast: Statusline renders frequently. Cache expensive operations.
Async Operations: Use
vim.looporvim.fn.jobstartfor external commands.Conditional Features: Only show LSP status when LSP is active.
Highlight Groups: Use semantic highlight groups for consistency with colorschemes.
Mouse Support: Add click handlers with
%{number}Tand%X.Truncation: Use
%<to mark truncation points for narrow windows.Window-Local: Consider
vim.wo.statuslinefor window-specific statuslines.
Summary
This chapter covered:
Basic statusline/tabline configuration with format strings
Building custom statuslines using Vimscript functions
Advanced Lua-based implementations with modular components
Performance optimization techniques (caching, async)
Winbar configuration for Neovim 0.8+
Integration with tmux and system tools
Popular plugin alternatives
Testing and debugging approaches
The statusline and tabline are highly customizable UI elements that can significantly enhance your workflow. Whether you build your own or use a plugin, understanding the underlying mechanisms helps you create an interface that works best for your needs.
Chapter #27: Language Server Protocol (LSP)
The Language Server Protocol (LSP) revolutionizes how editors provide language intelligence features. Instead of each editor implementing language support independently, LSP enables a single language server to work with any LSP-compliant editor. This chapter covers LSP fundamentals, Neovim’s built-in LSP client, configuration, and advanced usage patterns.
Understanding LSP
What is LSP?
LSP is a protocol that standardizes communication between editors and language intelligence tools. It provides:
Code completion (IntelliSense)
Go to definition/references
Hover documentation
Diagnostics (errors, warnings)
Code actions (quick fixes, refactoring)
Formatting
Rename refactoring
Signature help
Architecture
┌│─────────────┐ ┌──────────────────┐
││ │ JSON-RPC │ │
││ Neovim │◄─────────►│ Language Server │
││ (Client) │ │ (rust-analyzer, │
││ │ │ pyright, etc) │ └─────────────┘ └──────────────────┘
Neovim’s Built-in LSP Client
Neovim 0.5+ includes a native LSP client accessible via
vim.lsp.
Basic Setup
-- lua/lsp/init.lua
local M = {}
function M.setup()
-- Set up LSP keymaps and options when a language server attaches
vim.api.nvim_create_autocmd('LspAttach', {
group = vim.api.nvim_create_augroup('UserLspConfig', {}),
callback = function(ev)
-- Enable completion triggered by <c-x><c-o>
vim.bo[ev.buf].omnifunc = 'v:lua.vim.lsp.omnifunc'
-- Buffer local mappings
local opts = { buffer = ev.buf }
vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, opts)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, opts)
vim.keymap.set('n', '<C-k>', vim.lsp.buf.signature_help, opts)
vim.keymap.set('n', '<leader>wa', vim.lsp.buf.add_workspace_folder, opts)
vim.keymap.set('n', '<leader>wr', vim.lsp.buf.remove_workspace_folder, opts)
vim.keymap.set('n', '<leader>wl', function()
print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
end, opts)
vim.keymap.set('n', '<leader>D', vim.lsp.buf.type_definition, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
vim.keymap.set({ 'n', 'v' }, '<leader>ca', vim.lsp.buf.code_action, opts)
vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts)
vim.keymap.set('n', '<leader>f', function()
vim.lsp.buf.format { async = true }
end, opts)
end,
})
end
return MConfiguring Language Servers
Using nvim-lspconfig
The nvim-lspconfig plugin provides pre-configured
settings for popular language servers:
-- Install nvim-lspconfig first
-- lua/lsp/servers.lua
local M = {}
function M.setup()
local lspconfig = require('lspconfig')
-- Default capabilities (for nvim-cmp integration)
local capabilities = require('cmp_nvim_lsp').default_capabilities()
-- Python (pyright)
lspconfig.pyright.setup {
capabilities = capabilities,
settings = {
python = {
analysis = {
typeCheckingMode = "basic",
autoSearchPaths = true,
useLibraryCodeForTypes = true,
}
}
}
}
-- Lua (lua_ls)
lspconfig.lua_ls.setup {
capabilities = capabilities,
settings = {
Lua = {
runtime = {
version = 'LuaJIT',
},
diagnostics = {
globals = { 'vim' },
},
workspace = {
library = vim.api.nvim_get_runtime_file("", true),
checkThirdParty = false,
},
telemetry = {
enable = false,
},
},
},
}
-- TypeScript/JavaScript (tsserver)
lspconfig.tsserver.setup {
capabilities = capabilities,
on_attach = function(client, bufnr)
-- Disable tsserver formatting (use prettier instead)
client.server_capabilities.documentFormattingProvider = false
end,
}
-- Rust (rust_analyzer)
lspconfig.rust_analyzer.setup {
capabilities = capabilities,
settings = {
['rust-analyzer'] = {
checkOnSave = {
command = "clippy"
},
cargo = {
allFeatures = true,
},
}
}
}
-- Go (gopls)
lspconfig.gopls.setup {
capabilities = capabilities,
settings = {
gopls = {
analyses = {
unusedparams = true,
},
staticcheck = true,
},
},
}
-- C/C++ (clangd)
lspconfig.clangd.setup {
capabilities = capabilities,
cmd = {
"clangd",
"--background-index",
"--clang-tidy",
"--header-insertion=iwyu",
"--completion-style=detailed",
"--function-arg-placeholders",
},
}
-- JSON (jsonls)
lspconfig.jsonls.setup {
capabilities = capabilities,
settings = {
json = {
schemas = require('schemastore').json.schemas(),
validate = { enable = true },
},
},
}
end
return MManual Server Configuration
Without nvim-lspconfig:
-- Start a language server manually
vim.lsp.start({
name = 'my-server',
cmd = { 'path/to/language-server' },
root_dir = vim.fs.dirname(vim.fs.find({'package.json', '.git'}, { upward = true })[1]),
capabilities = capabilities,
settings = {
-- Server-specific settings
},
})LSP Handlers and Customization
Customizing Hover Windows
-- Custom hover handler
vim.lsp.handlers["textDocument/hover"] = vim.lsp.with(
vim.lsp.handlers.hover, {
border = "rounded",
max_width = 80,
max_height = 20,
}
)
-- Or create completely custom handler
vim.lsp.handlers["textDocument/hover"] = function(_, result, ctx, config)
config = config or {}
config.focus_id = ctx.method
if not (result and result.contents) then
return
end
local markdown_lines = vim.lsp.util.convert_input_to_markdown_lines(result.contents)
markdown_lines = vim.lsp.util.trim_empty_lines(markdown_lines)
if vim.tbl_isempty(markdown_lines) then
return
end
return vim.lsp.util.open_floating_preview(markdown_lines, "markdown", config)
endSignature Help Configuration
vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with(
vim.lsp.handlers.signature_help, {
border = "rounded",
close_events = { "CursorMoved", "BufHidden", "InsertCharPre" },
}
)Diagnostics Configuration
-- Global diagnostic configuration
vim.diagnostic.config({
virtual_text = {
prefix = '●',
source = "if_many",
},
signs = true,
underline = true,
update_in_insert = false,
severity_sort = true,
float = {
border = 'rounded',
source = 'always',
header = '',
prefix = '',
},
})
-- Custom 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 = hl })
endAdvanced LSP Features
Code Actions
-- Enhanced code action picker
local function code_action_menu()
local context = { diagnostics = vim.lsp.diagnostic.get_line_diagnostics() }
local params = vim.lsp.util.make_range_params()
params.context = context
vim.lsp.buf_request(0, 'textDocument/codeAction', params, function(err, result, ctx, config)
if err or not result or vim.tbl_isempty(result) then
print("No code actions available")
return
end
local items = {}
for i, action in ipairs(result) do
local title = action.title:gsub('\r\n', '\\r\\n'):gsub('\n', '\\n')
table.insert(items, string.format("%d. %s", i, title))
end
vim.ui.select(items, {
prompt = 'Code Actions:',
}, function(choice, idx)
if not idx then return end
local action = result[idx]
if action.edit then
vim.lsp.util.apply_workspace_edit(action.edit, 'utf-8')
end
if action.command then
vim.lsp.buf.execute_command(action.command)
end
end)
end)
end
vim.keymap.set('n', '<leader>ca', code_action_menu, { desc = 'Code actions' })Workspace Symbols
-- Search workspace symbols with Telescope integration
local function workspace_symbols()
require('telescope.builtin').lsp_dynamic_workspace_symbols({
fname_width = 50,
symbol_width = 40,
})
end
vim.keymap.set('n', '<leader>ws', workspace_symbols, { desc = 'Workspace symbols' })Document Symbols Outline
-- Simple document symbols outline
local function document_symbols()
vim.lsp.buf.document_symbol()
-- Or use a picker
require('telescope.builtin').lsp_document_symbols({
symbols = {
"Class",
"Function",
"Method",
"Constructor",
"Interface",
"Module",
"Struct",
"Trait",
}
})
end
vim.keymap.set('n', '<leader>ds', document_symbols, { desc = 'Document symbols' })Inlay Hints (Neovim 0.10+)
-- Enable inlay hints
if vim.lsp.inlay_hint then
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client.server_capabilities.inlayHintProvider then
vim.lsp.inlay_hint.enable(args.buf, true)
end
end,
})
-- Toggle inlay hints
vim.keymap.set('n', '<leader>th', function()
vim.lsp.inlay_hint.enable(0, not vim.lsp.inlay_hint.is_enabled(0))
end, { desc = 'Toggle inlay hints' })
endLSP Progress Indication
Using fidget.nvim
require('fidget').setup {
progress = {
display = {
render_limit = 16,
done_ttl = 3,
progress_icon = { pattern = "dots", period = 1 },
},
},
notification = {
window = {
winblend = 0,
},
},
}Custom Progress Handler
local progress_handler = function()
local client_notifs = {}
return function(err, result, ctx, config)
local client_id = ctx.client_id
local val = result.value
if not val.kind then
return
end
local notif_data = client_notifs[client_id]
if val.kind == 'begin' then
local message = string.format("%s: %s", result.token, val.title)
notif_data = {
notification = vim.notify(message, vim.log.levels.INFO, {
title = 'LSP Progress',
icon = '',
timeout = false,
hide_from_history = false,
}),
spinner = 1,
}
client_notifs[client_id] = notif_data
elseif val.kind == 'report' and notif_data then
local message = string.format(
"%s: %s - %s",
result.token,
val.title or '',
val.message or ''
)
notif_data.notification = vim.notify(message, vim.log.levels.INFO, {
replace = notif_data.notification,
hide_from_history = false,
})
elseif val.kind == 'end' and notif_data then
local message = string.format("%s: Complete", result.token)
notif_data.notification = vim.notify(message, vim.log.levels.INFO, {
replace = notif_data.notification,
timeout = 3000,
})
client_notifs[client_id] = nil
end
end
end
vim.lsp.handlers['$/progress'] = progress_handler()Formatting and Linting
Format on Save
-- Format on save with timeout
local format_on_save = vim.api.nvim_create_augroup('FormatOnSave', { clear = true })
vim.api.nvim_create_autocmd('BufWritePre', {
group = format_on_save,
callback = function()
vim.lsp.buf.format({ timeout_ms = 2000 })
end,
})Conditional Formatting
-- Format only specific file types
vim.api.nvim_create_autocmd('BufWritePre', {
group = format_on_save,
pattern = { '*.lua', '*.py', '*.rs', '*.go' },
callback = function()
vim.lsp.buf.format({ timeout_ms = 2000 })
end,
})Range Formatting
-- Format selection in visual mode
vim.keymap.set('v', '<leader>f', function()
vim.lsp.buf.format({ async = true })
end, { desc = 'Format selection' })Multiple Formatters
-- Choose specific formatter
local function format_with_client(client_name)
return function()
vim.lsp.buf.format({
filter = function(client)
return client.name == client_name
end,
timeout_ms = 2000,
})
end
end
vim.keymap.set('n', '<leader>fp', format_with_client('null-ls'), { desc = 'Format with null-ls' })
vim.keymap.set('n', '<leader>ft', format_with_client('tsserver'), { desc = 'Format with tsserver' })Diagnostics Management
Navigate Diagnostics
-- Jump to diagnostics
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, { desc = 'Previous diagnostic' })
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, { desc = 'Next diagnostic' })
-- Jump to errors only
vim.keymap.set('n', '[e', function()
vim.diagnostic.goto_prev({ severity = vim.diagnostic.severity.ERROR })
end, { desc = 'Previous error' })
vim.keymap.set('n', ']e', function()
vim.diagnostic.goto_next({ severity = vim.diagnostic.severity.ERROR })
end, { desc = 'Next error' })Show Diagnostics
-- Show diagnostics in floating window
vim.keymap.set('n', '<leader>d', vim.diagnostic.open_float, { desc = 'Show diagnostics' })
-- Show all diagnostics in location list
vim.keymap.set('n', '<leader>q', vim.diagnostic.setloclist, { desc = 'Diagnostics to loclist' })
-- Show all diagnostics in quickfix
vim.keymap.set('n', '<leader>Q', vim.diagnostic.setqflist, { desc = 'Diagnostics to qflist' })Custom Diagnostic Display
-- Custom diagnostic virtual text
vim.diagnostic.config({
virtual_text = {
format = function(diagnostic)
if diagnostic.severity == vim.diagnostic.severity.ERROR then
return string.format("❌ %s", diagnostic.message)
elseif diagnostic.severity == vim.diagnostic.severity.WARN then
return string.format("⚠️ %s", diagnostic.message)
else
return diagnostic.message
end
end,
},
})LSP Integration with Completion
nvim-cmp Setup
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup({
```lua
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({
['<C-b>'] = cmp.mapping.scroll_docs(-4),
['<C-f>'] = cmp.mapping.scroll_docs(4),
['<C-Space>'] = cmp.mapping.complete(),
['<C-e>'] = cmp.mapping.abort(),
['<CR>'] = cmp.mapping.confirm({ select = true }),
['<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' },
{ name = 'luasnip' },
{ name = 'buffer' },
{ name = 'path' },
}),
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
formatting = {
format = require('lspkind').cmp_format({
mode = 'symbol_text',
maxwidth = 50,
ellipsis_char = '...',
}),
},
})
-- Enable LSP capabilities
local capabilities = require('cmp_nvim_lsp').default_capabilities()
require('lspconfig').pyright.setup { capabilities = capabilities }
require('lspconfig').tsserver.setup { capabilities = capabilities }Summary: Chapter #27 – Language Server Protocol (LSP)
This chapter covered:
Concepts – The JSON-RPC-based architecture of the Language Server Protocol.
Neovim Integration – How Neovim’s built-in LSP client provides advanced language features.
Setup & Configuration – Using
nvim-lspconfigfor language servers (Python, Lua, C++, Rust, etc.).Customization – Modifying handlers for hover, diagnostics, and signature help.
Advanced Features – Code actions, workspace/document symbols, inlay hints, async progress.
Formatting and Diagnostics – Custom formatting logic, error navigation, and display configuration.
Completions – Integration with
nvim-cmpand snippet engines for smooth development workflows.
The next chapter will extend this foundation into Chapter #28: Treesitter and Semantic Syntax Analysis, covering syntax trees, incremental parsing, and plugin-based syntax enhancement.
Chapter #28: Tree-sitter in Neovim
Tree-sitter is a parsing system that builds and maintains syntax trees for source code. Unlike traditional regex-based syntax highlighting, Tree-sitter provides incremental parsing, accurate syntax understanding, and enables powerful text manipulation based on the code’s abstract syntax tree (AST).
Understanding Tree-sitter
What is Tree-sitter?
Tree-sitter is:
A parser generator that creates fast, incremental parsers
Error-resistant – continues parsing even with syntax errors
Incremental – only re-parses changed sections
Language-agnostic – supports 40+ programming languages
Architecture
┌│──────────────┐
││ Source Code │ └──────┬───────┘ │ ▼
┌│──────────────────┐
││ Tree-sitter │
││ Parser │ └──────┬───────────┘ │ ▼
┌│──────────────────┐ ┌─────────────────┐
││ Syntax Tree │───►│ Highlighting │
││ (AST) │ │ Indentation │
││ │ │ Text Objects │
││ │ │ Code Folding │ └──────────────────┘ └─────────────────┘
Advantages Over Regex
| Feature | Regex | Tree-sitter |
|---|---|---|
| Accuracy | Limited | High |
| Context-aware | No | Yes |
| Performance | Good | Excellent |
| Incremental | No | Yes |
| Error recovery | Poor | Good |
Setting Up Tree-sitter
Basic Installation
-- Using lazy.nvim
{
'nvim-treesitter/nvim-treesitter',
build = ':TSUpdate',
config = function()
require('nvim-treesitter.configs').setup({
-- Install parsers synchronously (only applied to `ensure_installed`)
sync_install = false,
-- Automatically install missing parsers when entering buffer
auto_install = true,
-- List of parsers to install
ensure_installed = {
'lua',
'vim',
'vimdoc',
'python',
'javascript',
'typescript',
'rust',
'go',
'c',
'cpp',
'html',
'css',
'json',
'yaml',
'markdown',
'markdown_inline',
'bash',
},
-- Highlighting module
highlight = {
enable = true,
-- Disable vim's regex highlighting
additional_vim_regex_highlighting = false,
},
})
end,
}Manual Parser Installation
" Install a specific parser
:TSInstall python
" Update all installed parsers
:TSUpdate
" Check parser status
:TSInstallInfo
" Uninstall a parser
:TSUninstall python
Syntax Highlighting
Enable Highlighting
require('nvim-treesitter.configs').setup({
highlight = {
enable = true,
-- Disable for large files
disable = function(lang, buf)
local max_filesize = 100 * 1024 -- 100 KB
local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(buf))
if ok and stats and stats.size > max_filesize then
return true
end
end,
-- Use both Tree-sitter and vim regex (not recommended)
additional_vim_regex_highlighting = false,
},
})Custom Highlights
-- Define custom highlight groups
vim.api.nvim_set_hl(0, '@variable', { fg = '#e0def4' })
vim.api.nvim_set_hl(0, '@function', { fg = '#9ccfd8', bold = true })
vim.api.nvim_set_hl(0, '@keyword', { fg = '#31748f', italic = true })
vim.api.nvim_set_hl(0, '@string', { fg = '#f6c177' })
vim.api.nvim_set_hl(0, '@comment', { fg = '#6e6a86', italic = true })
vim.api.nvim_set_hl(0, '@type', { fg = '#9ccfd8' })
vim.api.nvim_set_hl(0, '@constant', { fg = '#eb6f92' })
vim.api.nvim_set_hl(0, '@parameter', { fg = '#e0def4', italic = true })
-- Language-specific overrides
vim.api.nvim_set_hl(0, '@function.python', { fg = '#ebbcba', bold = true })
vim.api.nvim_set_hl(0, '@keyword.return', { fg = '#eb6f92', bold = true })Highlighting Queries
Tree-sitter uses queries to determine highlighting. Here’s a basic query structure:
; highlights.scm for a language
(function_definition
name: (identifier) @function)
(function_call
function: (identifier) @function.call)
(string) @string
(comment) @comment
(number) @number
(
(identifier) @constant
(#match? @constant "^[A-Z_]+$")
)Incremental Selection
Configuration
require('nvim-treesitter.configs').setup({
incremental_selection = {
enable = true,
keymaps = {
init_selection = '<CR>',
node_incremental = '<CR>',
scope_incremental = '<TAB>',
node_decremental = '<S-TAB>',
},
},
})Usage Example
With cursor on a variable:
<CR>– Select the identifier<CR>– Expand to the expression<CR>– Expand to the statement<S-TAB>– Shrink selection back
# Start here ▼
result = calculate(x + y)
# <CR> once: selects "calculate"
# <CR> twice: selects "calculate(x + y)"
# <CR> three times: selects entire lineIndentation
Enable Tree-sitter Indentation
require('nvim-treesitter.configs').setup({
indent = {
enable = true,
-- Disable for specific languages
disable = { 'python', 'yaml' },
},
})Override Indentation Logic
-- Custom indent expression
vim.api.nvim_create_autocmd('FileType', {
pattern = 'lua',
callback = function()
vim.bo.indentexpr = 'v:lua.require("nvim-treesitter.indent").get_indent()'
end,
})Text Objects
Configuration
require('nvim-treesitter.configs').setup({
textobjects = {
select = {
enable = true,
lookahead = true,
keymaps = {
-- Functions
['af'] = '@function.outer',
['if'] = '@function.inner',
-- Classes
['ac'] = '@class.outer',
['ic'] = '@class.inner',
-- Conditionals
['ai'] = '@conditional.outer',
['ii'] = '@conditional.inner',
-- Loops
['al'] = '@loop.outer',
['il'] = '@loop.inner',
-- Parameters/arguments
['aa'] = '@parameter.outer',
['ia'] = '@parameter.inner',
-- Comments
['a/'] = '@comment.outer',
},
},
-- Swap text objects
swap = {
enable = true,
swap_next = {
['<leader>sn'] = '@parameter.inner',
},
swap_previous = {
['<leader>sp'] = '@parameter.inner',
},
},
-- Move to next/previous text object
move = {
enable = true,
set_jumps = true,
goto_next_start = {
[']m'] = '@function.outer',
[']]'] = '@class.outer',
},
goto_next_end = {
[']M'] = '@function.outer',
[']['] = '@class.outer',
},
goto_previous_start = {
['[m'] = '@function.outer',
['[['] = '@class.outer',
},
goto_previous_end = {
['[M'] = '@function.outer',
['[]'] = '@class.outer',
},
},
-- LSP interop
lsp_interop = {
enable = true,
border = 'rounded',
peek_definition_code = {
['<leader>df'] = '@function.outer',
['<leader>dF'] = '@class.outer',
},
},
},
})Usage Examples
-- Select outer function: vaf
-- Select inner function: vif
-- Delete a function: daf
-- Change inner class: cic
-- Yank a conditional: yai
-- Jump to next function: ]m
-- Jump to previous class: [[
-- Swap parameters: <leader>sn (cursor on parameter)Code Folding
Enable Tree-sitter Folding
-- Set fold method to use expressions
vim.opt.foldmethod = 'expr'
vim.opt.foldexpr = 'nvim_treesitter#foldexpr()'
-- Don't fold by default
vim.opt.foldenable = false
-- Set fold level
vim.opt.foldlevel = 99
-- Minimum lines for a fold
vim.opt.foldminlines = 3Custom Fold Text
-- Better fold display
vim.opt.foldtext = [[substitute(getline(v:foldstart),'\\t',repeat('\ ',&tabstop),'g').'...'.trim(getline(v:foldend)) ]]
-- Or use a Lua function
vim.opt.foldtext = 'v:lua.CustomFoldText()'
function _G.CustomFoldText()
local line = vim.fn.getline(vim.v.foldstart)
local line_count = vim.v.foldend - vim.v.foldstart + 1
return line .. ' ... (' .. line_count .. ' lines)'
endFold Configuration
require('nvim-treesitter.configs').setup({
fold = {
enable = true,
disable = {},
},
})Advanced Queries
Understanding Queries
Tree-sitter queries use S-expressions to match syntax nodes:
; Basic pattern
(function_definition) @function
; With fields
(function_definition
name: (identifier) @function.name
parameters: (parameters) @function.params)
; With predicates
(
(identifier) @constant
(#match? @constant "^[A-Z_][A-Z0-9_]*$")
)
; Negation
(
(identifier) @variable
(#not-match? @variable "^[A-Z]")
)Custom Queries
-- Define custom queries in after/queries/{lang}/{query_type}.scm
-- Example: after/queries/python/highlights.scm
-- Or programmatically:
local query = vim.treesitter.query
local python_query = [[
(call
function: (attribute
object: (identifier) @module
attribute: (identifier) @function)
(#eq? @module "os")
(#eq? @function "path"))
]]
local parsed_query = query.parse('python', python_query)Running Queries
local function find_os_path_calls()
local bufnr = vim.api.nvim_get_current_buf()
local parser = vim.treesitter.get_parser(bufnr, 'python')
local tree = parser:parse()[1]
local root = tree:root()
local query_str = [[
(call
function: (attribute
object: (identifier) @obj
attribute: (identifier) @func))
]]
local query = vim.treesitter.query.parse('python', query_str)
for id, node in query:iter_captures(root, bufnr) do
local name = query.captures[id]
local text = vim.treesitter.get_node_text(node, bufnr)
print(name, text)
end
endTree-sitter Playground
Installation and Setup
{
'nvim-treesitter/playground',
dependencies = { 'nvim-treesitter/nvim-treesitter' },
config = function()
require('nvim-treesitter.configs').setup({
playground = {
enable = true,
updatetime = 25,
persist_queries = false,
keybindings = {
toggle_query_editor = 'o',
toggle_hl_groups = 'i',
toggle_injected_languages = 't',
toggle_anonymous_nodes = 'a',
toggle_language_display = 'I',
focus_language = 'f',
unfocus_language = 'F',
update = 'R',
goto_node = '<cr>',
show_help = '?',
},
},
})
end,
}Using Playground
" Open playground
:TSPlaygroundToggle
" Show syntax tree
:TSHighlightCapturesUnderCursor
" Edit queries
:EditQuery highlights
The playground shows:
Syntax tree of current buffer
Highlight captures under cursor
Query editor for testing patterns
Context-Aware Features
Show Current Context
{
'nvim-treesitter/nvim-treesitter-context',
config = function()
require('treesitter-context').setup({
enable = true,
max_lines = 3,
min_window_height = 20,
line_numbers = true,
multiline_threshold = 1,
trim_scope = 'outer',
mode = 'cursor',
separator = nil,
zindex = 20,
})
end,
}This shows function/class headers at the top when scrolling:
-- When scrolled down inside a function:
┌│────────────────────────────────┐
││ function calculate(x, y) │ ← Context line
├│────────────────────────────────┤
││ if x > 10 then │
││ return x * y │
││ end │ ← Current view
└────────────────────────────────┘Sticky Context Toggle
vim.keymap.set('n', '<leader>tc', function()
require('treesitter-context').toggle()
end, { desc = 'Toggle treesitter context' })Refactoring with Tree-sitter
{
'ThePrimeagen/refactoring.nvim',
dependencies = {
'nvim-lua/plenary.nvim',
'nvim-treesitter/nvim-treesitter',
},
config = function()
require('refactoring').setup({})
-- Extract function
vim.keymap.set('x', '<leader>re', function()
require('refactoring').refactor('Extract Function')
end, { desc = 'Extract function' })
-- Extract variable
vim.keymap.set('x', '<leader>rv', function()
require('refactoring').refactor('Extract Variable')
end, { desc = 'Extract variable' })
-- Inline variable
vim.keymap.set('n', '<leader>ri', function()
require('refactoring').refactor('Inline Variable')
end, { desc = 'Inline variable' })
end,
}Performance Optimization
Lazy Loading Parsers
require('nvim-treesitter.configs').setup({
highlight = {
enable = true,
disable = function(lang, buf)
-- Disable for very large files
local max_filesize = 200 * 1024 -- 200 KB
local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(buf))
if ok and stats and stats.size > max_filesize then
vim.notify(
'Tree-sitter disabled: file too large',
vim.log.levels.WARN
)
return true
end
-- Disable for specific patterns
local disabled_patterns = { '%.min%.js$', '%.min%.css$' }
local filename = vim.api.nvim_buf_get_name(buf)
for _, pattern in ipairs(disabled_patterns) do
if filename:match(pattern) then
return true
end
end
end,
},
})Async Parsing
-- Configure parser install behavior
require('nvim-treesitter.configs').setup({
sync_install = false, -- Install parsers asynchronously
auto_install = true, -- Auto-install missing parsers
})Language-Specific Configurations
Injected Languages
require('nvim-treesitter.configs').setup({
highlight = {
enable = true,
},
-- Enable for markdown code blocks, Vue templates, etc.
ensure_installed = {
'markdown',
'markdown_inline',
'html',
'javascript',
'typescript',
'vue',
},
})Custom Language Parsers
-- Register a custom parser
local parser_config = require('nvim-treesitter.parsers').get_parser_configs()
parser_config.mylang = {
install_info = {
url = '~/projects/tree-sitter-mylang',
files = { 'src/parser.c' },
branch = 'main',
},
filetype = 'mylang',
}
-- Then install
-- :TSInstall mylangDebugging Tree-sitter
Inspect Syntax Tree
function _G.inspect_tree()
local bufnr = vim.api.nvim_get_current_buf()
local parser = vim.treesitter.get_parser(bufnr)
local tree = parser:parse()[1]
local root = tree:root()
print('Root node type:', root:type())
print('Start:', root:start())
print('End:', root:end_())
print('Text:', vim.treesitter.get_node_text(root, bufnr))
-- Walk the tree
local function walk(node, level)
level = level or 0
local indent = string.rep(' ', level)
print(indent .. node:type())
for child in node:iter_children() do
walk(child, level + 1)
end
end
walk(root)
end
vim.keymap.set('n', '<leader>ti', _G.inspect_tree, { desc = 'Inspect syntax tree' })Get Node Under Cursor
function _G.get_node_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1] - 1, cursor[2]
local parser = vim.treesitter.get_parser(bufnr)
local tree = parser:parse()[1]
local root = tree:root()
local node = root:descendant_for_range(row, col, row, col)
if node then
print('Node type:', node:type())
print('Text:', vim.treesitter.get_node_text(node, bufnr))
print('Range:', node:range())
end
end
vim.keymap.set('n', '<leader>tn', _G.get_node_at_cursor, { desc = 'Get node at cursor' })Check Parser Status
function _G.check_parser()
local bufnr = vim.api.nvim_get_current_buf()
local ft = vim.bo[bufnr].filetype
local parser = vim.treesitter.get_parser(bufnr, ft)
if parser then
print('Parser found for:', ft)
print('Language:', parser:lang())
local has_parser = pcall(vim.treesitter.get_query, ft, 'highlights')
print('Has highlight queries:', has_parser)
else
print('No parser for:', ft)
end
endIntegration with LSP
Combined Highlighting
-- Use both Tree-sitter and LSP semantic tokens
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client.server_capabilities.semanticTokensProvider then
-- Enable semantic highlighting
vim.lsp.semantic_tokens.start(args.buf, client.id)
end
end,
})
-- Ensure Tree-sitter highlights have higher priority
vim.highlight.priorities.semantic_tokens = 95
vim.highlight.priorities.treesitter = 100Use Tree-sitter for LSP Features
-- Example: Use Tree-sitter for better code action context
local function get_function_node()
local node = vim.treesitter.get_node()
while node do
if node:type() == 'function_definition' or
node:type() == 'function_declaration' then
return node
end
node = node:parent()
end
endBest Practices
1. Selective Enabling
-- Disable for problematic file types
require('nvim-treesitter.configs').setup({
highlight = {
enable = true,
disable = { 'latex', 'vim' }, -- Use regex for these
},
})2. Performance Monitoring
-- Check parsing time
function _G.benchmark_parsing()
local start = vim.loop.hrtime()
local parser = vim.treesitter.get_parser()
parser:parse()
local duration = (vim.loop.hrtime() - start) / 1e6
print(string.format('Parsing took %.2f ms', duration))
end3. Fallback to Regex
-- Use additional regex highlighting for edge cases
require('nvim-treesitter.configs').setup({
highlight = {
enable = true,
additional_vim_regex_highlighting = { 'markdown' },
},
})4. Query Validation
-- Validate custom queries
function _G.validate_query(lang, query_type)
local ok, query = pcall(vim.treesitter.query.get, lang, query_type)
if ok then
print('Query valid for', lang, query_type)
else
print('Query error:', query)
end
endSummary: Chapter #28 – Tree-sitter in Neovim
This chapter covered:
Fundamentals – Understanding Tree-sitter’s incremental parsing and AST generation
Setup – Installing parsers and configuring
nvim-treesitterSyntax Highlighting – Accurate, context-aware highlighting using queries
Text Objects – Powerful text manipulation based on syntax understanding
Incremental Selection – Expanding/shrinking selections along the syntax tree
Indentation & Folding – Tree-sitter-powered code structure features
Advanced Queries – Writing and testing custom Tree-sitter queries
Debugging Tools – Playground, inspection functions, and parser diagnostics
Integration – Combining Tree-sitter with LSP for enhanced editing
Performance – Optimization strategies and selective enabling
The next chapter will explore Chapter #29: Testing and Debugging Neovim Configuration, covering techniques to validate your configuration, debug issues, and profile performance.
k(n) - Change the access position for an open channel self
Chapter #30: Python Integration
Neovim has excellent Python integration through its built-in RPC
API and the pynvim package. This allows you to write
plugins, create custom commands, and extend Neovim’s functionality
using Python.
Prerequisites and Setup
Installing pynvim
# Using pip
pip install pynvim
# Using pip3 (recommended)
pip3 install pynvim
# In a virtual environment
python3 -m venv ~/.nvim-venv
source ~/.nvim-venv/bin/activate
pip install pynvim
# System-wide with user installation
pip3 install --user pynvimConfiguring Python Provider
-- In your init.lua
-- Point to Python 3 executable
vim.g.python3_host_prog = '/usr/bin/python3'
-- Or use virtual environment
vim.g.python3_host_prog = vim.fn.expand('~/.nvim-venv/bin/python')
-- Disable Python 2 provider (deprecated)
vim.g.loaded_python_provider = 0
-- Check Python provider health
-- :checkhealth providerVerify Installation
" Check Python 3 support
:echo has('python3')
" Should return 1
" Check pynvim version
:py3 import pynvim; print(pynvim.__version__)
" Provider info
:checkhealth provider
Python3 Commands in Neovim
Executing Python Code
" Execute single Python statement
:py3 print("Hello from Neovim")
" Execute multiple lines
:py3 << EOF
import vim
current_buffer = vim.current.buffer
print(f"Current buffer has {len(current_buffer)} lines")
EOF
" Execute Python file
:py3file ~/scripts/my_script.py
Lua Integration
-- Execute Python from Lua
vim.cmd([[
py3 << EOF
import vim
vim.command('echo "Python via Lua"')
EOF
]])
-- Create command that runs Python
vim.api.nvim_create_user_command('PyHello', function()
vim.cmd('py3 print("Hello from Python command")')
end, {})Using the pynvim API
Basic API Structure
# my_plugin.py
import pynvim
@pynvim.plugin
class MyPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('HelloPython')
def hello_command(self):
self.nvim.out_write("Hello from Python plugin!\n")
@pynvim.function('PyAdd')
def add_function(self, args):
return args[0] + args[1]
@pynvim.autocmd('BufEnter', pattern='*.py')
def on_python_file(self):
self.nvim.out_write("Entered a Python file!\n")Buffer Operations
import pynvim
@pynvim.plugin
class BufferPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('LineCount')
def count_lines(self):
buffer = self.nvim.current.buffer
count = len(buffer)
self.nvim.out_write(f"Buffer has {count} lines\n")
@pynvim.command('InsertDate')
def insert_date(self):
from datetime import datetime
date_str = datetime.now().strftime('%Y-%m-%d')
# Get current position
row, col = self.nvim.current.window.cursor
# Insert at cursor
line = self.nvim.current.line
new_line = line[:col] + date_str + line[col:]
self.nvim.current.line = new_line
@pynvim.command('AppendLine', nargs='1')
def append_line(self, args):
text = args[0]
buffer = self.nvim.current.buffer
buffer.append(text)
@pynvim.command('ReplaceAll', nargs='2')
def replace_all(self, args):
old, new = args
buffer = self.nvim.current.buffer
# Modify all lines
for i, line in enumerate(buffer):
buffer[i] = line.replace(old, new)
@pynvim.command('GetSelection', range='')
def get_selection(self, range_info):
buffer = self.nvim.current.buffer
start, end = range_info
# Get lines in range (1-indexed to 0-indexed)
lines = buffer[start-1:end]
self.nvim.out_write('\n'.join(lines) + '\n')Window and Tab Operations
@pynvim.plugin
class WindowPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('SplitAndEdit', nargs='1')
def split_and_edit(self, args):
filename = args[0]
# Split window
self.nvim.command('split')
# Edit file in new window
self.nvim.command(f'edit {filename}')
@pynvim.command('WindowInfo')
def window_info(self):
win = self.nvim.current.window
info = [
f"Window number: {win.number}",
f"Height: {win.height}",
f"Width: {win.width}",
f"Cursor: {win.cursor}",
]
self.nvim.out_write('\n'.join(info) + '\n')
@pynvim.command('ResizeWindow', nargs='2')
def resize_window(self, args):
width, height = map(int, args)
win = self.nvim.current.window
win.width = width
win.height = height
@pynvim.command('TabInfo')
def tab_info(self):
tabpage = self.nvim.current.tabpage
windows = tabpage.windows
info = [
f"Tab number: {tabpage.number}",
f"Number of windows: {len(windows)}",
]
for i, win in enumerate(windows, 1):
info.append(f" Window {i}: {win.buffer.name}")
self.nvim.out_write('\n'.join(info) + '\n')Variable Operations
@pynvim.plugin
class VarPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('SetVar', nargs='2')
def set_variable(self, args):
var_name, value = args
# Set global variable
self.nvim.vars[var_name] = value
self.nvim.out_write(f"Set g:{var_name} = {value}\n")
@pynvim.command('GetVar', nargs='1')
def get_variable(self, args):
var_name = args[0]
try:
value = self.nvim.vars[var_name]
self.nvim.out_write(f"g:{var_name} = {value}\n")
except KeyError:
self.nvim.err_write(f"Variable g:{var_name} not found\n")
@pynvim.command('BufferVars')
def list_buffer_vars(self):
buf_vars = self.nvim.current.buffer.vars
if not buf_vars:
self.nvim.out_write("No buffer variables set\n")
return
for key, value in buf_vars.items():
self.nvim.out_write(f"b:{key} = {value}\n")
@pynvim.function('PyEval')
def eval_expression(self, args):
"""Evaluate Python expression and return result"""
expr = args[0]
try:
result = eval(expr)
return result
except Exception as e:
return f"Error: {str(e)}"Remote Plugin Development
Creating a Remote Plugin
# rplugin/python3/my_plugin.py
import pynvim
import os
@pynvim.plugin
class MyRemotePlugin:
def __init__(self, nvim):
self.nvim = nvim
self.cache = {}
@pynvim.command('PluginStatus')
def status(self):
"""Show plugin status"""
info = [
"Plugin Status:",
f"Cache size: {len(self.cache)}",
f"Neovim version: {self.nvim.api.get_vvar('version')}",
]
self.nvim.out_write('\n'.join(info) + '\n')
@pynvim.command('CacheSet', nargs='2')
def cache_set(self, args):
"""Set cache value"""
key, value = args
self.cache[key] = value
self.nvim.out_write(f"Cached: {key} = {value}\n")
@pynvim.command('CacheGet', nargs='1')
def cache_get(self, args):
"""Get cached value"""
key = args[0]
value = self.cache.get(key, 'Not found')
self.nvim.out_write(f"{key} = {value}\n")
@pynvim.function('_my_plugin_complete')
def complete_function(self, args):
"""Custom completion function"""
# args: [arglead, cmdline, cursorpos]
arglead = args[0]
# Return matching cache keys
matches = [k for k in self.cache.keys() if k.startswith(arglead)]
return matchesRegistering the Plugin
-- After creating the Python file, update remote plugins:
-- :UpdateRemotePlugins
-- Then restart Neovim
-- Check if plugin is loaded
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
vim.cmd('PluginStatus')
end,
})Asynchronous Operations
Using Async/Await
import pynvim
import asyncio
from typing import List
@pynvim.plugin
class AsyncPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('AsyncFetch', nargs='1')
def fetch_url(self, args):
"""Fetch URL asynchronously"""
url = args[0]
# Run async operation
asyncio.run(self._fetch_and_display(url))
async def _fetch_and_display(self, url):
"""Internal async method"""
try:
# Simulate async operation
self.nvim.out_write(f"Fetching {url}...\n")
# Use aiohttp or requests
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
text = await response.text()
# Create new buffer with results
self.nvim.command('new')
buffer = self.nvim.current.buffer
buffer[:] = text.split('\n')[:50] # First 50 lines
except Exception as e:
self.nvim.err_write(f"Error: {str(e)}\n")
@pynvim.command('AsyncProcess', nargs='+')
def run_process(self, args):
"""Run external process asynchronously"""
cmd = ' '.join(args)
asyncio.run(self._run_process(cmd))
async def _run_process(self, cmd):
"""Run process and capture output"""
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if stdout:
lines = stdout.decode().split('\n')
self.nvim.command('new')
self.nvim.current.buffer[:] = lines
if stderr:
self.nvim.err_write(stderr.decode())Background Tasks
import pynvim
import threading
import time
@pynvim.plugin
class BackgroundPlugin:
def __init__(self, nvim):
self.nvim = nvim
self.running = False
self.thread = None
@pynvim.command('StartMonitor')
def start_monitor(self):
"""Start background monitoring"""
if self.running:
self.nvim.out_write("Monitor already running\n")
return
self.running = True
self.thread = threading.Thread(target=self._monitor, daemon=True)
self.thread.start()
self.nvim.out_write("Monitor started\n")
@pynvim.command('StopMonitor')
def stop_monitor(self):
"""Stop background monitoring"""
self.running = False
self.nvim.out_write("Monitor stopped\n")
def _monitor(self):
"""Background monitoring task"""
while self.running:
try:
# Check something (e.g., file changes)
buffer = self.nvim.current.buffer
# Safe operation using nvim.async_call
self.nvim.async_call(
lambda: self.nvim.command(
f'echo "Monitoring... {time.time()}"'
)
)
time.sleep(5) # Check every 5 seconds
except Exception as e:
self.nvim.async_call(
lambda: self.nvim.err_write(f"Monitor error: {e}\n")
)
breakAdvanced Plugin Features
File Type Detection and Handling
import pynvim
import re
@pynvim.plugin
class FileTypePlugin:
def __init__(self, nvim):
self.nvim = nvim
self.formatters = {
'python': self._format_python,
'json': self._format_json,
'xml': self._format_xml,
}
@pynvim.autocmd('BufWritePre', pattern='*.py')
def format_on_save_python(self):
"""Auto-format Python files on save"""
self._format_python()
@pynvim.command('FormatCurrent')
def format_current_file(self):
"""Format current file based on filetype"""
ft = self.nvim.eval('&filetype')
formatter = self.formatters.get(ft)
if formatter:
formatter()
self.nvim.out_write(f"Formatted {ft} file\n")
else:
self.nvim.err_write(f"No formatter for {ft}\n")
def _format_python(self):
"""Format Python code using black"""
import subprocess
buffer = self.nvim.current.buffer
code = '\n'.join(buffer[:])
try:
result = subprocess.run(
['black', '-q', '-'],
input=code.encode(),
capture_output=True,
timeout=5
)
if result.returncode == 0:
formatted = result.stdout.decode().split('\n')
buffer[:] = formatted
except Exception as e:
self.nvim.err_write(f"Format error: {e}\n")
def _format_json(self):
"""Format JSON"""
import json
buffer = self.nvim.current.buffer
content = '\n'.join(buffer[:])
try:
data = json.loads(content)
formatted = json.dumps(data, indent=2)
buffer[:] = formatted.split('\n')
except json.JSONDecodeError as e:
self.nvim.err_write(f"JSON error: {e}\n")
def _format_xml(self):
"""Format XML"""
import xml.dom.minidom
buffer = self.nvim.current.buffer
content = '\n'.join(buffer[:])
try:
dom = xml.dom.minidom.parseString(content)
formatted = dom.toprettyxml(indent=' ')
# Remove extra blank lines
lines = [l for l in formatted.split('\n') if l.strip()]
buffer[:] = lines
except Exception as e:
self.nvim.err_write(f"XML error: {e}\n")Completion Provider
import pynvim
import os
@pynvim.plugin
class CompletionPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.function('_python_path_complete', sync=True)
def path_complete(self, args):
"""Custom path completion"""
# args: [findstart, base]
findstart, base = args
if findstart:
# Find start of completion
line = self.nvim.current.line
col = self.nvim.current.window.cursor[1]
# Find path start
start = col
while start > 0 and line[start-1] not in (' ', '\t', '"', "'"):
start -= 1
return start
else:
# Return completion matches
return self._get_path_matches(base)
def _get_path_matches(self, base):
"""Get matching file paths"""
if not base:
base = '.'
directory = os.path.dirname(base) or '.'
prefix = os.path.basename(base)
try:
entries = os.listdir(directory)
matches = []
for entry in entries:
if entry.startswith(prefix):
full_path = os.path.join(directory, entry)
# Add trailing slash for directories
if os.path.isdir(full_path):
entry += '/'
matches.append({
'word': entry,
'menu': '[Path]',
'kind': 'd' if os.path.isdir(full_path) else 'f',
})
return matches
except Exception:
return []
@pynvim.function('_python_snippet_complete', sync=True)
def snippet_complete(self, args):
"""Snippet completion"""
findstart, base = args
if findstart:
line = self.nvim.current.line
col = self.nvim.current.window.cursor[1]
start = col
while start > 0 and line[start-1].isalnum():
start -= 1
return start
else:
snippets = {
'def': 'def ${1:name}(${2:args}):\n ${3:pass}',
'class': 'class ${1:Name}:\n def __init__(self):\n ${2:pass}',
'for': 'for ${1:item} in ${2:items}:\n ${3:pass}',
'if': 'if ${1:condition}:\n ${2:pass}',
}
matches = []
for key, snippet in snippets.items():
if key.startswith(base):
matches.append({
'word': key,
'menu': '[Snippet]',
'info': snippet,
})
return matchesDiagnostic and Linting Integration
import pynvim
import subprocess
import json
@pynvim.plugin
class LintPlugin:
def __init__(self, nvim):
self.nvim = nvim
self.diagnostics = {}
@pynvim.command('LintCurrent')
def lint_current(self):
"""Lint current Python file"""
buffer = self.nvim.current.buffer
filename = buffer.name
if not filename.endswith('.py'):
self.nvim.err_write("Not a Python file\n")
return
# Run pylint
try:
result = subprocess.run(
['pylint', '--output-format=json', filename],
capture_output=True,
text=True
)
diagnostics = json.loads(result.stdout)
self._display_diagnostics(diagnostics)
except Exception as e:
self.nvim.err_write(f"Lint error: {e}\n")
def _display_diagnostics(self, diagnostics):
"""Display diagnostics in quickfix"""
qf_list = []
for diag in diagnostics:
qf_list.append({
'filename': diag['path'],
'lnum': diag['line'],
'col': diag['column'],
'text': diag['message'],
'type': diag['type'][0].upper(), # E, W, etc.
})
self.nvim.call('setqflist', qf_list)
self.nvim.command('copen')
if qf_list:
self.nvim.out_write(f"Found {len(qf_list)} issues\n")
else:
self.nvim.out_write("No issues found\n")
@pynvim.autocmd('BufWritePost', pattern='*.py')
def lint_on_save(self):
"""Auto-lint on save"""
# Could call self.lint_current() here
passIntegration with External Tools
Git Integration
import pynvim
import subprocess
import os
@pynvim.plugin
class GitPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('GitStatus')
def git_status(self):
"""Show git status"""
try:
result = subprocess.run(
['git', 'status', '--short'],
capture_output=True,
text=True,
cwd=self._get_git_root()
)
if result.returncode == 0:
# Open new buffer with status
self.nvim.command('new')
buffer = self.nvim.current.buffer
buffer.name = '[Git Status]'
buffer[:] = result.stdout.split('\n')
buffer.options['modifiable'] = False
else:
self.nvim.err_write(result.stderr)
except Exception as e:
self.nvim.err_write(f"Git error: {e}\n")
@pynvim.command('GitBlame')
def git_blame(self):
"""Show git blame for current file"""
filename = self.nvim.current.buffer.name
line_num = self.nvim.current.window.cursor[0]
try:
result = subprocess.run(
['git', 'blame', '-L', f'{line_num},{line_num}', filename],
capture_output=True,
text=True
)
if result.returncode == 0:
self.nvim.out_write(result.stdout)
else:
self.nvim.err_write(result.stderr)
except Exception as e:
self.nvim.err_write(f"Blame error: {e}\n")
@pynvim.command('GitDiff')
def git_diff(self):
"""Show git diff for current file"""
filename = self.nvim.current.buffer.name
try:
result = subprocess.run(
['git', 'diff', filename],
capture_output=True,
text=True
)
if result.returncode == 0:
# Open diff in new buffer
self.nvim.command('new')
buffer = self.nvim.current.buffer
buffer.name = f'[Git Diff] {os.path.basename(filename)}'
buffer[:] = result.stdout.split('\n')
buffer.options['filetype'] = 'diff'
buffer.options['modifiable'] = False
except Exception as e:
self.nvim.err_write(f"Diff error: {e}\n")
def _get_git_root(self):
"""Get git repository root"""
try:
result = subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
capture_output=True,
text=True,
cwd=os.path.dirname(self.nvim.current.buffer.name)
)
return result.stdout.strip()
except Exception:
return os.getcwd()Database Integration
import pynvim
import sqlite3
import json
@pynvim.plugin
class DatabasePlugin:
def __init__(self, nvim):
self.nvim = nvim
self.connection = None
@pynvim.command('DBConnect', nargs='1')
def connect_db(self, args):
"""Connect to SQLite database"""
db_path = args[0]
try:
self.connection = sqlite3.connect(db_path)
self.connection.row_factory = sqlite3.Row
self.nvim.out_write(f"Connected to {db_path}\n")
except Exception as e:
self.nvim.err_write(f"Connection error: {e}\n")
@pynvim.command('DBQuery', nargs='+')
def execute_query(self, args):
"""Execute SQL query"""
if not self.connection:
self.nvim.err_write("Not connected to database\n")
return
query = ' '.join(args)
try:
cursor = self.connection.cursor()
cursor.execute(query)
# Fetch results
rows = cursor.fetchall()
if rows:
# Format as table
columns = [desc[0] for desc in cursor.description]
self._display_table(columns, rows)
else:
self.nvim.out_write("Query executed successfully\n")
self.connection.commit()
except Exception as e:
self.nvim.err_write(f"Query error: {e}\n")
@pynvim.command('DBTables')
def list_tables(self):
"""List database tables"""
if not self.connection:
self.nvim.err_write("Not connected to database\n")
return
try:
cursor = self.connection.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [row[0] for row in cursor.fetchall()]
self.nvim.out_write("Tables:\n")
for table in tables:
self.nvim.out_write(f" - {table}\n")
except Exception as e:
self.nvim.err_write(f"Error: {e}\n")
def _display_table(self, columns, rows):
"""Display results as formatted table"""
# Create new buffer
self.nvim.command('new')
buffer = self.nvim.current.buffer
buffer.name = '[Query Results]'
# Format output
lines = []
# Header
header = ' | '.join(columns)
lines.append(header)
lines.append('-' * len(header))
# Rows
for row in rows:
row_data = [str(row[col]) for col in columns]
lines.append(' | '.join(row_data))
buffer[:] = lines
buffer.options['modifiable'] = FalseTesting Python Plugins
Unit Testing
# tests/test_my_plugin.py
import pytest
import pynvim
class TestMyPlugin:
@pytest.fixture
def nvim(self):
"""Create test Neovim instance"""
child_argv = ['nvim', '--embed', '--headless']
nvim = pynvim.attach('child', argv=child_argv)
yield nvim
nvim.close()
def test_buffer_operations(self, nvim):
"""Test buffer operations"""
# Create buffer
buffer = nvim.current.buffer
# Set content
buffer[:] = ['line 1', 'line 2']
# Verify
assert len(buffer) == 2
assert buffer[0] == 'line 1'
def test_command_execution(self, nvim):
"""Test command execution"""
# Load plugin
nvim.command('runtime! plugin/**/*.vim')
nvim.command('UpdateRemotePlugins')
# Test command
nvim.command('MyCommand')
# Verify results
# ...Integration Testing
# tests/integration/test_plugin_integration.py
import pynvim
import os
import tempfile
def test_file_operations():
"""Test file-based operations"""
nvim = pynvim.attach('child', argv=['nvim', '--embed', '--headless'])
try:
# Create temp file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f:
f.write('def test():\n pass\n')
temp_path = f.name
# Open in Neovim
nvim.command(f'edit {temp_path}')
# Test plugin functionality
buffer = nvim.current.buffer
assert len(buffer) == 2
# Cleanup
os.unlink(temp_path)
finally:
nvim.close()Performance Optimization
Caching
import pynvim
from functools import lru_cache
import time
@pynvim.plugin
class CachedPlugin:
def __init__(self, nvim):
self.nvim = nvim
self._cache = {}
self._cache_timeout = 60 # seconds
@pynvim.function('ExpensiveOperation')
def expensive_op(self, args):
"""Cached expensive operation"""
key = str(args)
# Check cache
if key in self._cache:
cached_value, timestamp = self._cache[key]
if time.time() - timestamp < self._cache_timeout:
return cached_value
# Compute
result = self._compute_expensive(args)
# Cache result
self._cache[key] = (result, time.time())
return result
@lru_cache(maxsize=128)
def _compute_expensive(self, args):
"""Expensive computation"""
# Simulate expensive operation
time.sleep(0.1)
return sum(args)
@pynvim.command('ClearCache')
def clear_cache(self):
"""Clear plugin cache"""
self._cache.clear()
self._compute_expensive.cache_clear()
self.nvim.out_write("Cache cleared\n")Batching Operations
@pynvim.plugin
class BatchPlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('BatchUpdate', range='')
def batch_update(self, range_info):
"""Update multiple lines efficiently"""
start, end = range_info
buffer = self.nvim.current.buffer
# Batch operation instead of line-by-line
lines = buffer[start-1:end]
# Process all lines
processed = [self._process_line(line) for line in lines]
# Single update
buffer[start-1:end] = processed
def _process_line(self, line):
"""Process single line"""
return line.upper()Best Practices
1. Error Handling
@pynvim.plugin
class SafePlugin:
def __init__(self, nvim):
self.nvim = nvim
@pynvim.command('SafeCommand')
def safe_command(self):
"""Command with proper error handling"""
try:
# Risky operation
result = self._risky_operation()
self.nvim.out_write(f"Success: {result}\n")
except FileNotFoundError as e:
self.nvim.err_write(f"File not found: {e}\n")
except PermissionError as e:
self.nvim.err_write(f"Permission denied: {e}\n")
except Exception as e:
# Log unexpected errors
self._log_error(e)
self.nvim.err_write(f"Unexpected error: {e}\n")
def _log_error(self, error):
"""Log error to file"""
import traceback
log_path = os.path.expanduser('~/.nvim-plugin-errors.log')
with open(log_path, 'a') as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(traceback.format_exc())
f.write("\n\n")2. Configuration Management
@pynvim.plugin
class ConfigurablePlugin:
def __init__(self, nvim):
self.nvim = nvim
self.config = self._load_config()
def _load_config(self):
"""Load plugin configuration"""
return {
'enabled': self.nvim.vars.get('plugin_enabled', True),
'timeout': self.nvim.vars.get('plugin_timeout', 30),
'cache_dir': self.nvim.vars.get(
'plugin_cache_dir',
os.path.expanduser('~/.cache/nvim-plugin')
),
}
@pynvim.command('PluginConfig')
def show_config(self):
"""Show current configuration"""
import json
config_str = json.dumps(self.config, indent=2)
self.nvim.out_write(config_str + '\n')
@pynvim.command('PluginSetConfig', nargs='+')
def set_config(self, args):
"""Set configuration value"""
if len(args) < 2:
self.nvim.err_write("Usage: PluginSetConfig <key> <value>\n")
return
key, value = args[0], ' '.join(args[1:])
# Try to parse as JSON
try:
import json
value = json.loads(value)
except:
pass # Keep as string
self.config[key] = value
self.nvim.out_write(f"Set {key} = {value}\n")3. Documentation
"""
My Neovim Plugin
================
A Python plugin for Neovim that provides...
Requirements:
- pynvim >= 0.4.0
- Python >= 3.6
Installation:
1. Install pynvim: pip install pynvim
2. Copy to rplugin/python3/
3. Run :UpdateRemotePlugins
Commands:
:MyCommand - Does something useful
:MyStatus - Shows plugin status
Functions:
MyFunc(args) - Callable function
Configuration:
let g:my_plugin_enabled = 1
let g:my_plugin_timeout = 30
License: MIT
"""
import pynvim
@pynvim.plugin
class MyPlugin:
"""Main plugin class"""
def __init__(self, nvim):
"""
Initialize plugin
Args:
nvim: Neovim instance
"""
self.nvim = nvim
@pynvim.command('MyCommand', nargs='*')
def my_command(self, args):
"""
Execute my command
Args:
args: Command arguments
Example:
:MyCommand arg1 arg2
"""
passSummary: Chapter #30 – Python Integration
This chapter covered:
Setup – Installing and configuring the Python provider
Basic API – Executing Python code and using pynvim decorators
Buffer/Window Operations – Manipulating Neovim buffers, windows, and tabs
Remote Plugins – Creating full-featured Python plugins
Async Operations – Background tasks and asynchronous programming
Advanced Features – File type handling, completion, linting, and external tool integration
Testing – Unit and integration testing strategies
Performance – Caching and batching optimizations
Best Practices – Error handling, configuration, and documentation
The next chapter will explore Chapter #31: Lua Plugin Development, covering how to create performant plugins using Neovim’s built-in Lua runtime.
Chapter #31: Ruby Integration
Neovim provides Ruby integration through its RPC API and the
neovim Ruby gem. While less commonly used than Python
or Lua, Ruby integration offers powerful capabilities for those
familiar with the language.
Prerequisites and Setup
Installing the Ruby Provider
# Install Ruby (if not already installed)
# On Ubuntu/Debian
sudo apt-get install ruby ruby-dev
# On macOS
brew install ruby
# Install neovim gem
gem install neovim
# Or using bundler
bundle add neovim
# For system-wide installation
sudo gem install neovimConfiguring the Ruby Provider
-- In your init.lua
-- Point to Ruby executable
vim.g.ruby_host_prog = '/usr/bin/ruby'
-- Or specify gem binary path
vim.g.ruby_host_prog = vim.fn.expand('~/.gem/ruby/3.0.0/bin/neovim-ruby-host')
-- Check Ruby provider health
-- :checkhealth providerVerify Installation
" Check Ruby support
:echo has('ruby')
" Should return 1
" Check provider
:checkhealth provider
" Test Ruby execution
:ruby puts "Hello from Ruby"
Ruby Commands in Neovim
Executing Ruby Code
" Execute single Ruby statement
:ruby puts "Hello from Neovim"
" Execute multiple lines
:ruby << EOF
require 'neovim'
buffer = Neovim.current.buffer
puts "Current buffer has #{buffer.count} lines"
EOF
" Execute Ruby file
:rubyfile ~/scripts/my_script.rb
Lua Integration
-- Execute Ruby from Lua
vim.cmd([[
ruby << EOF
puts "Ruby executed from Lua"
EOF
]])
-- Create command that runs Ruby
vim.api.nvim_create_user_command('RubyHello', function()
vim.cmd('ruby puts "Hello from Ruby command"')
end, {})Using the Neovim Ruby API
Basic Plugin Structure
# my_plugin.rb
require 'neovim'
Neovim.plugin do |plug|
# Define a command
plug.command(:HelloRuby) do |nvim|
nvim.out_write("Hello from Ruby plugin!\n")
end
# Define a function
plug.function(:RubyAdd, sync: true) do |nvim, a, b|
a + b
end
# Define an autocmd
plug.autocmd(:BufEnter, pattern: '*.rb') do |nvim|
nvim.out_write("Entered a Ruby file!\n")
end
endBuffer Operations
require 'neovim'
Neovim.plugin do |plug|
plug.command(:LineCount) do |nvim|
buffer = nvim.current.buffer
count = buffer.count
nvim.out_write("Buffer has #{count} lines\n")
end
plug.command(:InsertDate) do |nvim|
require 'date'
date_str = Date.today.to_s
# Get current position
row, col = nvim.current.window.cursor
# Get current line
line = nvim.current.line
# Insert at cursor
new_line = line[0...col] + date_str + line[col..-1]
nvim.current.line = new_line
end
plug.command(:AppendLine, nargs: 1) do |nvim, text|
buffer = nvim.current.buffer
buffer.append(buffer.count, text)
end
plug.command(:ReplaceAll, nargs: 2) do |nvim, old_text, new_text|
buffer = nvim.current.buffer
# Modify all lines
buffer.count.times do |i|
line = buffer[i]
buffer[i] = line.gsub(old_text, new_text)
end
end
plug.command(:GetSelection, range: true) do |nvim, start_line, end_line|
buffer = nvim.current.buffer
# Get lines in range (1-indexed)
lines = buffer[start_line - 1...end_line]
nvim.out_write(lines.join("\n") + "\n")
end
plug.command(:ReverseLines, range: '%') do |nvim, start_line, end_line|
buffer = nvim.current.buffer
lines = buffer[start_line - 1...end_line]
# Reverse the lines
buffer[start_line - 1...end_line] = lines.reverse
end
endWindow and Tab Operations
Neovim.plugin do |plug|
plug.command(:SplitAndEdit, nargs: 1) do |nvim, filename|
# Split window
nvim.command('split')
# Edit file in new window
nvim.command("edit #{filename}")
end
plug.command(:WindowInfo) do |nvim|
win = nvim.current.window
info = [
"Window number: #{win.number}",
"Height: #{win.height}",
"Width: #{win.width}",
"Cursor: #{win.cursor.inspect}"
]
nvim.out_write(info.join("\n") + "\n")
end
plug.command(:ResizeWindow, nargs: 2) do |nvim, width, height|
win = nvim.current.window
win.width = width.to_i
win.height = height.to_i
end
plug.command(:TabInfo) do |nvim|
tabpage = nvim.current.tabpage
windows = tabpage.windows
info = [
"Tab number: #{tabpage.number}",
"Number of windows: #{windows.length}"
]
windows.each_with_index do |win, i|
info << " Window #{i + 1}: #{win.buffer.name}"
end
nvim.out_write(info.join("\n") + "\n")
end
endVariable Operations
Neovim.plugin do |plug|
plug.command(:SetVar, nargs: 2) do |nvim, var_name, value|
# Set global variable
nvim.set_var(var_name, value)
nvim.out_write("Set g:#{var_name} = #{value}\n")
end
plug.command(:GetVar, nargs: 1) do |nvim, var_name|
begin
value = nvim.get_var(var_name)
nvim.out_write("g:#{var_name} = #{value}\n")
rescue StandardError => e
nvim.err_write("Variable g:#{var_name} not found\n")
end
end
plug.command(:BufferVars) do |nvim|
buffer = nvim.current.buffer
# Note: Direct buffer.vars access may vary by API version
# This is a simplified example
nvim.out_write("Buffer variables:\n")
# List buffer-local variables through Vim evaluation
vars = nvim.evaluate('getbufvar(bufnr("%"), "")')
if vars.empty?
nvim.out_write("No buffer variables set\n")
else
vars.each do |key, value|
nvim.out_write("b:#{key} = #{value}\n")
end
end
end
plug.function(:RubyEval, sync: true) do |nvim, expression|
begin
eval(expression)
rescue StandardError => e
"Error: #{e.message}"
end
end
endRemote Plugin Development
Creating a Remote Plugin
# rplugin/ruby/my_plugin.rb
require 'neovim'
Neovim.plugin do |plug|
# Plugin state
@cache = {}
plug.command(:PluginStatus) do |nvim|
info = [
"Plugin Status:",
"Cache size: #{@cache.size}",
"Ruby version: #{RUBY_VERSION}"
]
nvim.out_write(info.join("\n") + "\n")
end
plug.command(:CacheSet, nargs: 2) do |nvim, key, value|
@cache[key] = value
nvim.out_write("Cached: #{key} = #{value}\n")
end
plug.command(:CacheGet, nargs: 1) do |nvim, key|
value = @cache[key] || 'Not found'
nvim.out_write("#{key} = #{value}\n")
end
plug.command(:CacheClear) do |nvim|
@cache.clear
nvim.out_write("Cache cleared\n")
end
plug.function(:_my_plugin_complete, sync: true) do |nvim, arglead, cmdline, cursorpos|
# Custom completion function
@cache.keys.select { |k| k.start_with?(arglead) }
end
endRegistering the Plugin
-- After creating the Ruby file, update remote plugins
-- :UpdateRemotePlugins
-- Then restart Neovim
-- Verify plugin loaded
vim.api.nvim_create_autocmd('VimEnter', {
callback = function()
vim.cmd('PluginStatus')
end,
})Working with Files and I/O
File Processing Plugin
Neovim.plugin do |plug|
plug.command(:ProcessCSV) do |nvim|
require 'csv'
buffer = nvim.current.buffer
content = buffer.to_a.join("\n")
begin
# Parse CSV
data = CSV.parse(content, headers: true)
# Process data (example: convert to markdown table)
lines = []
# Headers
lines << "| #{data.headers.join(' | ')} |"
lines << "|#{data.headers.map { '---' }.join('|')}|"
# Rows
data.each do |row|
lines << "| #{row.fields.join(' | ')} |"
end
# Create new buffer with results
nvim.command('new')
nvim.current.buffer.append(0, lines)
rescue StandardError => e
nvim.err_write("CSV processing error: #{e.message}\n")
end
end
plug.command(:LoadJSON, nargs: 1) do |nvim, filepath|
require 'json'
begin
content = File.read(File.expand_path(filepath))
data = JSON.parse(content)
# Pretty print to buffer
pretty = JSON.pretty_generate(data)
nvim.command('new')
buffer = nvim.current.buffer
buffer.append(0, pretty.split("\n"))
buffer.name = "[JSON] #{File.basename(filepath)}"
rescue StandardError => e
nvim.err_write("JSON load error: #{e.message}\n")
end
end
plug.command(:SaveJSON, nargs: 1) do |nvim, filepath|
require 'json'
buffer = nvim.current.buffer
content = buffer.to_a.join("\n")
begin
# Validate JSON
JSON.parse(content)
# Save to file
File.write(File.expand_path(filepath), content)
nvim.out_write("Saved to #{filepath}\n")
rescue JSON::ParserError => e
nvim.err_write("Invalid JSON: #{e.message}\n")
rescue StandardError => e
nvim.err_write("Save error: #{e.message}\n")
end
end
endAdvanced Features
Syntax Highlighting and Formatting
Neovim.plugin do |plug|
plug.command(:FormatRuby) do |nvim|
require 'tempfile'
buffer = nvim.current.buffer
code = buffer.to_a.join("\n")
# Create temp file
Tempfile.create(['nvim_ruby', '.rb']) do |f|
f.write(code)
f.flush
# Format using rubocop
system("rubocop -a #{f.path} 2>/dev/null")
# Read formatted content
formatted = File.read(f.path).split("\n")
# Update buffer
buffer.set_lines(0, -1, true, formatted)
end
nvim.out_write("Formatted Ruby code\n")
rescue StandardError => e
nvim.err_write("Format error: #{e.message}\n")
end
plug.command(:LintRuby) do |nvim|
buffer = nvim.current.buffer
filename = buffer.name
unless filename.end_with?('.rb')
nvim.err_write("Not a Ruby file\n")
return
end
# Run rubocop
output = `rubocop --format json #{filename} 2>&1`
begin
require 'json'
results = JSON.parse(output)
# Convert to quickfix format
qf_list = []
results['files'].each do |file|
file['offenses'].each do |offense|
qf_list << {
'filename' => file['path'],
'lnum' => offense['location']['line'],
'col' => offense['location']['column'],
'text' => offense['message'],
'type' => offense['severity'][0].upcase
}
end
end
# Set quickfix list
nvim.call('setqflist', qf_list)
nvim.command('copen')
if qf_list.empty?
nvim.out_write("No issues found\n")
else
nvim.out_write("Found #{qf_list.size} issues\n")
end
rescue StandardError => e
nvim.err_write("Lint error: #{e.message}\n")
end
end
endText Processing Utilities
Neovim.plugin do |plug|
plug.command(:SortLines, range: '%') do |nvim, start_line, end_line|
buffer = nvim.current.buffer
lines = buffer[start_line - 1...end_line]
sorted = lines.sort
buffer.set_lines(start_line - 1, end_line, true, sorted)
end
plug.command(:UniqLines, range: '%') do |nvim, start_line, end_line|
buffer = nvim.current.buffer
lines = buffer[start_line - 1...end_line]
unique = lines.uniq
buffer.set_lines(start_line - 1, end_line, true, unique)
end
plug.command(:NumberLines, range: '%') do |nvim, start_line, end_line|
buffer = nvim.current.buffer
lines = buffer[start_line - 1...end_line]
numbered = lines.each_with_index.map do |line, i|
"#{i + 1}. #{line}"
end
buffer.set_lines(start_line - 1, end_line, true, numbered)
end
plug.command(:AlignOn, nargs: 1, range: '%') do |nvim, char, start_line, end_line|
buffer = nvim.current.buffer
lines = buffer[start_line - 1...end_line]
# Find max position of alignment character
positions = lines.map { |line| line.index(char) || 0 }
max_pos = positions.max
# Align lines
aligned = lines.each_with_index.map do |line, i|
pos = positions[i]
if pos > 0
spaces = ' ' * (max_pos - pos)
line.sub(char, "#{spaces}#{char}")
else
line
end
end
buffer.set_lines(start_line - 1, end_line, true, aligned)
end
plug.command(:CountWords) do |nvim|
buffer = nvim.current.buffer
text = buffer.to_a.join(' ')
words = text.split(/\s+/).reject(&:empty?)
chars = text.length
info = [
"Words: #{words.length}",
"Characters: #{chars}",
"Lines: #{buffer.count}"
]
nvim.out_write(info.join("\n") + "\n")
end
endIntegration with External Tools
Git Integration
Neovim.plugin do |plug|
plug.command(:GitStatus) do |nvim|
output = `git status --short 2>&1`
if $?.success?
nvim.command('new')
buffer = nvim.current.buffer
buffer.name = '[Git Status]'
buffer.append(0, output.split("\n"))
nvim.command('setlocal nomodifiable buftype=nofile')
else
nvim.err_write("Git error: #{output}\n")
end
end
plug.command(:GitBlame) do |nvim|
filename = nvim.current.buffer.name
line_num = nvim.current.window.cursor[0]
output = `git blame -L #{line_num},#{line_num} #{filename} 2>&1`
if $?.success?
nvim.out_write(output)
else
nvim.err_write("Blame error: #{output}\n")
end
end
plug.command(:GitDiff) do |nvim|
filename = nvim.current.buffer.name
output = `git diff #{filename} 2>&1`
if $?.success?
nvim.command('new')
buffer = nvim.current.buffer
buffer.name = "[Git Diff] #{File.basename(filename)}"
buffer.append(0, output.split("\n"))
nvim.command('setlocal filetype=diff nomodifiable buftype=nofile')
else
nvim.err_write("Diff error: #{output}\n")
end
end
plug.command(:GitLog, nargs: '?') do |nvim, count = '10'|
output = `git log -n #{count} --oneline 2>&1`
if $?.success?
nvim.command('new')
buffer = nvim.current.buffer
buffer.name = '[Git Log]'
buffer.append(0, output.split("\n"))
nvim.command('setlocal nomodifiable buftype=nofile')
else
nvim.err_write("Log error: #{output}\n")
end
end
endHTTP Client
Neovim.plugin do |plug|
plug.command(:HTTPGet, nargs: 1) do |nvim, url|
require 'net/http'
require 'uri'
begin
uri = URI.parse(url)
response = Net::HTTP.get_response(uri)
# Display response
nvim.command('new')
buffer = nvim.current.buffer
buffer.name = "[HTTP] #{uri.host}"
lines = [
"Status: #{response.code} #{response.message}",
"Headers:",
*response.each_header.map { |k, v| " #{k}: #{v}" },
"",
"Body:",
*response.body.split("\n")
]
buffer.append(0, lines)
nvim.command('setlocal buftype=nofile')
rescue StandardError => e
nvim.err_write("HTTP error: #{e.message}\n")
end
end
plug.command(:HTTPPost, nargs: '+') do |nvim, url, *data|
require 'net/http'
require 'uri'
begin
uri = URI.parse(url)
post_data = data.join(' ')
response = Net::HTTP.post_form(uri, { data: post_data })
nvim.out_write("Response: #{response.code} #{response.message}\n")
nvim.out_write(response.body[0...500] + "\n")
rescue StandardError => e
nvim.err_write("HTTP error: #{e.message}\n")
end
end
endDatabase Operations
Neovim.plugin do |plug|
@db_connection = nil
plug.command(:DBConnect, nargs: 1) do |nvim, db_path|
require 'sqlite3'
begin
@db_connection = SQLite3::Database.new(File.expand_path(db_path))
@db_connection.results_as_hash = true
nvim.out_write("Connected to #{db_path}\n")
rescue StandardError => e
nvim.err_write("Connection error: #{e.message}\n")
end
end
plug.command(:DBQuery, nargs: '+') do |nvim, *query_parts|
unless @db_connection
nvim.err_write("Not connected to database\n")
return
end
query = query_parts.join(' ')
begin
results = @db_connection.execute(query)
if results.empty?
nvim.out_write("Query executed successfully\n")
else
# Format as table
nvim.command('new')
buffer = nvim.current.buffer
buffer.name = '[Query Results]'
# Headers
headers = results.first.keys
lines = [
headers.join(' | '),
headers.map { |h| '-' * h.length }.join('-+-')
]
# Data rows
results.each do |row|
lines << headers.map { |h| row[h].to_s }.join(' | ')
end
buffer.append(0, lines)
nvim.command('setlocal nomodifiable buftype=nofile')
end
rescue StandardError => e
nvim.err_write("Query error: #{e.message}\n")
end
end
plug.command(:DBTables) do |nvim|
unless @db_connection
nvim.err_write("Not connected to database\n")
return
end
begin
tables = @db_connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
nvim.out_write("Tables:\n")
tables.each do |row|
nvim.out_write(" - #{row['name']}\n")
end
rescue StandardError => e
nvim.err_write("Error: #{e.message}\n")
end
end
endAsynchronous Operations
Background Tasks
Neovim.plugin do |plug|
@background_thread = nil
@running = false
plug.command(:StartMonitor) do |nvim|
if @running
nvim.out_write("Monitor already running\n")
return
end
@running = true
@background_thread = Thread.new do
while @running
begin
# Perform background task
sleep 5
# Update Neovim (must be thread-safe)
time = Time.now.strftime('%H:%M:%S')
nvim.command("echo 'Monitor tick: #{time}'")
rescue StandardError => e
nvim.err_write("Monitor error: #{e.message}\n")
break
end
end
end
nvim.out_write("Monitor started\n")
end
plug.command(:StopMonitor) do |nvim|
@running = false
if @background_thread
@background_thread.join(2)
@background_thread = nil
end
nvim.out_write("Monitor stopped\n")
end
endEvent-Driven Processing
Neovim.plugin do |plug|
@event_queue = Queue.new
@processor = nil
plug.command(:StartEventProcessor) do |nvim|
return if @processor&.alive?
@processor = Thread.new do
loop do
event = @event_queue.pop
begin
# Process event
nvim.command("echo 'Processing: #{event}'")
# Simulate processing
sleep 1
rescue StandardError => e
nvim.err_write("Event processing error: #{e.message}\n")
end
end
end
nvim.out_write("Event processor started\n")
end
plug.command(:QueueEvent, nargs: 1) do |nvim, event|
@event_queue.push(event)
nvim.out_write("Queued: #{event}\n")
end
plug.command(:QueueStatus) do |nvim|
nvim.out_write("Queue size: #{@event_queue.size}\n")
nvim.out_write("Processor alive: #{@processor&.alive?}\n")
end
endTesting Ruby Plugins
Unit Testing with RSpec
# spec/my_plugin_spec.rb
require 'rspec'
require 'neovim'
RSpec.describe 'MyPlugin' do
let(:nvim) do
# Create embedded Neovim instance
argv = ['nvim', '--embed', '--headless']
Neovim.attach_child(argv)
end
after do
nvim.shutdown
end
it 'executes commands' do
nvim.command('echo "test"')
expect(nvim).to be_truthy
end
it 'manipulates buffers' do
buffer = nvim.current.buffer
buffer.append(0, 'test line')
expect(buffer.count).to be > 0
expect(buffer[0]).to eq('test line')
end
it 'sets variables' do
nvim.set_var('test_var', 'test_value')
expect(nvim.get_var('test_var')).to eq('test_value')
end
endIntegration Testing
# spec/integration/plugin_integration_spec.rb
require 'rspec'
require 'neovim'
require 'tempfile'
RSpec.describe 'Plugin Integration' do
let(:nvim) { Neovim.attach_child(['nvim', '--embed', '--headless']) }
after { nvim.shutdown }
it 'loads remote plugins' do
# Load plugin
nvim.command('runtime! plugin/**/*.vim')
nvim.command('UpdateRemotePlugins')
# Test plugin command exists
commands = nvim.api.get_commands({})
expect(commands).to have_key('MyCommand')
end
it 'processes files' do
Tempfile.create(['test', '.rb']) do |f|
f.write("def test\n puts 'hello'\nend")
f.flush
nvim.command("edit #{f.path}")
buffer = nvim.current.buffer
expect(buffer.count).to eq(3)
end
end
endPerformance Optimization
Memoization
Neovim.plugin do |plug|
@cache = {}
plug.function(:ExpensiveOperation, sync: true) do |nvim, *args|
cache_key = args.to_s
@cache[cache_key] ||= begin
# Expensive computation
sleep 0.1
args.sum
end
end
plug.command(:ClearMemoCache) do |nvim|
@cache.clear
nvim.out_write("Memoization cache cleared\n")
end
endBatch Processing
Neovim.plugin do |plug|
plug.command(:BatchProcess, range: '%') do |nvim, start_line, end_line|
buffer = nvim.current.buffer
# Get all lines at once
lines = buffer.get_lines(start_line - 1, end_line, true)
# Process in batch
processed = lines.map(&:upcase)
# Update all at once
buffer.set_lines(start_line - 1, end_line, true, processed)
end
endBest Practices
1. Error Handling
Neovim.plugin do |plug|
plug.command(:SafeCommand) do |nvim|
begin
# Risky operation
result = risky_operation
nvim.out_write("Success: #{result}\n")
rescue Errno::ENOENT => e
nvim.err_write("File not found: #{e.message}\n")
rescue Errno::EACCES => e
nvim.err_write("Permission denied: #{e.message}\n")
rescue StandardError => e
# Log unexpected errors
log_error(e)
nvim.err_write("Unexpected error: #{e.message}\n")
end
end
def log_error(error)
log_path = File.expand_path('~/.nvim-ruby-errors.log')
File.open(log_path, 'a') do |f|
f.puts "#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
f.puts error.message
f.puts error.backtrace.join("\n")
f.puts "\n"
end
end
end2. Configuration Management
Neovim.plugin do |plug|
def load_config(nvim)
{
enabled: nvim.get_var('ruby_plugin_enabled') rescue true,
timeout: nvim.get_var('ruby_plugin_timeout') rescue 30,
cache_dir: nvim.get_var('ruby_plugin_cache_dir') rescue File.expand_path('~/.cache/nvim-ruby')
}
end
plug.command(:PluginConfig) do |nvim|
require 'json'
config = load_config(nvim)
nvim.out_write(JSON.pretty_generate(config) + "\n")
end
plug.command(:PluginSetConfig, nargs: 2) do |nvim, key, value|
# Parse value
parsed_value = begin
Integer(value)
rescue ArgumentError
value == 'true' ? true : value == 'false' ? false : value
end
nvim.set_var("ruby_plugin_#{key}", parsed_value)
nvim.out_write("Set #{key} = #{parsed_value}\n")
end
end3. Documentation
# my_plugin.rb
#
# My Neovim Ruby Plugin
# =====================
#
# A Ruby plugin for Neovim that provides...
#
# Requirements:
# - neovim gem >= 0.9.0
# - Ruby >= 2.7
#
# Installation:
# 1. gem install neovim
# 2. Copy to rplugin/ruby/
# 3. Run :UpdateRemotePlugins
#
# Commands:
# :MyCommand - Does something useful
# :MyStatus - Shows plugin status
#
# Configuration:
# let g:my_plugin_enabled = 1
# let g:my_plugin_timeout = 30
#
# License: MIT
require 'neovim'
Neovim.plugin do |plug|
# Plugin implementation
plug.command(:MyCommand, nargs: '*') do |nvim, *args|
# Command implementation
#
# Args:
# args - Command arguments
#
# Example:
# :MyCommand arg1 arg2
end
endSummary: Chapter #31 – Ruby Integration
This chapter covered:
Setup – Installing and configuring the Ruby provider
Basic API – Executing Ruby code and using the Neovim Ruby API
Buffer/Window Operations – Manipulating Neovim buffers, windows, and tabs
Remote Plugins – Creating full-featured Ruby plugins
File Processing – Working with CSV, JSON, and other file formats
Advanced Features – Syntax highlighting, linting, and text processing
External Tools – Git, HTTP, and database integration
Async Operations – Background tasks and event-driven processing
Testing – Unit and integration testing with RSpec
Performance – Memoization and batch processing
Best Practices – Error handling, configuration, and documentation
The next chapter will explore Chapter #32: JavaScript/Node.js Integration, covering how to extend Neovim using JavaScript and the Node.js ecosystem.
Chapter #32: Git Integration
Neovim offers extensive capabilities for Git integration, from built-in features to powerful plugins. This chapter explores various approaches to working with Git seamlessly within your editor.
Built-in Git Features
Vim’s Native Git Support
" Read git output directly
:read !git status
:read !git diff %
" Write and commit in one go
:write | !git add % && git commit -m "Quick commit"
" Open file from specific commit
:edit .git/objects/...
" Use Git as external diff/merge tool
:set diffopt+=vertical
Terminal Integration
-- Terminal commands for Git
vim.keymap.set('n', '<leader>gs', ':terminal git status<CR>', { desc = 'Git Status' })
vim.keymap.set('n', '<leader>gl', ':terminal git log --oneline --graph --all<CR>', { desc = 'Git Log' })
vim.keymap.set('n', '<leader>gd', ':terminal git diff<CR>', { desc = 'Git Diff' })
-- Floating terminal for Git commands
vim.keymap.set('n', '<leader>gg', function()
local buf = vim.api.nvim_create_buf(false, true)
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.8)
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.fn.termopen('git status', {
on_exit = function()
vim.api.nvim_win_close(win, true)
end
})
vim.cmd('startinsert')
end, { desc = 'Git Status (Float)' })Using vim-fugitive
Installation and Setup
-- Using lazy.nvim
{
'tpope/vim-fugitive',
cmd = { 'G', 'Git', 'Gdiffsplit', 'Gread', 'Gwrite', 'Ggrep', 'GMove', 'GDelete', 'GBrowse' },
keys = {
{ '<leader>gs', ':Git<CR>', desc = 'Git Status' },
{ '<leader>gc', ':Git commit<CR>', desc = 'Git Commit' },
{ '<leader>gp', ':Git push<CR>', desc = 'Git Push' },
{ '<leader>gP', ':Git pull<CR>', desc = 'Git Pull' },
{ '<leader>gb', ':Git blame<CR>', desc = 'Git Blame' },
{ '<leader>gd', ':Gdiffsplit<CR>', desc = 'Git Diff' },
{ '<leader>gw', ':Gwrite<CR>', desc = 'Git Write (Stage)' },
{ '<leader>gr', ':Gread<CR>', desc = 'Git Read (Checkout)' },
},
}
-- Basic configuration
vim.g.fugitive_git_executable = 'git'Essential Fugitive Commands
" Git status interface
:Git
" or
:G
" In status window:
" - s: Stage file
" - u: Unstage file
" - =: Toggle inline diff
" - cc: Commit
" - ca: Commit --amend
" - ce: Commit --amend --no-edit
" - cw: Commit --amend --only
" - dv: Diff in vertical split
" - O: Open file in new tab
" Diff current file
:Gdiffsplit
:Gdiffsplit HEAD~1 " Compare with previous commit
:Gdiffsplit main " Compare with main branch
" In diff window:
" - do: Obtain from other buffer
" - dp: Put to other buffer
" - ]c: Next change
" - [c: Previous change
" Stage and unstage
:Gwrite " Stage current file
:Gread " Checkout current file (unstage changes)
" Commit
:Git commit
:Git commit -m "Message"
:Git commit --amend
" Blame
:Git blame
" In blame window:
" - o: Open commit
" - O: Open commit in new tab
" - -: Reblame at commit
" Browse on GitHub/GitLab
:GBrowse
:GBrowse main:% " Browse file on main branch
:'<,'>GBrowse " Browse selection (creates link)
Advanced Fugitive Workflows
-- Custom commands and mappings
vim.api.nvim_create_user_command('Gst', 'Git', {})
vim.api.nvim_create_user_command('Gco', 'Git checkout <args>', { nargs = '+' })
vim.api.nvim_create_user_command('Gbr', 'Git branch <args>', { nargs = '*' })
vim.api.nvim_create_user_command('Glog', 'Git log --oneline --graph --all', {})
-- Quick commit current file
vim.keymap.set('n', '<leader>gq', function()
local file = vim.fn.expand('%')
vim.cmd('Git add ' .. file)
local msg = vim.fn.input('Commit message: ')
if msg ~= '' then
vim.cmd('Git commit -m "' .. msg .. '"')
end
end, { desc = 'Quick Commit Current File' })
-- Show file history
vim.keymap.set('n', '<leader>gh', function()
vim.cmd('Git log --follow -p -- ' .. vim.fn.expand('%'))
end, { desc = 'File History' })
-- Resolve merge conflicts
vim.keymap.set('n', '<leader>gm', function()
-- Open 3-way merge
vim.cmd('Gdiffsplit!')
end, { desc = 'Merge Conflict (3-way)' })
-- In 3-way merge:
-- Target branch (HEAD): //2
-- Merge branch: //3
-- Working copy: (no suffix)
vim.keymap.set('n', '<leader>gh', ':diffget //2<CR>', { desc = 'Get from HEAD' })
vim.keymap.set('n', '<leader>gl', ':diffget //3<CR>', { desc = 'Get from merge branch' })Fugitive Integration with Telescope
{
'nvim-telescope/telescope.nvim',
dependencies = { 'tpope/vim-fugitive' },
config = function()
local builtin = require('telescope.builtin')
-- Git commits
vim.keymap.set('n', '<leader>gc', builtin.git_commits, { desc = 'Git Commits' })
-- Buffer commits
vim.keymap.set('n', '<leader>gbc', builtin.git_bcommits, { desc = 'Buffer Commits' })
-- Git branches
vim.keymap.set('n', '<leader>gB', builtin.git_branches, { desc = 'Git Branches' })
-- Git status
vim.keymap.set('n', '<leader>gS', builtin.git_status, { desc = 'Git Status (Telescope)' })
-- Git stash
vim.keymap.set('n', '<leader>gt', builtin.git_stash, { desc = 'Git Stash' })
end,
}Using gitsigns.nvim
Installation and Configuration
{
'lewis6991/gitsigns.nvim',
event = { 'BufReadPre', 'BufNewFile' },
opts = {
signs = {
add = { text = '│' },
change = { text = '│' },
delete = { text = '_' },
topdelete = { text = '‾' },
changedelete = { text = '~' },
untracked = { text = '┆' },
},
signcolumn = true, -- Toggle with `:Gitsigns toggle_signs`
numhl = false, -- Toggle with `:Gitsigns toggle_numhl`
linehl = false, -- Toggle with `:Gitsigns toggle_linehl`
word_diff = false, -- Toggle with `:Gitsigns toggle_word_diff`
watch_gitdir = {
interval = 1000,
follow_files = true
},
attach_to_untracked = true,
current_line_blame = false, -- Toggle with `:Gitsigns toggle_current_line_blame`
current_line_blame_opts = {
virt_text = true,
virt_text_pos = 'eol', -- 'eol' | 'overlay' | 'right_align'
delay = 1000,
ignore_whitespace = false,
},
current_line_blame_formatter = '<author>, <author_time:%Y-%m-%d> - <summary>',
sign_priority = 6,
update_debounce = 100,
status_formatter = nil, -- Use default
max_file_length = 40000,
preview_config = {
-- Options passed to nvim_open_win
border = 'rounded',
style = 'minimal',
relative = 'cursor',
row = 0,
col = 1
},
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', ']c', function()
if vim.wo.diff then return ']c' end
vim.schedule(function() gs.next_hunk() end)
return '<Ignore>'
end, { expr = true, desc = 'Next Hunk' })
map('n', '[c', function()
if vim.wo.diff then return '[c' end
vim.schedule(function() gs.prev_hunk() end)
return '<Ignore>'
end, { expr = true, desc = 'Previous Hunk' })
-- Actions
map('n', '<leader>hs', gs.stage_hunk, { desc = 'Stage Hunk' })
map('n', '<leader>hr', gs.reset_hunk, { desc = 'Reset Hunk' })
map('v', '<leader>hs', function()
gs.stage_hunk { vim.fn.line('.'), vim.fn.line('v') }
end, { desc = 'Stage Hunk' })
map('v', '<leader>hr', function()
gs.reset_hunk { vim.fn.line('.'), vim.fn.line('v') }
end, { desc = 'Reset Hunk' })
map('n', '<leader>hS', gs.stage_buffer, { desc = 'Stage Buffer' })
map('n', '<leader>hu', gs.undo_stage_hunk, { desc = 'Undo Stage Hunk' })
map('n', '<leader>hR', gs.reset_buffer, { desc = 'Reset Buffer' })
map('n', '<leader>hp', gs.preview_hunk, { desc = 'Preview Hunk' })
map('n', '<leader>hb', function()
gs.blame_line { full = true }
end, { desc = 'Blame Line' })
map('n', '<leader>tb', gs.toggle_current_line_blame, { desc = 'Toggle Blame' })
map('n', '<leader>hd', gs.diffthis, { desc = 'Diff This' })
map('n', '<leader>hD', function()
gs.diffthis('~')
end, { desc = 'Diff This ~' })
map('n', '<leader>td', gs.toggle_deleted, { desc = 'Toggle Deleted' })
-- Text object
map({'o', 'x'}, 'ih', ':<C-U>Gitsigns select_hunk<CR>', { desc = 'Select Hunk' })
end
},
}Gitsigns Commands and Features
-- Common Gitsigns commands
vim.api.nvim_create_user_command('GitsignsStatus', function()
local gs = package.loaded.gitsigns
local status = vim.b.gitsigns_status_dict
if status then
print(string.format('Git Status: +%d ~%d -%d',
status.added or 0,
status.changed or 0,
status.removed or 0
))
else
print('Not in a git repository')
end
end, {})
-- Custom statusline integration
local function git_status()
local status = vim.b.gitsigns_status_dict
if not status then return '' end
local parts = {}
if status.added and status.added > 0 then
table.insert(parts, '+' .. status.added)
end
if status.changed and status.changed > 0 then
table.insert(parts, '~' .. status.changed)
end
if status.removed and status.removed > 0 then
table.insert(parts, '-' .. status.removed)
end
if #parts > 0 then
return ' [' .. table.concat(parts, ' ') .. ']'
end
return ''
end
-- Add to statusline
vim.opt.statusline = table.concat({
'%f', -- filename
'%{luaeval("require(\'gitsigns\').get_status()")}',
'%m', -- modified flag
'%=', -- right align
'%l,%c', -- line, column
}, ' ')Using neogit
Installation and Configuration
{
'TimUntersberger/neogit',
dependencies = {
'nvim-lua/plenary.nvim',
'sindrets/diffview.nvim', -- Optional
'nvim-telescope/telescope.nvim', -- Optional
},
cmd = 'Neogit',
keys = {
{ '<leader>gn', ':Neogit<CR>', desc = 'Neogit' },
{ '<leader>gc', ':Neogit commit<CR>', desc = 'Neogit Commit' },
},
opts = {
disable_signs = false,
disable_hint = false,
disable_context_highlighting = false,
disable_commit_confirmation = false,
-- Customize signs
signs = {
section = { '', '' }, -- or { '>', 'v' }
item = { '', '' },
hunk = { '', '' },
},
integrations = {
diffview = true,
telescope = true,
},
-- Sections to show
sections = {
untracked = {
folded = false
},
unstaged = {
folded = false
},
staged = {
folded = false
},
stashes = {
folded = true
},
unpulled = {
folded = true
},
unmerged = {
folded = false
},
recent = {
folded = true
},
},
-- Popup mappings
mappings = {
status = {
['q'] = 'Close',
['I'] = 'InitRepo',
['1'] = 'Depth1',
['2'] = 'Depth2',
['3'] = 'Depth3',
['4'] = 'Depth4',
['<tab>'] = 'Toggle',
['x'] = 'Discard',
['s'] = 'Stage',
['S'] = 'StageUnstaged',
['<c-s>'] = 'StageAll',
['u'] = 'Unstage',
['U'] = 'UnstageStaged',
['d'] = 'DiffAtFile',
['$'] = 'CommandHistory',
['<c-r>'] = 'RefreshBuffer',
['<enter>'] = 'GoToFile',
['<c-v>'] = 'VSplitOpen',
['<c-x>'] = 'SplitOpen',
['<c-t>'] = 'TabOpen',
['{'] = 'GoToPreviousHunkHeader',
['}'] = 'GoToNextHunkHeader',
}
},
-- Auto refresh
auto_refresh = true,
-- Disable line numbers in neogit buffer
disable_line_numbers = true,
-- Commit editor configuration
commit_editor = {
kind = 'split', -- 'split', 'vsplit', 'split_above', 'tab', 'floating'
},
-- Console output
console_timeout = 2000,
auto_show_console = true,
},
}Neogit Workflows
-- Quick commit workflow
vim.keymap.set('n', '<leader>gcc', function()
require('neogit').open({ 'commit' })
end, { desc = 'Git Commit' })
-- Push workflow
vim.keymap.set('n', '<leader>gpp', function()
require('neogit').open({ 'push' })
end, { desc = 'Git Push' })
-- Pull workflow
vim.keymap.set('n', '<leader>gPP', function()
require('neogit').open({ 'pull' })
end, { desc = 'Git Pull' })
-- Log workflow
vim.keymap.set('n', '<leader>gll', function()
require('neogit').open({ 'log' })
end, { desc = 'Git Log' })
-- Stash workflow
vim.keymap.set('n', '<leader>gss', function()
require('neogit').open({ 'stash' })
end, { desc = 'Git Stash' })Using diffview.nvim
Installation and Configuration
{
'sindrets/diffview.nvim',
dependencies = 'nvim-lua/plenary.nvim',
cmd = { 'DiffviewOpen', 'DiffviewClose', 'DiffviewToggleFiles', 'DiffviewFocusFiles', 'DiffviewFileHistory' },
keys = {
{ '<leader>gdo', ':DiffviewOpen<CR>', desc = 'Diffview Open' },
{ '<leader>gdc', ':DiffviewClose<CR>', desc = 'Diffview Close' },
{ '<leader>gdh', ':DiffviewFileHistory %<CR>', desc = 'File History' },
{ '<leader>gdH', ':DiffviewFileHistory<CR>', desc = 'Full History' },
},
opts = {
diff_binaries = false,
enhanced_diff_hl = true,
git_cmd = { 'git' },
use_icons = true,
icons = {
folder_closed = '',
folder_open = '',
},
signs = {
fold_closed = '',
fold_open = '',
},
view = {
default = {
layout = 'diff2_horizontal',
winbar_info = false,
},
merge_tool = {
layout = 'diff3_horizontal',
disable_diagnostics = true,
},
file_history = {
layout = 'diff2_horizontal',
winbar_info = false,
},
},
file_panel = {
listing_style = 'tree', -- 'list' or 'tree'
tree_options = {
flatten_dirs = true,
folder_statuses = 'only_folded',
},
win_config = {
position = 'left',
width = 35,
},
},
file_history_panel = {
log_options = {
git = {
single_file = {
diff_merges = 'combined',
},
multi_file = {
diff_merges = 'first-parent',
},
},
},
win_config = {
position = 'bottom',
height = 16,
},
},
commit_log_panel = {
win_config = {},
},
default_args = {
DiffviewOpen = {},
DiffviewFileHistory = {},
},
hooks = {
diff_buf_read = function(bufnr)
-- Set local options for diff buffers
vim.opt_local.wrap = false
vim.opt_local.list = false
vim.opt_local.colorcolumn = ''
end,
diff_buf_win_enter = function(bufnr, winid, ctx)
-- Highlight the line in diff view when entering
if ctx.layout_name:match('^diff2') then
if ctx.symbol == 'a' then
vim.opt_local.winhl = table.concat({
'DiffAdd:DiffviewDiffAddAsDelete',
'DiffDelete:DiffviewDiffDelete',
}, ',')
elseif ctx.symbol == 'b' then
vim.opt_local.winhl = table.concat({
'DiffDelete:DiffviewDiffDelete',
}, ',')
end
end
end,
},
keymaps = {
disable_defaults = false,
view = {
['<tab>'] = require('diffview.actions').select_next_entry,
['<s-tab>'] = require('diffview.actions').select_prev_entry,
['gf'] = require('diffview.actions').goto_file,
['<C-w><C-f>'] = require('diffview.actions').goto_file_split,
['<C-w>gf'] = require('diffview.actions').goto_file_tab,
['<leader>e'] = require('diffview.actions').focus_files,
['<leader>b'] = require('diffview.actions').toggle_files,
},
file_panel = {
['j'] = require('diffview.actions').next_entry,
['k'] = require('diffview.actions').prev_entry,
['<cr>'] = require('diffview.actions').select_entry,
['o'] = require('diffview.actions').select_entry,
['<2-LeftMouse>'] = require('diffview.actions').select_entry,
['-'] = require('diffview.actions').toggle_stage_entry,
['S'] = require('diffview.actions').stage_all,
['U'] = require('diffview.actions').unstage_all,
['X'] = require('diffview.actions').restore_entry,
['R'] = require('diffview.actions').refresh_files,
['<tab>'] = require('diffview.actions').select_next_entry,
['<s-tab>'] = require('diffview.actions').select_prev_entry,
['gf'] = require('diffview.actions').goto_file,
['<C-w><C-f>'] = require('diffview.actions').goto_file_split,
['<C-w>gf'] = require('diffview.actions').goto_file_tab,
['i'] = require('diffview.actions').listing_style,
['f'] = require('diffview.actions').toggle_flatten_dirs,
['<leader>e'] = require('diffview.actions').focus_files,
['<leader>b'] = require('diffview.actions').toggle_files,
},
file_history_panel = {
['g!'] = require('diffview.actions').options,
['<C-A-d>'] = require('diffview.actions').open_in_diffview,
['y'] = require('diffview.actions').copy_hash,
['zR'] = require('diffview.actions').open_all_folds,
['zM'] = require('diffview.actions').close_all_folds,
['j'] = require('diffview.actions').next_entry,
['k'] = require('diffview.actions').prev_entry,
['<cr>'] = require('diffview.actions').select_entry,
['o'] = require('diffview.actions').select_entry,
['<2-LeftMouse>'] = require('diffview.actions').select_entry,
['<tab>'] = require('diffview.actions').select_next_entry,
['<s-tab>'] = require('diffview.actions').select_prev_entry,
['gf'] = require('diffview.actions').goto_file,
['<C-w><C-f>'] = require('diffview.actions').goto_file_split,
['<C-w>gf'] = require('diffview.actions').goto_file_tab,
['<leader>e'] = require('diffview.actions').focus_files,
['<leader>b'] = require('diffview.actions').toggle_files,
},
option_panel = {
['<tab>'] = require('diffview.actions').select_entry,
['q'] = require('diffview.actions').close,
},
},
},
}Diffview Usage
" Open diff view
:DiffviewOpen
:DiffviewOpen HEAD~2 " Compare with 2 commits ago
:DiffviewOpen HEAD~4..HEAD~2 " Compare commit range
:DiffviewOpen origin/main...HEAD " Compare with origin/main
" File history
:DiffviewFileHistory " All commits affecting current file
:DiffviewFileHistory % " Current file history
:DiffviewFileHistory --range=origin/main...HEAD " Specific range
" Close diff view
:DiffviewClose
" Toggle file panel
:DiffviewToggleFiles
" Focus file panel
:DiffviewFocusFiles
" Refresh
:DiffviewRefresh
Advanced Diffview Workflows
-- Compare branches
vim.keymap.set('n', '<leader>gdb', function()
local branch = vim.fn.input('Branch to compare: ')
if branch ~= '' then
vim.cmd('DiffviewOpen ' .. branch)
end
end, { desc = 'Compare Branch' })
-- Compare with remote
vim.keymap.set('n', '<leader>gdr', function()
vim.cmd('DiffviewOpen origin/main...HEAD')
end, { desc = 'Compare with Remote' })
-- View merge conflicts
vim.keymap.set('n', '<leader>gdm', function()
vim.cmd('DiffviewOpen')
end, { desc = 'View Merge Conflicts' })
-- File history with filters
vim.keymap.set('n', '<leader>gdf', function()
local author = vim.fn.input('Author (optional): ')
local cmd = 'DiffviewFileHistory'
if author ~= '' then
cmd = cmd .. ' --author=' .. author
end
vim.cmd(cmd)
end, { desc = 'Filtered File History' })Custom Git Integration
Simple Git Commands
-- Create custom git commands
local function git_exec(cmd, show_output)
local output = vim.fn.system('git ' .. cmd)
local exit_code = vim.v.shell_error
if exit_code ~= 0 then
vim.notify('Git error: ' .. output, vim.log.levels.ERROR)
return nil
end
if show_output then
vim.notify(output, vim.log.levels.INFO)
end
return output
end
-- Git add current file
vim.keymap.set('n', '<leader>ga', function()
local file = vim.fn.expand('%')
git_exec('add ' .. file, true)
end, { desc = 'Git Add' })
-- Git reset current file
vim.keymap.set('n', '<leader>gR', function()
local file = vim.fn.expand('%')
local confirm = vim.fn.confirm('Reset ' .. file .. '?', '&Yes\n&No', 2)
if confirm == 1 then
git_exec('checkout -- ' .. file, true)
vim.cmd('edit!')
end
end, { desc = 'Git Reset File' })
-- Quick commit
vim.keymap.set('n', '<leader>gC', function()
local msg = vim.fn.input('Commit message: ')
if msg ~= '' then
git_exec('commit -m "' .. msg .. '"', true)
end
end, { desc = 'Quick Commit' })
-- Git log for current file
vim.keymap.set('n', '<leader>gL', function()
local file = vim.fn.expand('%')
local log = git_exec('log --oneline --follow ' .. file, false)
if log then
local lines = vim.split(log, '\n')
vim.ui.select(lines, {
prompt = 'Select commit to view:',
}, function(choice)
if choice then
local commit = vim.split(choice, ' ')[1]
vim.cmd('DiffviewOpen ' .. commit .. '^..' .. commit)
end
end)
end
end, { desc = 'File Log' })Git Status in Floating Window
local function git_status_float()
local status = vim.fn.system('git status')
-- Create buffer
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(status, '\n'))
-- Set filetype for syntax highlighting
vim.api.nvim_buf_set_option(buf, 'filetype', 'git')
-- Calculate window size
local width = math.min(80, vim.o.columns - 4)
local height = math.min(20, vim.o.lines - 4)
-- Open floating window
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
title = ' Git Status ',
title_pos = 'center',
})
-- Set buffer options
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
-- Close on q or Esc
vim.keymap.set('n', 'q', ':close<CR>', { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', ':close<CR>', { buffer = buf, silent = true })
end
vim.keymap.set('n', '<leader>gf', git_status_float, { desc = 'Git Status (Float)' })Git Branch Switcher
local function git_branch_switcher()
-- Get branches
local branches = vim.fn.systemlist('git branch --all')
-- Clean up branch names
local branch_list = {}
for _, branch in ipairs(branches) do
local clean = branch:gsub('^[* ] ', ''):gsub('^remotes/', '')
table.insert(branch_list, clean)
end
-- Show picker
vim.ui.select(branch_list, {
prompt = 'Select branch:',
format_item = function(item)
return item
end,
}, function(choice)
if choice then
-- Remove remote prefix if exists
local branch = choice:gsub('^origin/', '')
-- Checkout branch
local result = vim.fn.system('git checkout ' .. branch)
if vim.v.shell_error == 0 then
vim.notify('Switched to branch: ' .. branch, vim.log.levels.INFO)
-- Reload buffers
vim.cmd('checktime')
else
vim.notify('Failed to switch branch: ' .. result, vim.log.levels.ERROR)
end
end
end)
end
vim.keymap.set('n', '<leader>gbb', git_branch_switcher, { desc = 'Switch Branch' })Git Commit Browser
local function git_commit_browser()
local commits = vim.fn.systemlist('git log --oneline -n 100')
vim.ui.select(commits, {
prompt = 'Select commit:',
}, function(choice)
if choice then
local commit = vim.split(choice, ' ')[1]
-- Show options
vim.ui.select({
'View diff',
'View files',
'Checkout',
'Cherry-pick',
'Show details',
}, {
prompt = 'Action:',
}, function(action)
if action == 'View diff' then
vim.cmd('DiffviewOpen ' .. commit .. '^..' .. commit)
elseif action == 'View files' then
local files = vim.fn.systemlist('git show --name-only --format="" ' .. commit)
vim.notify('Files changed:\n' .. table.concat(files, '\n'), vim.log.levels.INFO)
elseif action == 'Checkout' then
vim.cmd('Git checkout ' .. commit)
elseif action == 'Cherry-pick' then
vim.cmd('Git cherry-pick ' .. commit)
elseif action == 'Show details' then
local details = vim.fn.system('git show ' .. commit)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(details, '\n'))
vim.api.nvim_buf_set_option(buf, 'filetype', 'git')
vim.cmd('vsplit')
vim.api.nvim_win_set_buf(0, buf)
end
end)
end
end)
end
vim.keymap.set('n', '<leader>gCB', git_commit_browser, { desc = 'Commit Browser' })Git Worktree Management
Using git-worktree.nvim
{
'ThePrimeagen/git-worktree.nvim',
dependencies = {
'nvim-telescope/telescope.nvim',
'nvim-lua/plenary.nvim',
},
config = function()
require('git-worktree').setup({
change_directory_command = 'cd',
update_on_change = true,
update_on_change_command = 'e .',
clearjumps_on_change = true,
autopush = false,
})
-- Telescope integration
require('telescope').load_extension('git_worktree')
-- Keymaps
vim.keymap.set('n', '<leader>gwt',
require('telescope').extensions.git_worktree.git_worktrees,
{ desc = 'Git Worktrees' }
)
vim.keymap.set('n', '<leader>gwc',
require('telescope').extensions.git_worktree.create_git_worktree,
{ desc = 'Create Worktree' }
)
-- Hooks
local worktree = require('git-worktree')
worktree.on_tree_change(function(op, metadata)
if op == worktree.Operations.Switch then
print('Switched to ' .. metadata.path)
elseif op == worktree.Operations.Create then
print('Created worktree at ' .. metadata.path)
elseif op == worktree.Operations.Delete then
print('Deleted worktree at ' .. metadata.path)
end
end)
end,
}Manual Worktree Commands
-- Create worktree
vim.api.nvim_create_user_command('WorktreeCreate', function(opts)
local branch = opts.fargs[1]
local path = opts.fargs[2] or ('../' .. branch)
local cmd = string.format('git worktree add %s %s', path, branch)
local output = vim.fn.system(cmd)
if vim.v.shell_error == 0 then
vim.notify('Created worktree: ' .. path, vim.log.levels.INFO)
-- Optionally switch to it
local confirm = vim.fn.confirm('Switch to new worktree?', '&Yes\n&No', 1)
if confirm == 1 then
vim.cmd('cd ' .. path)
vim.cmd('edit .')
end
else
vim.notify('Failed: ' .. output, vim.log.levels.ERROR)
end
end, { nargs = '+' })
-- List worktrees
vim.api.nvim_create_user_command('WorktreeList', function()
local output = vim.fn.system('git worktree list')
vim.notify(output, vim.log.levels.INFO)
end, {})
-- Remove worktree
vim.api.nvim_create_user_command('WorktreeRemove', function(opts)
local path = opts.fargs[1]
local cmd = 'git worktree remove ' .. path
local confirm = vim.fn.confirm('Remove worktree: ' .. path .. '?', '&Yes\n&No', 2)
if confirm == 1 then
local output = vim.fn.system(cmd)
if vim.v.shell_error == 0 then
vim.notify('Removed worktree: ' .. path, vim.log.levels.INFO)
else
vim.notify('Failed: ' .. output, vim.log.levels.ERROR)
end
end
end, { nargs = 1 })Git Conflict Resolution
Conflict Markers and Navigation
-- Highlight conflict markers
vim.api.nvim_create_autocmd('BufRead', {
pattern = '*',
callback = function()
-- Check if file has conflict markers
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local has_conflicts = false
for _, line in ipairs(lines) do
if line:match('^<<<<<<<') or line:match('^=======') or line:match('^>>>>>>>') then
has_conflicts = true
break
end
end
if has_conflicts then
-- Set up conflict navigation
vim.keymap.set('n', ']x', function()
vim.fn.search('^<<<<<<< ', 'W')
end, { buffer = true, desc = 'Next Conflict' })
vim.keymap.set('n', '[x', function()
vim.fn.search('^<<<<<<< ', 'bW')
end, { buffer = true, desc = 'Previous Conflict' })
-- Quick resolution
vim.keymap.set('n', '<leader>co', function()
-- Take ours (current branch)
local line = vim.fn.line('.')
vim.cmd(line .. ',/^=======/-1d')
vim.cmd('/^>>>>>>>/d')
end, { buffer = true, desc = 'Conflict: Ours' })
vim.keymap.set('n', '<leader>ct', function()
-- Take theirs (incoming branch)
vim.cmd('/^<<<<<<</,/^=======/d')
vim.cmd('/^>>>>>>>/d')
end, { buffer = true, desc = 'Conflict: Theirs' })
vim.keymap.set('n', '<leader>cb', function()
-- Take both
vim.cmd('/^<<<<<<</d')
vim.cmd('/^=======/d')
vim.cmd('/^>>>>>>>/d')
end, { buffer = true, desc = 'Conflict: Both' })
vim.notify('Conflict markers detected. Use ]x/[x to navigate, <leader>co/ct/cb to resolve',
vim.log.levels.WARN)
end
end,
})Three-Way Merge Helper
local function setup_merge_tool()
-- Detect if in merge conflict
local output = vim.fn.system('git diff --check')
if output:match('conflict') then
-- Open three-way diff
vim.cmd('Gdiffsplit!')
-- Helpful keymaps in merge mode
vim.keymap.set('n', '<leader>gh', ':diffget //2<CR>', { desc = 'Get from HEAD' })
vim.keymap.set('n', '<leader>gt', ':diffget //3<CR>', { desc = 'Get from MERGE_HEAD' })
vim.keymap.set('n', '<leader>gn', ']c', { desc = 'Next conflict', remap = true })
vim.keymap.set('n', '<leader>gp', '[c', { desc = 'Previous conflict', remap = true })
end
end
vim.keymap.set('n', '<leader>gM', setup_merge_tool, { desc = 'Setup Merge Tool' })Advanced Git Workflows
Staging Hunks Interactively
-- Interactive hunk staging (requires gitsigns.nvim)
vim.keymap.set('n', '<leader>hv', function()
local gs = package.loaded.gitsigns
-- Preview hunk
gs.preview_hunk()
-- Wait for user input
vim.defer_fn(function()
local choice = vim.fn.input('Stage this hunk? (y/n/q): ')
if choice == 'y' then
gs.stage_hunk()
-- Move to next hunk and repeat
vim.schedule(function()
gs.next_hunk()
vim.cmd('normal! zz')
-- Could recursively call this function
end)
elseif choice == 'n' then
-- Skip to next hunk
gs.next_hunk()
vim.cmd('normal! zz')
end
-- 'q' quits
end, 100)
end, { desc = 'Interactive Hunk Staging' })Commit Message Templates
-- Set up commit message template
vim.api.nvim_create_autocmd('BufRead', {
pattern = 'COMMIT_EDITMSG',
callback = function()
-- Insert template if buffer is empty
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local is_empty = #lines == 1 and lines[1] == ''
if is_empty then
local template = {
'',
'# Type: feat|fix|docs|style|refactor|test|chore',
'# Scope: component|module',
'# ',
'# Subject: imperative mood, lowercase, no period',
'# ',
'# Body: explain what and why (optional)',
'# ',
'# Footer: breaking changes, issues closed (optional)',
}
vim.api.nvim_buf_set_lines(0, 0, 0, false, template)
vim.api.nvim_win_set_cursor(0, {1, 0})
end
-- Enable spell checking
vim.opt_local.spell = true
vim.opt_local.spelllang = 'en_us'
-- Set textwidth
vim.opt_local.textwidth = 72
vim.opt_local.colorcolumn = '50,72'
end,
})Git Stash Manager
local function git_stash_manager()
local stashes = vim.fn.systemlist('git stash list')
if #stashes == 0 then
vim.notify('No stashes found', vim.log.levels.INFO)
return
end
vim.ui.select(stashes, {
prompt = 'Select stash:',
}, function(choice)
if choice then
local stash_id = vim.split(choice, ':')[1]
vim.ui.select({
'Show',
'Apply',
'Pop',
'Drop',
}, {
prompt = 'Action:',
}, function(action)
local cmd
if action == 'Show' then
local content = vim.fn.system('git stash show -p ' .. stash_id)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(content, '\n'))
vim.api.nvim_buf_set_option(buf, 'filetype', 'diff')
vim.cmd('vsplit')
vim.api.nvim_win_set_buf(0, buf)
elseif action == 'Apply' then
cmd = 'git stash apply ' .. stash_id
elseif action == 'Pop' then
cmd = 'git stash pop ' .. stash_id
elseif action == 'Drop' then
cmd = 'git stash drop ' .. stash_id
end
if cmd then
local output = vim.fn.system(cmd)
if vim.v.shell_error == 0 then
vim.notify('Stash ' .. action:lower() .. 'ed successfully', vim.log.levels.INFO)
vim.cmd('checktime')
else
vim.notify('Failed: ' .. output, vim.log.levels.ERROR)
end
end
end)
end
end)
end
vim.keymap.set('n', '<leader>gst', git_stash_manager, { desc = 'Stash Manager' })Performance Optimization
Lazy Loading Git Plugins
-- Lazy load git plugins only in git repositories
local function is_git_repo()
local handle = io.popen('git rev-parse --is-inside-work-tree 2>/dev/null')
if handle then
local result = handle:read('*a')
handle:close()
return result:match('true')
end
return false
end
-- Conditional loading
{
'lewis6991/gitsigns.nvim',
cond = is_git_repo,
event = { 'BufReadPre', 'BufNewFile' },
}
-- Or use event-based loading
{
'tpope/vim-fugitive',
cmd = { 'G', 'Git' },
ft = { 'gitcommit', 'gitrebase' },
}Optimizing Git Operations
-- Cache git root for faster operations
local git_root_cache = {}
local function get_git_root(path)
if git_root_cache[path] then
return git_root_cache[path]
end
local handle = io.popen('git -C ' .. path .. ' rev-parse --show-toplevel 2>/dev/null')
if handle then
local root = handle:read('*l')
handle:close()
if root and root ~= '' then
git_root_cache[path] = root
return root
end
end
return nil
end
-- Use cached git root
vim.api.nvim_create_user_command('GitRoot', function()
local root = get_git_root(vim.fn.getcwd())
if root then
vim.notify('Git root: ' .. root, vim.log.levels.INFO)
else
vim.notify('Not in a git repository', vim.log.levels.WARN)
end
end, {})Summary: Chapter #32 – Git Integration
This chapter covered comprehensive Git integration in Neovim:
Built-in Features – Using Vim’s native Git support and terminal integration
vim-fugitive – The classic Git wrapper with status, diff, blame, and merge capabilities
gitsigns.nvim – Modern Git signs, hunk operations, and inline blame
neogit – A Magit-inspired Git client for Neovim
diffview.nvim – Advanced diff viewing and file history
Custom Integration – Building custom Git commands and workflows
Worktree Management – Managing Git worktrees efficiently
Conflict Resolution – Tools and strategies for resolving merge conflicts
Advanced Workflows – Interactive staging, commit templates, and stash management
Performance – Lazy loading and optimization techniques
With these tools and configurations, you can perform nearly all Git operations without leaving Neovim, creating a seamless and efficient development workflow.
Chapter #33: File Navigation and Fuzzy Finders
Modern file navigation is essential for productive Neovim workflows. This chapter explores built-in navigation features, fuzzy finders, and file explorer plugins.
Built-in File Navigation
Native Netrw
Neovim includes netrw, a built-in file explorer.
" Open netrw
:Explore " Open in current window
:Sexplore " Open in horizontal split
:Vexplore " Open in vertical split
:Texplore " Open in new tab
" Navigate to specific directory
:Explore ~/projects
:Explore %:h " Directory of current file
" Netrw browser commands (in netrw window):
" <CR> - Open file/directory
" - - Go up one directory
" d - Create directory
" % - Create new file
" D - Delete file/directory
" R - Rename file
" s - Change sort order
" i - Cycle through view modes
" gh - Toggle hidden files
" qf - Display file info
" v - Open in vertical split
" t - Open in new tab
Netrw Configuration
-- Disable netrw for plugin file explorers
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
-- Or configure netrw
vim.g.netrw_banner = 0 -- Hide banner
vim.g.netrw_liststyle = 3 -- Tree view
vim.g.netrw_browse_split = 4 -- Open in previous window
vim.g.netrw_altv = 1 -- Open splits to the right
vim.g.netrw_winsize = 25 -- Width percentage
vim.g.netrw_list_hide = '.git,.DS_Store'
-- Custom netrw mappings
vim.api.nvim_create_autocmd('FileType', {
pattern = 'netrw',
callback = function()
local map = function(lhs, rhs)
vim.keymap.set('n', lhs, rhs, { buffer = true, remap = true })
end
map('h', '-') -- Go up with h
map('l', '<CR>') -- Open with l
map('.', 'gh') -- Toggle hidden files
end,
})Path and Find Commands
" Set path for file searching
:set path+=** " Search recursively in current directory
:set path+=/usr/include " Add specific directories
" Find files
:find filename.txt " Find and open file
:find **/*filename* " Fuzzy find with wildcards
:find *.lua " Find by pattern
" Tab completion for find
:find conf<Tab> " Cycle through matches
" Navigate to file under cursor
gf " Go to file
<C-w>gf " Open in new tab
<C-w>f " Open in new split
" Set suffixes for gf to find files
:set suffixesadd+=.lua,.js,.py
Buffer Navigation
-- Enhanced buffer navigation
vim.keymap.set('n', '<leader>bb', ':buffers<CR>:buffer<Space>', { desc = 'Buffer List' })
vim.keymap.set('n', '<leader>bn', ':bnext<CR>', { desc = 'Next Buffer' })
vim.keymap.set('n', '<leader>bp', ':bprevious<CR>', { desc = 'Previous Buffer' })
vim.keymap.set('n', '<leader>bd', ':bdelete<CR>', { desc = 'Delete Buffer' })
vim.keymap.set('n', '<leader>bw', ':bwipeout<CR>', { desc = 'Wipeout Buffer' })
-- Delete other buffers
vim.api.nvim_create_user_command('BufOnly', function()
local current = vim.api.nvim_get_current_buf()
local buffers = vim.api.nvim_list_bufs()
for _, buf in ipairs(buffers) do
if buf ~= current and vim.api.nvim_buf_is_loaded(buf) then
vim.api.nvim_buf_delete(buf, { force = false })
end
end
end, {})
vim.keymap.set('n', '<leader>bo', ':BufOnly<CR>', { desc = 'Delete Other Buffers' })Telescope.nvim - The Modern Fuzzy Finder
Installation and Basic Setup
{
'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-tree/nvim-web-devicons',
},
config = function()
local telescope = require('telescope')
local actions = require('telescope.actions')
local builtin = require('telescope.builtin')
telescope.setup({
defaults = {
prompt_prefix = '🔍 ',
selection_caret = '➤ ',
path_display = { 'truncate' },
mappings = {
i = {
['<C-n>'] = actions.move_selection_next,
['<C-p>'] = actions.move_selection_previous,
['<C-c>'] = actions.close,
['<Down>'] = actions.move_selection_next,
['<Up>'] = actions.move_selection_previous,
['<CR>'] = actions.select_default,
['<C-x>'] = actions.select_horizontal,
['<C-v>'] = actions.select_vertical,
['<C-t>'] = actions.select_tab,
['<C-u>'] = actions.preview_scrolling_up,
['<C-d>'] = actions.preview_scrolling_down,
['<PageUp>'] = actions.results_scrolling_up,
['<PageDown>'] = actions.results_scrolling_down,
['<Tab>'] = actions.toggle_selection + actions.move_selection_worse,
['<S-Tab>'] = actions.toggle_selection + actions.move_selection_better,
['<C-q>'] = actions.send_to_qflist + actions.open_qflist,
['<M-q>'] = actions.send_selected_to_qflist + actions.open_qflist,
['<C-l>'] = actions.complete_tag,
['<C-_>'] = actions.which_key, -- keys from pressing <C-/>
},
n = {
['<esc>'] = actions.close,
['<CR>'] = actions.select_default,
['<C-x>'] = actions.select_horizontal,
['<C-v>'] = actions.select_vertical,
['<C-t>'] = actions.select_tab,
['<Tab>'] = actions.toggle_selection + actions.move_selection_worse,
['<S-Tab>'] = actions.toggle_selection + actions.move_selection_better,
['<C-q>'] = actions.send_to_qflist + actions.open_qflist,
['<M-q>'] = actions.send_selected_to_qflist + actions.open_qflist,
['j'] = actions.move_selection_next,
['k'] = actions.move_selection_previous,
['H'] = actions.move_to_top,
['M'] = actions.move_to_middle,
['L'] = actions.move_to_bottom,
['<Down>'] = actions.move_selection_next,
['<Up>'] = actions.move_selection_previous,
['gg'] = actions.move_to_top,
['G'] = actions.move_to_bottom,
['<C-u>'] = actions.preview_scrolling_up,
['<C-d>'] = actions.preview_scrolling_down,
['<PageUp>'] = actions.results_scrolling_up,
['<PageDown>'] = actions.results_scrolling_down,
['?'] = actions.which_key,
},
},
file_ignore_patterns = {
'node_modules',
'.git/',
'dist/',
'build/',
'%.jpg',
'%.jpeg',
'%.png',
'%.svg',
'%.otf',
'%.ttf',
},
-- Sorting
sorting_strategy = 'ascending',
layout_strategy = 'horizontal',
layout_config = {
horizontal = {
prompt_position = 'top',
preview_width = 0.55,
results_width = 0.8,
},
vertical = {
mirror = false,
},
width = 0.87,
height = 0.80,
preview_cutoff = 120,
},
-- Border
border = true,
borderchars = { '─', '│', '─', '│', '╭', '╮', '╯', '╰' },
-- Preview
preview = {
treesitter = true,
},
-- History
history = {
path = vim.fn.stdpath('data') .. '/telescope_history.sqlite3',
limit = 100,
},
},
pickers = {
find_files = {
theme = 'dropdown',
previewer = false,
hidden = true,
},
git_files = {
theme = 'dropdown',
previewer = false,
},
buffers = {
theme = 'dropdown',
previewer = false,
sort_lastused = true,
mappings = {
i = {
['<C-d>'] = actions.delete_buffer,
},
n = {
['dd'] = actions.delete_buffer,
},
},
},
oldfiles = {
theme = 'dropdown',
previewer = false,
},
live_grep = {
additional_args = function()
return { '--hidden' }
end,
},
grep_string = {
additional_args = function()
return { '--hidden' }
end,
},
},
extensions = {
fzf = {
fuzzy = true,
override_generic_sorter = true,
override_file_sorter = true,
case_mode = 'smart_case',
},
},
})
-- Load extensions
telescope.load_extension('fzf')
end,
}Essential Telescope Keymaps
local builtin = require('telescope.builtin')
-- File finders
vim.keymap.set('n', '<leader>ff', builtin.find_files, { desc = 'Find Files' })
vim.keymap.set('n', '<leader>fg', builtin.git_files, { desc = 'Git Files' })
vim.keymap.set('n', '<leader>fb', builtin.buffers, { desc = 'Buffers' })
vim.keymap.set('n', '<leader>fo', builtin.oldfiles, { desc = 'Old Files' })
vim.keymap.set('n', '<leader>fr', builtin.resume, { desc = 'Resume' })
-- Search
vim.keymap.set('n', '<leader>/', builtin.live_grep, { desc = 'Live Grep' })
vim.keymap.set('n', '<leader>fw', builtin.grep_string, { desc = 'Grep String' })
vim.keymap.set('n', '<leader>fc', builtin.current_buffer_fuzzy_find, { desc = 'Current Buffer' })
-- Help and documentation
vim.keymap.set('n', '<leader>fh', builtin.help_tags, { desc = 'Help Tags' })
vim.keymap.set('n', '<leader>fm', builtin.man_pages, { desc = 'Man Pages' })
vim.keymap.set('n', '<leader>fk', builtin.keymaps, { desc = 'Keymaps' })
vim.keymap.set('n', '<leader>fC', builtin.commands, { desc = 'Commands' })
vim.keymap.set('n', '<leader>fH', builtin.command_history, { desc = 'Command History' })
-- Vim internals
vim.keymap.set('n', '<leader>fv', builtin.vim_options, { desc = 'Vim Options' })
vim.keymap.set('n', '<leader>fa', builtin.autocommands, { desc = 'Autocommands' })
vim.keymap.set('n', '<leader>fq', builtin.quickfix, { desc = 'Quickfix' })
vim.keymap.set('n', '<leader>fl', builtin.loclist, { desc = 'Location List' })
vim.keymap.set('n', '<leader>fj', builtin.jumplist, { desc = 'Jumplist' })
vim.keymap.set('n', '<leader>fR', builtin.registers, { desc = 'Registers' })
vim.keymap.set('n', '<leader>fs', builtin.spell_suggest, { desc = 'Spell Suggest' })
-- Git
vim.keymap.set('n', '<leader>gc', builtin.git_commits, { desc = 'Git Commits' })
vim.keymap.set('n', '<leader>gbc', builtin.git_bcommits, { desc = 'Buffer Commits' })
vim.keymap.set('n', '<leader>gb', builtin.git_branches, { desc = 'Git Branches' })
vim.keymap.set('n', '<leader>gs', builtin.git_status, { desc = 'Git Status' })
vim.keymap.set('n', '<leader>gt', builtin.git_stash, { desc = 'Git Stash' })
-- LSP
vim.keymap.set('n', '<leader>lr', builtin.lsp_references, { desc = 'LSP References' })
vim.keymap.set('n', '<leader>ld', builtin.lsp_definitions, { desc = 'LSP Definitions' })
vim.keymap.set('n', '<leader>li', builtin.lsp_implementations, { desc = 'LSP Implementations' })
vim.keymap.set('n', '<leader>lt', builtin.lsp_type_definitions, { desc = 'LSP Type Definitions' })
vim.keymap.set('n', '<leader>ls', builtin.lsp_document_symbols, { desc = 'Document Symbols' })
vim.keymap.set('n', '<leader>lw', builtin.lsp_workspace_symbols, { desc = 'Workspace Symbols' })
vim.keymap.set('n', '<leader>le', builtin.diagnostics, { desc = 'Diagnostics' })
-- Treesitter
vim.keymap.set('n', '<leader>ft', builtin.treesitter, { desc = 'Treesitter Symbols' })Custom Telescope Pickers
-- Find files in config directory
vim.keymap.set('n', '<leader>fN', function()
builtin.find_files({
prompt_title = 'Neovim Config',
cwd = vim.fn.stdpath('config'),
})
end, { desc = 'Neovim Config Files' })
-- Find in specific directory
vim.keymap.set('n', '<leader>fp', function()
builtin.find_files({
prompt_title = 'Project Files',
cwd = '~/projects',
})
end, { desc = 'Project Files' })
-- Search in current directory
vim.keymap.set('n', '<leader>fD', function()
builtin.find_files({
prompt_title = 'Current Directory',
cwd = vim.fn.expand('%:p:h'),
})
end, { desc = 'Current Directory Files' })
-- Search only in git tracked files
vim.keymap.set('n', '<leader>fG', function()
builtin.git_files({
show_untracked = false,
})
end, { desc = 'Git Tracked Files' })
-- Grep in specific file types
vim.keymap.set('n', '<leader>fL', function()
builtin.live_grep({
prompt_title = 'Grep Lua Files',
glob_pattern = '*.lua',
})
end, { desc = 'Grep Lua Files' })
-- Find todos
vim.keymap.set('n', '<leader>fT', function()
builtin.grep_string({
prompt_title = 'Find TODOs',
search = 'TODO|FIXME|HACK|NOTE',
use_regex = true,
})
end, { desc = 'Find TODOs' })
-- Search in open buffers
vim.keymap.set('n', '<leader>fB', function()
builtin.live_grep({
prompt_title = 'Grep Open Buffers',
grep_open_files = true,
})
end, { desc = 'Grep Buffers' })Advanced Telescope Features
-- Multi-selection workflow
vim.keymap.set('n', '<leader>fM', function()
builtin.find_files({
attach_mappings = function(_, map)
local action_state = require('telescope.actions.state')
map('i', '<CR>', function(prompt_bufnr)
local picker = action_state.get_current_picker(prompt_bufnr)
local multi = picker:get_multi_selection()
if #multi > 0 then
-- Process multiple selections
for _, entry in ipairs(multi) do
print('Selected: ' .. entry.value)
end
else
-- Single selection
actions.select_default(prompt_bufnr)
end
end)
return true
end,
})
end, { desc = 'Multi-select Files' })
-- Custom preview configuration
vim.keymap.set('n', '<leader>fP', function()
builtin.find_files({
previewer = require('telescope.previewers').vim_buffer_cat.new({}),
preview = {
hide_on_startup = false,
},
})
end, { desc = 'Files with Preview' })
-- Search with hidden files
vim.keymap.set('n', '<leader>f.', function()
builtin.find_files({
hidden = true,
no_ignore = true,
})
end, { desc = 'All Files (Hidden)' })Telescope Extensions
File Browser Extension
{
'nvim-telescope/telescope-file-browser.nvim',
dependencies = { 'nvim-telescope/telescope.nvim' },
config = function()
require('telescope').setup({
extensions = {
file_browser = {
theme = 'ivy',
hijack_netrw = true,
mappings = {
['i'] = {
['<C-n>'] = require('telescope._extensions.file_browser.actions').create,
['<C-r>'] = require('telescope._extensions.file_browser.actions').rename,
['<C-d>'] = require('telescope._extensions.file_browser.actions').remove,
},
['n'] = {
['c'] = require('telescope._extensions.file_browser.actions').create,
['r'] = require('telescope._extensions.file_browser.actions').rename,
['d'] = require('telescope._extensions.file_browser.actions').remove,
['h'] = require('telescope._extensions.file_browser.actions').goto_parent_dir,
['l'] = require('telescope.actions').select_default,
},
},
},
},
})
require('telescope').load_extension('file_browser')
vim.keymap.set('n', '<leader>fe', function()
require('telescope').extensions.file_browser.file_browser({
path = '%:p:h',
select_buffer = true,
})
end, { desc = 'File Browser' })
end,
}Project Management Extension
{
'nvim-telescope/telescope-project.nvim',
dependencies = { 'nvim-telescope/telescope.nvim' },
config = function()
require('telescope').setup({
extensions = {
project = {
base_dirs = {
'~/projects',
{ '~/work', max_depth = 2 },
},
hidden_files = true,
order_by = 'recent',
sync_with_nvim_tree = true,
},
},
})
require('telescope').load_extension('project')
vim.keymap.set('n', '<leader>fP',
require('telescope').extensions.project.project,
{ desc = 'Projects' }
)
end,
}Frecency Extension
{
'nvim-telescope/telescope-frecency.nvim',
dependencies = {
'nvim-telescope/telescope.nvim',
'kkharji/sqlite.lua',
},
config = function()
require('telescope').setup({
extensions = {
frecency = {
show_scores = false,
show_unindexed = true,
ignore_patterns = { '*.git/*', '*/tmp/*' },
workspaces = {
['conf'] = vim.fn.stdpath('config'),
['data'] = vim.fn.stdpath('data'),
['proj'] = '~/projects',
},
},
},
})
require('telescope').load_extension('frecency')
vim.keymap.set('n', '<leader>fF',
require('telescope').extensions.frecency.frecency,
{ desc = 'Frecent Files' }
)
end,
}FZF.vim Alternative
For those who prefer the classic fzf.vim:
{
'junegunn/fzf',
build = './install --all',
}
{
'junegunn/fzf.vim',
dependencies = { 'junegunn/fzf' },
config = function()
-- FZF layout
vim.g.fzf_layout = { window = { width = 0.9, height = 0.6 } }
-- Custom FZF colors
vim.g.fzf_colors = {
fg = { 'fg', 'Normal' },
bg = { 'bg', 'Normal' },
hl = { 'fg', 'Comment' },
['fg+'] = { 'fg', 'CursorLine', 'CursorColumn', 'Normal' },
['bg+'] = { 'bg', 'CursorLine', 'CursorColumn' },
['hl+'] = { 'fg', 'Statement' },
info = { 'fg', 'PreProc' },
border = { 'fg', 'Ignore' },
prompt = { 'fg', 'Conditional' },
pointer = { 'fg', 'Exception' },
marker = { 'fg', 'Keyword' },
spinner = { 'fg', 'Label' },
header = { 'fg', 'Comment' },
}
-- Keymaps
vim.keymap.set('n', '<leader>ff', ':Files<CR>', { desc = 'Files' })
vim.keymap.set('n', '<leader>fg', ':GFiles<CR>', { desc = 'Git Files' })
vim.keymap.set('n', '<leader>fb', ':Buffers<CR>', { desc = 'Buffers' })
vim.keymap.set('n', '<leader>/', ':Rg<CR>', { desc = 'Ripgrep' })
vim.keymap.set('n', '<leader>fl', ':Lines<CR>', { desc = 'Lines' })
vim.keymap.set('n', '<leader>fc', ':BLines<CR>', { desc = 'Buffer Lines' })
vim.keymap.set('n', '<leader>ft', ':Tags<CR>', { desc = 'Tags' })
vim.keymap.set('n', '<leader>fm', ':Marks<CR>', { desc = 'Marks' })
vim.keymap.set('n', '<leader>fw', ':Windows<CR>', { desc = 'Windows' })
vim.keymap.set('n', '<leader>fh', ':History<CR>', { desc = 'History' })
vim.keymap.set('n', '<leader>f:', ':History:<CR>', { desc = 'Command History' })
vim.keymap.set('n', '<leader>f/', ':History/<CR>', { desc = 'Search History' })
vim.keymap.set('n', '<leader>fH', ':Helptags<CR>', { desc = 'Help Tags' })
vim.keymap.set('n', '<leader>fC', ':Commands<CR>', { desc = 'Commands' })
vim.keymap.set('n', '<leader>fM', ':Maps<CR>', { desc = 'Keymaps' })
-- Custom commands
vim.api.nvim_create_user_command('ProjectFiles', function()
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
if vim.v.shell_error == 0 then
vim.fn['fzf#run'](vim.fn['fzf#wrap']({
source = 'git ls-files',
dir = git_root,
}))
else
vim.cmd('Files')
end
end, {})
end,
}nvim-tree.lua - File Explorer
Installation and Configuration
{
'nvim-tree/nvim-tree.lua',
dependencies = { 'nvim-tree/nvim-web-devicons' },
config = function()
-- Disable netrw
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
require('nvim-tree').setup({
disable_netrw = true,
hijack_netrw = true,
hijack_cursor = true,
hijack_unnamed_buffer_when_opening = false,
sync_root_with_cwd = true,
update_focused_file = {
enable = true,
update_root = false,
ignore_list = {},
},
view = {
adaptive_size = false,
centralize_selection = false,
width = 30,
side = 'left',
preserve_window_proportions = false,
number = false,
relativenumber = false,
signcolumn = 'yes',
float = {
enable = false,
quit_on_focus_loss = true,
open_win_config = {
relative = 'editor',
border = 'rounded',
width = 30,
height = 30,
row = 1,
col = 1,
},
},
},
renderer = {
add_trailing = false,
group_empty = false,
highlight_git = true,
full_name = false,
highlight_opened_files = 'none',
root_folder_label = ':~:s?$?/..?',
indent_width = 2,
indent_markers = {
enable = true,
inline_arrows = true,
icons = {
corner = '└',
edge = '│',
item = '│',
bottom = '─',
none = ' ',
},
},
icons = {
webdev_colors = true,
git_placement = 'before',
padding = ' ',
symlink_arrow = ' ➛ ',
show = {
file = true,
folder = true,
folder_arrow = true,
git = true,
},
glyphs = {
default = '',
symlink = '',
bookmark = '',
folder = {
arrow_closed = '',
arrow_open = '',
default = '',
open = '',
empty = '',
empty_open = '',
symlink = '',
symlink_open = '',
},
git = {
unstaged = '✗',
staged = '✓',
unmerged = '',
renamed = '➜',
untracked = '★',
deleted = '',
ignored = '◌',
},
},
},
special_files = { 'Cargo.toml', 'Makefile', 'README.md', 'readme.md' },
symlink_destination = true,
},
filters = {
dotfiles = false,
git_clean = false,
no_buffer = false,
custom = { '^.git$', 'node_modules', '.cache' },
exclude = {},
},
filesystem_watchers = {
enable = true,
debounce_delay = 50,
ignore_dirs = {},
},
git = {
enable = true,
ignore = false,
show_on_dirs = true,
show_on_open_dirs = true,
timeout = 400,
},
actions = {
use_system_clipboard = true,
change_dir = {
enable = true,
global = false,
restrict_above_cwd = false,
},
expand_all = {
max_folder_discovery = 300,
exclude = {},
},
file_popup = {
open_win_config = {
col = 1,
row = 1,
relative = 'cursor',
border = 'shadow',
style = 'minimal',
},
},
open_file = {
quit_on_open = false,
resize_window = true,
window_picker = {
enable = true,
picker = 'default',
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
exclude = {
filetype = { 'notify', 'packer', 'qf', 'diff', 'fugitive', 'fugitiveblame' },
buftype = { 'nofile', 'terminal', 'help' },
},
},
},
remove_file = {
close_window = true,
},
},
trash = {
cmd = 'trash',
require_confirm = true,
},
live_filter = {
prefix = '[FILTER]: ',
always_show_folders = true,
},
tab = {
sync = {
open = false,
close = false,
ignore = {},
},
},
notify = {
threshold = vim.log.levels.INFO,
},
log = {
enable = false,
truncate = false,
types = {
all = false,
profile = false,
config = false,
copy_paste = false,
dev = false,
diagnostics = false,
git = false,
watcher = false,
web_devicons = false,
},
},
})
-- Keymaps
vim.keymap.set('n', '<leader>e', ':NvimTreeToggle<CR>', { desc = 'Toggle File Explorer' })
vim.keymap.set('n', '<leader>o', ':NvimTreeFocus<CR>', { desc = 'Focus File Explorer' })
vim.keymap.set('n', '<leader>E', ':NvimTreeFindFile<CR>', { desc = 'Find in File Explorer' })
end,
}nvim-tree Custom Functions
-- Auto-close when last window
vim.api.nvim_create_autocmd('QuitPre', {
callback = function()
local tree_wins = {}
local floating_wins = {}
local wins = vim.api.nvim_list_wins()
for _, w in ipairs(wins) do
local bufname = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(w))
if bufname:match('NvimTree_') ~= nil then
table.insert(tree_wins, w)
end
if vim.api.nvim_win_get_config(w).relative ~= '' then
table.insert(floating_wins, w)
end
end
if 1 == #wins - #floating_wins - #tree_wins then
for _, w in ipairs(tree_wins) do
vim.api.nvim_win_close(w, true)
end
end
end
})
-- Open nvim-tree on startup for directory
vim.api.nvim_create_autocmd('VimEnter', {
callback = function(data)
local directory = vim.fn.isdirectory(data.file) == 1
if directory then
vim.cmd.cd(data.file)
require('nvim-tree.api').tree.open()
end
end
})Neo-tree.nvim Alternative
{
'nvim-neo-tree/neo-tree.nvim',
branch = 'v3.x',
dependencies = {
'nvim-lua/plenary.nvim',
'nvim-tree/nvim-web-devicons',
'MunifTanjim/nui.nvim',
},
config = function()
require('neo-tree').setup({
close_if_last_window = true,
popup_border_style = 'rounded',
enable_git_status = true,
enable_diagnostics = true,
default_component_configs = {
container = {
enable_character_fade = true
},
indent = {
indent_size = 2,
padding = 1,
with_markers = true,
indent_marker = '│',
last_indent_marker = '└',
highlight = 'NeoTreeIndentMarker',
with_expanders = nil,
expander_collapsed = '',
expander_expanded = '',
expander_highlight = 'NeoTreeExpander',
},
icon = {
folder_closed = '',
folder_open = '',
folder_empty = '',
default = '*',
highlight = 'NeoTreeFileIcon'
},
modified = {
symbol = '[+]',
highlight = 'NeoTreeModified',
},
name = {
trailing_slash = false,
use_git_status_colors = true,
highlight = 'NeoTreeFileName',
},
git_status = {
symbols = {
added = '✚',
modified = '',
deleted = '✖',
renamed = '',
untracked = '',
ignored = '',
unstaged = '',
staged = '',
conflict = '',
}
},
},
window = {
position = 'left',
width = 30,
mapping_options = {
noremap = true,
nowait = true,
},
mappings = {
['<space>'] = {
'toggle_node',
nowait = false,
},
['<2-LeftMouse>'] = 'open',
['<cr>'] = 'open',
['<esc>'] = 'revert_preview',
['P'] = { 'toggle_preview', config = { use_float = true } },
['l'] = 'focus_preview',
['S'] = 'open_split',
['s'] = 'open_vsplit',
['t'] = 'open_tabnew',
['w'] = 'open_with_window_picker',
['C'] = 'close_node',
['z'] = 'close_all_nodes',
['a'] = {
'add',
config = {
show_path = 'none'
}
},
['A'] = 'add_directory',
['d'] = 'delete',
['r'] = 'rename',
['y'] = 'copy_to_clipboard',
['x'] = 'cut_to_clipboard',
['p'] = 'paste_from_clipboard',
['c'] = 'copy',
['m'] = 'move',
['q'] = 'close_window',
['R'] = 'refresh',
['?'] = 'show_help',
['<'] = 'prev_source',
['>'] = 'next_source',
}
},
filesystem = {
filtered_items = {
visible = false,
hide_dotfiles = false,
hide_gitignored = false,
hide_hidden = true,
hide_by_name = {
'node_modules'
},
hide_by_pattern = {
--'*.meta',
--'*/src/*/tsconfig.json',
},
always_show = {
'.gitignored',
},
never_show = {
'.DS_Store',
'thumbs.db'
},
never_show_by_pattern = {
--'.null-ls_*',
},
},
follow_current_file = {
enabled = true,
leave_dirs_open = false,
},
group_empty_dirs = false,
hijack_netrw_behavior = 'open_default',
use_libuv_file_watcher = false,
window = {
mappings = {
['<bs>'] = 'navigate_up',
['.'] = 'set_root',
['H'] = 'toggle_hidden',
['/'] = 'fuzzy_finder',
['D'] = 'fuzzy_finder_directory',
['#'] = 'fuzzy_sorter',
['f'] = 'filter_on_submit',
['<c-x>'] = 'clear_filter',
['[g'] = 'prev_git_modified',
[']g'] = 'next_git_modified',
}
},
commands = {}
},
buffers = {
follow_current_file = {
enabled = true,
leave_dirs_open = false,
},
group_empty_dirs = true,
show_unloaded = true,
window = {
mappings = {
['bd'] = 'buffer_delete',
['<bs>'] = 'navigate_up',
['.'] = 'set_root',
}
},
},
git_status = {
window = {
position = 'float',
mappings = {
['A'] = 'git_add_all',
['gu'] = 'git_unstage_file',
['ga'] = 'git_add_file',
['gr'] = 'git_revert_file',
['gc'] = 'git_commit',
['gp'] = 'git_push',
['gg'] = 'git_commit_and_push',
}
}
}
})
vim.keymap.set('n', '<leader>e', ':Neotree toggle<CR>', { desc = 'Toggle Neo-tree' })
vim.keymap.set('n', '<leader>o', ':Neotree focus<CR>', { desc = 'Focus Neo-tree' })
vim.keymap.set('n', '<leader>E', ':Neotree reveal<CR>', { desc = 'Reveal in Neo-tree' })
vim.keymap.set('n', '<leader>gb', ':Neotree float git_status<CR>', { desc = 'Git Status' })
vim.keymap.set('n', '<leader>bb', ':Neotree float buffers<CR>', { desc = 'Buffers' })
end,
}Oil.nvim - Edit Your Filesystem
{
'stevearc/oil.nvim',
dependencies = { 'nvim-tree/nvim-web-devicons' },
config = function()
require('oil').setup({
default_file_explorer = true,
columns = {
'icon',
'permissions',
'size',
'mtime',
},
buf_options = {
buflisted = false,
bufhidden = 'hide',
},
win_options = {
wrap = false,
signcolumn = 'no',
cursorcolumn = false,
foldcolumn = '0',
spell = false,
list = false,
conceallevel = 3,
concealcursor = 'nvic',
},
delete_to_trash = false,
skip_confirm_for_simple_edits = false,
prompt_save_on_select_new_entry = true,
cleanup_delay_ms = 2000,
lsp_file_methods = {
timeout_ms = 1000,
autosave_changes = false,
},
constrain_cursor = 'editable',
experimental_watch_for_changes = false,
keymaps = {
['g?'] = 'actions.show_help',
['<CR>'] = 'actions.select',
['<C-s>'] = 'actions.select_vsplit',
['<C-h>'] = 'actions.select_split',
['<C-t>'] = 'actions.select_tab',
['<C-p>'] = 'actions.preview',
['<C-c>'] = 'actions.close',
['<C-l>'] = 'actions.refresh',
['-'] = 'actions.parent',
['_'] = 'actions.open_cwd',
['`'] = 'actions.cd',
['~'] = 'actions.tcd',
['gs'] = 'actions.change_sort',
['gx'] = 'actions.open_external',
['g.'] = 'actions.toggle_hidden',
['g\\'] = 'actions.toggle_trash',
},
use_default_keymaps = true,
view_options = {
show_hidden = false,
is_hidden_file = function(name, bufnr)
return vim.startswith(name, '.')
end,
is_always_hidden = function(name, bufnr)
return false
end,
sort = {
{ 'type', 'asc' },
{ 'name', 'asc' },
},
},
float = {
padding = 2,
max_width = 0,
max_height = 0,
border = 'rounded',
win_options = {
winblend = 0,
},
override = function(conf)
return conf
end,
},
preview = {
max_width = 0.9,
min_width = { 40, 0.4 },
width = nil,
max_height = 0.9,
min_height = { 5, 0.1 },
height = nil,
border = 'rounded',
win_options = {
winblend = 0,
},
},
progress = {
max_width = 0.9,
min_width = { 40, 0.4 },
width = nil,
max_height = { 10, 0.9 },
min_height = { 5, 0.1 },
height = nil,
border = 'rounded',
minimized_border = 'none',
win_options = {
winblend = 0,
},
},
})
-- Keymaps
vim.keymap.set('n', '-', '<CMD>Oil<CR>', { desc = 'Open parent directory' })
vim.keymap.set('n', '<leader>-', require('oil').toggle_float, { desc = 'Oil Float' })
end,
}Harpoon - Quick File Navigation
{
'ThePrimeagen/harpoon',
branch = 'harpoon2',
dependencies = { 'nvim-lua/plenary.nvim' },
config = function()
local harpoon = require('harpoon')
harpoon:setup({})
-- Keymaps
vim.keymap.set('n', '<leader>a', function()
harpoon:list():append()
end, { desc = 'Harpoon Add' })
vim.keymap.set('n', '<C-e>', function()
harpoon.ui:toggle_quick_menu(harpoon:list())
end, { desc = 'Harpoon Menu' })
vim.keymap.set('n', '<C-h>', function()
harpoon:list():select(1)
end, { desc = 'Harpoon 1' })
vim.keymap.set('n', '<C-j>', function()
harpoon:list():select(2)
end, { desc = 'Harpoon 2' })
vim.keymap.set('n', '<C-k>', function()
harpoon:list():select(3)
end, { desc = 'Harpoon 3' })
vim.keymap.set('n', '<C-l>', function()
harpoon:list():select(4)
end, { desc = 'Harpoon 4' })
-- Toggle previous & next buffers stored within Harpoon list
vim.keymap.set('n', '<C-S-P>', function()
harpoon:list():prev()
end, { desc = 'Harpoon Prev' })
vim.keymap.set('n', '<C-S-N>', function()
harpoon:list():next()
end, { desc = 'Harpoon Next' })
-- Telescope integration
local conf = require('telescope.config').values
local function toggle_telescope(harpoon_files)
local file_paths = {}
for _, item in ipairs(harpoon_files.items) do
table.insert(file_paths, item.value)
end
require('telescope.pickers').new({}, {
prompt_title = 'Harpoon',
finder = require('telescope.finders').new_table({
results = file_paths,
}),
previewer = conf.file_previewer({}),
sorter = conf.generic_sorter({}),
}):find()
end
vim.keymap.set('n', '<leader>fh', function()
toggle_telescope(harpoon:list())
end, { desc = 'Harpoon Telescope' })
end,
}Arrow.nvim - Bookmark Files
{
'otavioschwanck/arrow.nvim',
opts = {
show_icons = true,
leader_key = ';',
buffer_leader_key = 'm',
},
keys = {
{ ';', desc = 'Arrow' },
{ 'm', desc = 'Arrow Buffer' },
},
}Advanced Navigation Patterns
Jump to Definition Across Projects
-- Cross-project definition jump
vim.keymap.set('n', '<leader>gD', function()
local word = vim.fn.expand('<cword>')
builtin.live_grep({
prompt_title = 'Find Definition: ' .. word,
default_text = 'def ' .. word,
search_dirs = { '~/projects' },
})
end, { desc = 'Global Definition' })Recent Files with Filter
-- Recent files from current project
vim.keymap.set('n', '<leader>fR', function()
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
if vim.v.shell_error == 0 then
builtin.oldfiles({
cwd = git_root,
cwd_only = true,
})
else
builtin.oldfiles()
end
end, { desc = 'Recent Project Files' })Smart Buffer Navigation
-- Navigate buffers by directory
vim.keymap.set('n', '<leader>bD', function()
local cwd = vim.fn.getcwd()
local buffers = vim.tbl_filter(function(buf)
local bufpath = vim.api.nvim_buf_get_name(buf)
return vim.startswith(bufpath, cwd)
end, vim.api.nvim_list_bufs())
local buffer_names = vim.tbl_map(function(buf)
return vim.api.nvim_buf_get_name(buf)
end, buffers)
builtin.buffers({
bufnrs = buffers,
})
end, { desc = 'Directory Buffers' })Summary: Chapter #33 – File Navigation and Fuzzy Finders
This chapter covered comprehensive file navigation solutions:
Built-in Navigation – Netrw, path settings, and buffer management
Telescope.nvim – Modern fuzzy finder with extensive features
Telescope Extensions – File browser, projects, frecency
FZF.vim – Classic fuzzy finder alternative
nvim-tree.lua – Feature-rich file explorer
Neo-tree – Modern file explorer with multiple views
Oil.nvim – Edit filesystem like a buffer
Harpoon – Quick file marking and navigation
Arrow.nvim – Persistent bookmarks
Advanced Patterns – Custom workflows and optimizations
These tools provide powerful ways to navigate files, projects, and code, making Neovim an incredibly efficient development environment.
Chapter #34: Snippets and Templates
Snippets are reusable code templates that dramatically speed up development by expanding short triggers into complete code structures. This chapter explores snippet engines, creation, and management in Neovim.
Understanding Snippets
What Are Snippets?
Snippets are expandable text templates with:
Trigger: Short text that expands (e.g.,
for→ full for-loop)Placeholders: Tabstops for cursor jumping
Variables: Dynamic content (filename, date, etc.)
Transformations: Text manipulation during expansion
Snippet Engines
Popular snippet engines for Neovim:
LuaSnip - Lua-based, most flexible
vim-vsnip - VSCode-compatible
UltiSnips - Legacy Python-based (Vim)
snippy.nvim - Minimal Lua engine
LuaSnip - Modern Snippet Engine
Installation and Setup
{
'L3MON4D3/LuaSnip',
version = 'v2.*',
build = 'make install_jsregexp',
dependencies = {
'rafamadriz/friendly-snippets', -- Pre-made snippets
'saadparwaiz1/cmp_luasnip', -- nvim-cmp integration
},
config = function()
local luasnip = require('luasnip')
-- Configuration
luasnip.setup({
history = true, -- Keep history for jumping back
update_events = 'TextChanged,TextChangedI', -- Update as you type
delete_check_events = 'TextChanged',
enable_autosnippets = true, -- Enable auto-triggered snippets
store_selection_keys = '<Tab>', -- Visual selection key
ext_opts = {
[require('luasnip.util.types').choiceNode] = {
active = {
virt_text = { { '●', 'GruvboxOrange' } },
},
},
},
})
-- Load friendly-snippets
require('luasnip.loaders.from_vscode').lazy_load()
-- Load custom snippets from ~/.config/nvim/snippets/
require('luasnip.loaders.from_lua').load({
paths = vim.fn.stdpath('config') .. '/snippets'
})
-- Load snippets from specific file
require('luasnip.loaders.from_snipmate').lazy_load({
paths = vim.fn.stdpath('config') .. '/snippets'
})
end,
}Basic Keymaps
local luasnip = require('luasnip')
-- Expand or jump forward
vim.keymap.set({'i', 's'}, '<C-k>', function()
if luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
end
end, { silent = true, desc = 'Expand or Jump' })
-- Jump backward
vim.keymap.set({'i', 's'}, '<C-j>', function()
if luasnip.jumpable(-1) then
luasnip.jump(-1)
end
end, { silent = true, desc = 'Jump Back' })
-- Choice node selection
vim.keymap.set({'i', 's'}, '<C-l>', function()
if luasnip.choice_active() then
luasnip.change_choice(1)
end
end, { desc = 'Next Choice' })
vim.keymap.set({'i', 's'}, '<C-h>', function()
if luasnip.choice_active() then
luasnip.change_choice(-1)
end
end, { desc = 'Previous Choice' })
-- Reload snippets
vim.keymap.set('n', '<leader>rs', function()
require('luasnip.loaders.from_lua').load({
paths = vim.fn.stdpath('config') .. '/snippets'
})
print('Snippets reloaded!')
end, { desc = 'Reload Snippets' })Creating Snippets in Lua
Create ~/.config/nvim/snippets/all.lua:
local ls = require('luasnip')
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local f = ls.function_node
local c = ls.choice_node
local d = ls.dynamic_node
local r = ls.restore_node
local sn = ls.snippet_node
local fmt = require('luasnip.extras.fmt').fmt
local rep = require('luasnip.extras').rep
return {
-- Simple text insertion
s('hello', {
t('Hello, '),
i(1, 'World'),
t('!'),
}),
-- Using fmt for better formatting
s('todo', fmt([[
-- TODO({}): {}
{}
]], {
i(1, 'username'),
i(2, 'description'),
i(0),
})),
-- Repeat node
s('dup', fmt([[
local {} = {}
print({})
]], {
i(1, 'var'),
i(2, 'value'),
rep(1), -- Repeats first insert node
})),
-- Choice node
s('log', fmt([[
console.{}('{}')
]], {
c(1, {
t('log'),
t('warn'),
t('error'),
t('info'),
}),
i(2, 'message'),
})),
-- Function node (dynamic content)
s('date', {
f(function()
return os.date('%Y-%m-%d')
end),
}),
-- Current filename
s('fn', {
f(function()
return vim.fn.expand('%:t')
end),
}),
-- Dynamic node
s('dyn', {
t('Value: '),
i(1),
t({ '', 'Repeated: ' }),
d(2, function(args)
-- args[1] contains text from i(1)
return sn(nil, {
t(args[1][1]:upper()),
})
end, {1}),
}),
}Language-Specific Snippets
Create ~/.config/nvim/snippets/lua.lua:
local ls = require('luasnip')
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local fmt = require('luasnip.extras.fmt').fmt
return {
-- Function snippet
s('fn', fmt([[
function {}({})
{}
end
]], {
i(1, 'name'),
i(2, 'args'),
i(0),
})),
-- Local function
s('lfn', fmt([[
local function {}({})
{}
end
]], {
i(1, 'name'),
i(2, 'args'),
i(0),
})),
-- For loop
s('for', fmt([[
for {} = {}, {} do
{}
end
]], {
i(1, 'i'),
i(2, '1'),
i(3, '10'),
i(0),
})),
-- Pairs iterator
s('forp', fmt([[
for {}, {} in pairs({}) do
{}
end
]], {
i(1, 'k'),
i(2, 'v'),
i(3, 'table'),
i(0),
})),
-- Ipairs iterator
s('fori', fmt([[
for {}, {} in ipairs({}) do
{}
end
]], {
i(1, 'i'),
i(2, 'v'),
i(3, 'table'),
i(0),
})),
-- Require statement
s('req', fmt([[
local {} = require('{}')
]], {
i(1, 'module'),
i(2, 'module.path'),
})),
-- If statement
s('if', fmt([[
if {} then
{}
end
]], {
i(1, 'condition'),
i(0),
})),
-- If-else
s('ife', fmt([[
if {} then
{}
else
{}
end
]], {
i(1, 'condition'),
i(2),
i(0),
})),
}Create ~/.config/nvim/snippets/python.lua:
local ls = require('luasnip')
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local c = ls.choice_node
local fmt = require('luasnip.extras.fmt').fmt
local rep = require('luasnip.extras').rep
return {
-- Function
s('def', fmt([[
def {}({}):
{}
{}
]], {
i(1, 'function_name'),
i(2, 'args'),
i(3, '"""Docstring."""'),
i(0),
})),
-- Class
s('class', fmt([[
class {}:
"""{}"""
def __init__(self, {}):
{}
]], {
i(1, 'ClassName'),
i(2, 'Class description'),
i(3, 'args'),
i(0),
})),
-- If main
s('ifmain', fmt([[
if __name__ == '__main__':
{}
]], {
i(0),
})),
-- Try-except
s('try', fmt([[
try:
{}
except {} as {}:
{}
]], {
i(1),
i(2, 'Exception'),
i(3, 'e'),
i(0),
})),
-- List comprehension
s('lc', fmt([[
[{} for {} in {}]
]], {
i(1, 'item'),
rep(1),
i(2, 'iterable'),
})),
-- Dict comprehension
s('dc', fmt([[
{{{}: {} for {} in {}}}
]], {
i(1, 'key'),
i(2, 'value'),
i(3, 'item'),
i(4, 'iterable'),
})),
-- Dataclass
s('dc', fmt([[
@dataclass
class {}:
{}
]], {
i(1, 'ClassName'),
i(0),
})),
-- Property
s('prop', fmt([[
@property
def {}(self):
return self._{}
@{}.setter
def {}(self, value):
self._{} = value
]], {
i(1, 'name'),
rep(1),
rep(1),
rep(1),
rep(1),
})),
}Advanced Snippet Techniques
Context-Aware Snippets
-- Only expand in specific contexts
local function in_comment()
return vim.fn.synIDattr(vim.fn.synID(vim.fn.line('.'), vim.fn.col('.'), true), 'name'):match('Comment') ~= nil
end
local function in_string()
return vim.fn.synIDattr(vim.fn.synID(vim.fn.line('.'), vim.fn.col('.'), true), 'name'):match('String') ~= nil
end
return {
-- Only in comments
s({
trig = 'todo',
condition = in_comment,
}, fmt([[
TODO({}): {}
]], {
i(1, vim.fn.expand('$USER')),
i(0),
})),
-- Not in strings
s({
trig = 'fn',
condition = function()
return not in_string()
end,
}, fmt([[
function {}({})
{}
end
]], {
i(1),
i(2),
i(0),
})),
}Regex Triggers
return {
-- Regex trigger: bb1, bb2, etc.
s({
trig = 'bb(%d)',
regTrig = true,
}, {
f(function(args, snip)
return 'Blackboard bold: ℝ' .. snip.captures[1]
end),
}),
-- Auto-expand arithmetic
s({
trig = '(%d+)%+(%d+)',
regTrig = true,
wordTrig = false,
}, {
f(function(args, snip)
return tonumber(snip.captures[1]) + tonumber(snip.captures[2])
end),
}),
}Multi-line Snippets with Proper Indentation
return {
s('switch', {
t('switch ('), i(1, 'expression'), t(') {'),
t({'', '\tcase '}), i(2, 'value'), t(':'),
t({'', '\t\t'}), i(3), t({'', '\t\tbreak;'}),
t({'', '\tdefault:'}),
t({'', '\t\t'}), i(0), t({'', '\t\tbreak;'}),
t({'', '}'}),
}),
}Snippet with User Input
local function get_comment_string()
local cs = vim.bo.commentstring
if cs == '' then cs = '# %s' end
return cs:format('%s'):gsub('%%s', '')
end
return {
s('box', {
f(function()
local comment = get_comment_string()
return comment .. string.rep('=', 70)
end),
t({'', ''}),
f(function()
return get_comment_string()
end),
t(' '), i(1, 'Title'), t(' '),
f(function()
return string.rep('=', 70 - #get_comment_string())
end),
t({'', ''}),
f(function()
return get_comment_string() .. string.rep('=', 70)
end),
}),
}nvim-cmp Integration
Setup with LuaSnip
{
'hrsh7th/nvim-cmp',
dependencies = {
'L3MON4D3/LuaSnip',
'saadparwaiz1/cmp_luasnip',
},
config = function()
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({
['<C-b>'] = cmp.mapping.scroll_docs(-4),
['<C-f>'] = cmp.mapping.scroll_docs(4),
['<C-Space>'] = cmp.mapping.complete(),
['<C-e>'] = cmp.mapping.abort(),
['<CR>'] = cmp.mapping.confirm({ select = true }),
-- Tab completion
['<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' },
{ name = 'luasnip' },
{ name = 'buffer' },
{ name = 'path' },
}),
formatting = {
format = function(entry, vim_item)
-- Kind icons
local icons = {
Text = '',
Method = '',
Function = '',
Constructor = '',
Snippet = '',
}
vim_item.kind = string.format('%s %s', icons[vim_item.kind], vim_item.kind)
vim_item.menu = ({
nvim_lsp = '[LSP]',
luasnip = '[Snip]',
buffer = '[Buf]',
path = '[Path]',
})[entry.source.name]
return vim_item
end,
},
})
end,
}VSCode-Style Snippets
Using friendly-snippets
-- Load all VSCode snippets
require('luasnip.loaders.from_vscode').lazy_load()
-- Load from specific paths
require('luasnip.loaders.from_vscode').lazy_load({
paths = { '~/.config/nvim/snippets/vscode' }
})
-- Exclude certain snippet packs
require('luasnip.loaders.from_vscode').lazy_load({
exclude = { 'javascript', 'typescript' }
})Creating VSCode-Style Snippets
Create
~/.config/nvim/snippets/vscode/package.json:
{
"name": "custom-snippets",
"engines": {
"vscode": "^1.0.0"
},
"contributes": {
"snippets": [
{
"language": "javascript",
"path": "./javascript.json"
},
{
"language": "python",
"path": "./python.json"
}
]
}
}Create
~/.config/nvim/snippets/vscode/javascript.json:
{
"Import Statement": {
"prefix": "imp",
"body": [
"import ${2:module} from '${1:package}';"
],
"description": "Import a module"
},
"Arrow Function": {
"prefix": "af",
"body": [
"const ${1:name} = (${2:params}) => {",
"\t$0",
"};"
],
"description": "Arrow function"
},
"React Component": {
"prefix": "rfc",
"body": [
"import React from 'react';",
"",
"const ${1:ComponentName} = () => {",
"\treturn (",
"\t\t<div>",
"\t\t\t$0",
"\t\t</div>",
"\t);",
"};",
"",
"export default ${1:ComponentName};"
],
"description": "React functional component"
},
"Console Log": {
"prefix": ["cl", "log"],
"body": [
"console.log('${1:label}:', $2);"
],
"description": "Console log with label"
},
"Try-Catch": {
"prefix": "tryc",
"body": [
"try {",
"\t$1",
"} catch (${2:error}) {",
"\t${3:console.error(error);}",
"}"
],
"description": "Try-catch block"
}
}Auto-Snippets
Self-Expanding Snippets
local ls = require('luasnip')
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local fmt = require('luasnip.extras.fmt').fmt
return {
-- Auto-expand when typing
s({
trig = '->',
wordTrig = false,
snippetType = 'autosnippet',
}, {
t(' => '),
}),
-- Auto-brackets
s({
trig = '(%w+)%(%)$',
regTrig = true,
snippetType = 'autosnippet',
}, {
f(function(args, snip)
return snip.captures[1] .. '('
end),
i(1),
t(')'),
}),
-- Math mode auto-snippets (LaTeX)
s({
trig = 'ff',
snippetType = 'autosnippet',
condition = function()
return vim.bo.filetype == 'tex'
end,
}, fmt([[
\frac{{{}}}{{{}}}
]], {
i(1),
i(2),
})),
}Snippet Management Commands
Custom Commands
-- List all available snippets for current filetype
vim.api.nvim_create_user_command('SnippetsList', function()
local ft = vim.bo.filetype
local snippets = require('luasnip').get_snippets(ft)
local lines = {}
for _, snip in ipairs(snippets or {}) do
table.insert(lines, string.format('%-20s %s', snip.trigger, snip.name or ''))
end
vim.api.nvim_echo({{table.concat(lines, '\n'), 'Normal'}}, false, {})
end, {})
-- Edit snippets for current filetype
vim.api.nvim_create_user_command('SnippetsEdit', function()
local ft = vim.bo.filetype
local snippet_file = vim.fn.stdpath('config') .. '/snippets/' .. ft .. '.lua'
-- Create directory if it doesn't exist
vim.fn.mkdir(vim.fn.fnamemodify(snippet_file, ':h'), 'p')
-- Create file with template if it doesn't exist
if vim.fn.filereadable(snippet_file) == 0 then
local template = {
'local ls = require("luasnip")',
'local s = ls.snippet',
'local t = ls.text_node',
'local i = ls.insert_node',
'local fmt = require("luasnip.extras.fmt").fmt',
'',
'return {',
'\t-- Your snippets here',
'}',
}
vim.fn.writefile(template, snippet_file)
end
vim.cmd('edit ' .. snippet_file)
end, {})
vim.keymap.set('n', '<leader>se', ':SnippetsEdit<CR>', { desc = 'Edit Snippets' })
vim.keymap.set('n', '<leader>sl', ':SnippetsList<CR>', { desc = 'List Snippets' })vim-vsnip Alternative
Setup
{
'hrsh7th/vim-vsnip',
dependencies = {
'hrsh7th/vim-vsnip-integ',
'rafamadriz/friendly-snippets',
},
config = function()
vim.g.vsnip_snippet_dir = vim.fn.stdpath('config') .. '/snippets/vsnip'
-- Keymaps
vim.keymap.set({'i', 's'}, '<C-k>', function()
if vim.fn['vsnip#available'](1) == 1 then
return '<Plug>(vsnip-expand-or-jump)'
end
return '<C-k>'
end, { expr = true, remap = true })
vim.keymap.set({'i', 's'}, '<C-j>', function()
if vim.fn['vsnip#jumpable'](-1) == 1 then
return '<Plug>(vsnip-jump-prev)'
end
return '<C-j>'
end, { expr = true, remap = true })
end,
}Templates and Skeletons
Auto-Templates on New Files
-- Create template directory
local template_dir = vim.fn.stdpath('config') .. '/templates'
vim.fn.mkdir(template_dir, 'p')
-- Auto-load template on new file
vim.api.nvim_create_autocmd('BufNewFile', {
pattern = '*',
callback = function()
local ext = vim.fn.expand('%:e')
local template = template_dir .. '/' .. ext .. '.template'
if vim.fn.filereadable(template) == 1 then
vim.cmd('0read ' .. template)
-- Replace placeholders
local replacements = {
['{{FILENAME}}'] = vim.fn.expand('%:t:r'),
['{{DATE}}'] = os.date('%Y-%m-%d'),
['{{YEAR}}'] = os.date('%Y'),
['{{AUTHOR}}'] = vim.fn.expand('$USER'),
}
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
for i, line in ipairs(lines) do
for placeholder, value in pairs(replacements) do
line = line:gsub(placeholder, value)
end
lines[i] = line
end
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
-- Delete empty first line
if vim.fn.getline(1) == '' then
vim.cmd('1delete')
end
end
end,
})Example Templates
~/.config/nvim/templates/py.template:
#!/usr/bin/env python3
"""
{{FILENAME}}.py
Author: {{AUTHOR}}
Date: {{DATE}}
"""
def main():
pass
if __name__ == '__main__':
main()~/.config/nvim/templates/lua.template:
--[[
{{FILENAME}}.lua
Author: {{AUTHOR}}
Date: {{DATE}}
--]]
local M = {}
return M~/.config/nvim/templates/sh.template:
#!/usr/bin/env bash
#
# {{FILENAME}}.sh
# Author: {{AUTHOR}}
# Date: {{DATE}}
#
set -euo pipefail
main() {
:
}
main "$@"Interactive Template Selection
vim.api.nvim_create_user_command('Template', function(opts)
local template_dir = vim.fn.stdpath('config') .. '/templates'
local templates = vim.fn.globpath(template_dir, '*.template', false, true)
if #templates == 0 then
print('No templates found in ' .. template_dir)
return
end
vim.ui.select(templates, {
prompt = 'Select template:',
format_item = function(item)
return vim.fn.fnamemodify(item, ':t:r')
end,
}, function(choice)
if choice then
vim.cmd('0read ' .. choice)
vim.cmd('1delete')
end
end)
end, {})
vim.keymap.set('n', '<leader>tp', ':Template<CR>', { desc = 'Insert Template' })Snippet Library Organization
Recommended Structure
~/.config/nvim/snippets/
├│── all.lua # Global snippets
├│── lua.lua # Lua-specific
├│── python.lua # Python-specific
├│── javascript.lua # JavaScript-specific
├│── typescript.lua # TypeScript-specific
├│── rust.lua # Rust-specific
├│── go.lua # Go-specific └── vscode/ # VSCode-style snippets ├── package.json ├── javascript.json └── python.json
Snippet Categories
Organize by category:
-- ~/.config/nvim/snippets/python.lua
local ls = require('luasnip')
local s = ls.snippet
local fmt = require('luasnip.extras.fmt').fmt
local i = ls.insert_node
local snippets = {
-- Control flow
control = {
s('if', fmt('if {}:\n {}', { i(1), i(0) })),
s('for', fmt('for {} in {}:\n {}', { i(1), i(2), i(0) })),
},
-- Functions
functions = {
s('def', fmt('def {}({}):\n {}', { i(1), i(2), i(0) })),
s('async', fmt('async def {}({}):\n {}', { i(1), i(2), i(0) })),
},
-- Testing
testing = {
s('test', fmt('def test_{}():\n {}', { i(1), i(0) })),
s('assert', fmt('assert {} == {}', { i(1), i(2) })),
},
}
-- Flatten categories
local all_snippets = {}
for _, category in pairs(snippets) do
for _, snip in ipairs(category) do
table.insert(all_snippets, snip)
end
end
return all_snippetsSummary: Chapter #34 – Snippets and Templates
This chapter covered comprehensive snippet management:
LuaSnip – Modern, flexible Lua-based snippet engine
Snippet Creation – Simple to advanced snippet patterns
Language-Specific – Targeted snippets for different languages
Advanced Techniques – Context-aware, regex triggers, dynamic content
nvim-cmp Integration – Seamless completion workflow
VSCode Snippets – Compatible with existing snippet libraries
Auto-Snippets – Self-expanding snippets
Templates – File templates and skeletons
Organization – Best practices for snippet management
Mastering snippets dramatically improves coding speed and consistency, making them an essential tool in modern development workflows.
Chapter #35: Terminal Integration
Neovim’s built-in terminal emulator provides seamless integration between your editor and shell commands, enabling a unified workflow without leaving your editing environment.
Understanding Neovim’s Terminal
Terminal Basics
Neovim’s terminal is a fully-featured terminal emulator built into the editor:
True Terminal: Not just command execution—full terminal emulation
Buffer-Based: Terminals are regular buffers with special properties
Job Control: Asynchronous process management
Multiple Modes: Normal, Insert, and Terminal modes
Opening Terminals
-- Horizontal split terminal
vim.keymap.set('n', '<leader>th', ':split | terminal<CR>',
{ desc = 'Terminal Horizontal' })
-- Vertical split terminal
vim.keymap.set('n', '<leader>tv', ':vsplit | terminal<CR>',
{ desc = 'Terminal Vertical' })
-- Full window terminal
vim.keymap.set('n', '<leader>tt', ':terminal<CR>',
{ desc = 'Terminal' })
-- New tab terminal
vim.keymap.set('n', '<leader>tT', ':tabnew | terminal<CR>',
{ desc = 'Terminal Tab' })
-- Terminal with specific command
vim.keymap.set('n', '<leader>tg', ':terminal lazygit<CR>',
{ desc = 'LazyGit' })
-- Terminal with size
vim.keymap.set('n', '<leader>tb', ':15split | terminal<CR>',
{ desc = 'Terminal Bottom' })Terminal Mode Navigation
-- Exit terminal mode
vim.keymap.set('t', '<Esc><Esc>', '<C-\\><C-n>',
{ desc = 'Exit Terminal Mode' })
-- Alternative exit (more convenient)
vim.keymap.set('t', '<C-x>', '<C-\\><C-n>',
{ desc = 'Exit Terminal Mode' })
-- Window navigation from terminal
vim.keymap.set('t', '<C-h>', '<C-\\><C-n><C-w>h',
{ desc = 'Go to Left Window' })
vim.keymap.set('t', '<C-j>', '<C-\\><C-n><C-w>j',
{ desc = 'Go to Down Window' })
vim.keymap.set('t', '<C-k>', '<C-\\><C-n><C-w>k',
{ desc = 'Go to Up Window' })
vim.keymap.set('t', '<C-l>', '<C-\\><C-n><C-w>l',
{ desc = 'Go to Right Window' })
-- Send Ctrl-c to terminal (not exit)
vim.keymap.set('t', '<C-c><C-c>', '<C-c>',
{ desc = 'Send Ctrl-C' })
-- Paste from register
vim.keymap.set('t', '<C-r>', [['<C-\><C-N>"' . nr2char(getchar()) . 'pi']],
{ expr = true, desc = 'Paste Register' })Advanced Terminal Configuration
Smart Terminal Setup
-- Terminal settings
vim.api.nvim_create_autocmd('TermOpen', {
group = vim.api.nvim_create_augroup('custom-term-open', { clear = true }),
callback = function()
-- Disable line numbers in terminal
vim.opt_local.number = false
vim.opt_local.relativenumber = false
-- Disable sign column
vim.opt_local.signcolumn = 'no'
-- Start in insert mode
vim.cmd('startinsert')
-- Set buffer options
vim.opt_local.buflisted = false
vim.opt_local.filetype = 'terminal'
-- Custom buffer-local keymaps
local opts = { buffer = 0 }
vim.keymap.set('t', '<Esc>', '<C-\\><C-n>', opts)
vim.keymap.set('t', '<C-v><Esc>', '<Esc>', opts)
end,
})
-- Auto-close terminal when process exits
vim.api.nvim_create_autocmd('TermClose', {
callback = function()
-- Only close if exit status is 0
if vim.v.event.status == 0 then
vim.cmd('bdelete!')
end
end,
})Terminal Window Management
-- Toggle terminal function
local term_buf = nil
local term_win = nil
local function toggle_terminal()
-- Check if terminal buffer exists and is valid
if term_buf and vim.api.nvim_buf_is_valid(term_buf) then
-- Check if terminal window is visible
if term_win and vim.api.nvim_win_is_valid(term_win) then
-- Hide terminal
vim.api.nvim_win_hide(term_win)
term_win = nil
else
-- Show terminal in split
vim.cmd('botright 15split')
term_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(term_win, term_buf)
vim.cmd('startinsert')
end
else
-- Create new terminal
vim.cmd('botright 15split')
vim.cmd('terminal')
term_buf = vim.api.nvim_get_current_buf()
term_win = vim.api.nvim_get_current_win()
-- Set local options
vim.api.nvim_buf_set_option(term_buf, 'buflisted', false)
end
end
vim.keymap.set({'n', 't'}, '<C-\\>', toggle_terminal,
{ desc = 'Toggle Terminal' })Floating Terminal
local function create_floating_terminal(cmd)
cmd = cmd or vim.o.shell
-- Get editor dimensions
local width = vim.o.columns
local height = vim.o.lines
-- Calculate floating window size
local win_height = math.ceil(height * 0.8)
local win_width = math.ceil(width * 0.8)
-- Calculate starting position
local row = math.ceil((height - win_height) / 2)
local col = math.ceil((width - win_width) / 2)
-- Create buffer
local buf = vim.api.nvim_create_buf(false, true)
-- Define window configuration
local opts = {
relative = 'editor',
width = win_width,
height = win_height,
row = row,
col = col,
style = 'minimal',
border = 'rounded',
}
-- Create window
local win = vim.api.nvim_open_win(buf, true, opts)
-- Start terminal
vim.fn.termopen(cmd, {
on_exit = function()
vim.api.nvim_buf_delete(buf, { force = true })
end,
})
-- Enter insert mode
vim.cmd('startinsert')
-- Close on <Esc><Esc>
vim.keymap.set('t', '<Esc><Esc>', '<C-\\><C-n>:q<CR>',
{ buffer = buf, silent = true })
end
vim.keymap.set('n', '<leader>tf', function()
create_floating_terminal()
end, { desc = 'Floating Terminal' })
vim.keymap.set('n', '<leader>tg', function()
create_floating_terminal('lazygit')
end, { desc = 'LazyGit Floating' })Running Commands
Execute and Display Output
-- Run command and show in split
vim.keymap.set('n', '<leader>r', function()
vim.ui.input({ prompt = 'Command: ' }, function(input)
if input then
vim.cmd('split | terminal ' .. input)
end
end)
end, { desc = 'Run Command' })
-- Run current file
vim.keymap.set('n', '<leader>x', function()
local filetype = vim.bo.filetype
local filename = vim.fn.expand('%')
local runners = {
python = 'python3 ' .. filename,
lua = 'lua ' .. filename,
javascript = 'node ' .. filename,
typescript = 'ts-node ' .. filename,
go = 'go run ' .. filename,
rust = 'cargo run',
sh = 'bash ' .. filename,
}
local cmd = runners[filetype]
if cmd then
vim.cmd('split | resize 15 | terminal ' .. cmd)
else
print('No runner configured for ' .. filetype)
end
end, { desc = 'Execute File' })Send Text to Terminal
-- Function to send text to terminal
local function send_to_term(text)
-- Find terminal buffer
local term_bufs = vim.tbl_filter(function(buf)
return vim.bo[buf].buftype == 'terminal'
end, vim.api.nvim_list_bufs())
if #term_bufs == 0 then
print('No terminal buffer found')
return
end
-- Use most recent terminal
local term_buf = term_bufs[#term_bufs]
-- Get terminal channel
local chan = vim.bo[term_buf].channel
if chan then
-- Send text
vim.fn.chansend(chan, text .. '\n')
end
end
-- Send current line
vim.keymap.set('n', '<leader>ts', function()
local line = vim.api.nvim_get_current_line()
send_to_term(line)
end, { desc = 'Send Line to Terminal' })
-- Send visual selection
vim.keymap.set('v', '<leader>ts', function()
local start_line = vim.fn.line('v')
local end_line = vim.fn.line('.')
-- Get lines in correct order
if start_line > end_line then
start_line, end_line = end_line, start_line
end
local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
send_to_term(table.concat(lines, '\n'))
end, { desc = 'Send Selection to Terminal' })
-- Send entire buffer
vim.keymap.set('n', '<leader>tS', function()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
send_to_term(table.concat(lines, '\n'))
end, { desc = 'Send Buffer to Terminal' })REPL Integration
Interactive REPL Setup
local M = {}
-- REPL configurations
M.repls = {
python = 'python3',
lua = 'lua',
node = 'node',
ruby = 'irb',
r = 'R',
julia = 'julia',
}
-- Track REPL buffers per filetype
M.repl_buffers = {}
function M.start_repl()
local ft = vim.bo.filetype
local cmd = M.repls[ft]
if not cmd then
print('No REPL configured for ' .. ft)
return
end
-- Check if REPL already exists
if M.repl_buffers[ft] and vim.api.nvim_buf_is_valid(M.repl_buffers[ft]) then
-- Focus existing REPL
local wins = vim.fn.win_findbuf(M.repl_buffers[ft])
if #wins > 0 then
vim.api.nvim_set_current_win(wins[1])
else
vim.cmd('botright 15split')
vim.api.nvim_win_set_buf(0, M.repl_buffers[ft])
end
vim.cmd('startinsert')
return
end
-- Create new REPL
vim.cmd('botright 15split')
vim.cmd('terminal ' .. cmd)
M.repl_buffers[ft] = vim.api.nvim_get_current_buf()
-- Mark as REPL buffer
vim.b.is_repl = true
end
function M.send_to_repl(text)
local ft = vim.bo.filetype
local repl_buf = M.repl_buffers[ft]
if not repl_buf or not vim.api.nvim_buf_is_valid(repl_buf) then
print('No REPL running for ' .. ft)
return
end
local chan = vim.bo[repl_buf].channel
if chan then
vim.fn.chansend(chan, text .. '\n')
end
end
-- Keymaps
vim.keymap.set('n', '<leader>rs', M.start_repl, { desc = 'Start REPL' })
vim.keymap.set('n', '<leader>rl', function()
local line = vim.api.nvim_get_current_line()
M.send_to_repl(line)
end, { desc = 'Send Line to REPL' })
vim.keymap.set('v', '<leader>rs', function()
local start_line = vim.fn.line('v')
local end_line = vim.fn.line('.')
if start_line > end_line then
start_line, end_line = end_line, start_line
end
local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
M.send_to_repl(table.concat(lines, '\n'))
end, { desc = 'Send Selection to REPL' })
return MTerminal Buffer Management
Named Terminals
local terminals = {}
local function create_named_terminal(name)
name = name or 'default'
-- Check if terminal exists
if terminals[name] and vim.api.nvim_buf_is_valid(terminals[name]) then
-- Switch to existing terminal
local wins = vim.fn.win_findbuf(terminals[name])
if #wins > 0 then
vim.api.nvim_set_current_win(wins[1])
else
vim.cmd('botright 15split')
vim.api.nvim_win_set_buf(0, terminals[name])
end
vim.cmd('startinsert')
else
-- Create new terminal
vim.cmd('botright 15split')
vim.cmd('terminal')
terminals[name] = vim.api.nvim_get_current_buf()
-- Set buffer name
vim.api.nvim_buf_set_name(terminals[name], 'term://' .. name)
end
end
vim.api.nvim_create_user_command('Term', function(opts)
create_named_terminal(opts.args)
end, { nargs = '?' })
-- Quick access to named terminals
vim.keymap.set('n', '<leader>t1', function()
create_named_terminal('main')
end, { desc = 'Terminal: main' })
vim.keymap.set('n', '<leader>t2', function()
create_named_terminal('test')
end, { desc = 'Terminal: test' })
vim.keymap.set('n', '<leader>t3', function()
create_named_terminal('dev')
end, { desc = 'Terminal: dev' })Terminal List and Selection
local function list_terminals()
local terms = {}
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[buf].buftype == 'terminal' then
local name = vim.api.nvim_buf_get_name(buf)
table.insert(terms, {
buf = buf,
name = name,
display = name:match('term://(.+)') or name,
})
end
end
return terms
end
vim.keymap.set('n', '<leader>tl', function()
local terms = list_terminals()
if #terms == 0 then
print('No terminal buffers')
return
end
vim.ui.select(terms, {
prompt = 'Select terminal:',
format_item = function(item)
return item.display
end,
}, function(choice)
if choice then
vim.cmd('buffer ' .. choice.buf)
vim.cmd('startinsert')
end
end)
end, { desc = 'List Terminals' })Job Control
Background Jobs
-- Run command in background
local function run_background_job(cmd, on_complete)
local output = {}
vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
vim.list_extend(output, data)
end
end,
on_stderr = function(_, data)
if data then
vim.list_extend(output, data)
end
end,
on_exit = function(_, exit_code)
if on_complete then
on_complete(output, exit_code)
end
end,
stdout_buffered = true,
stderr_buffered = true,
})
end
-- Example: Run tests in background
vim.keymap.set('n', '<leader>mt', function()
print('Running tests...')
run_background_job('npm test', function(output, exit_code)
if exit_code == 0 then
print('Tests passed! ✓')
else
print('Tests failed! ✗')
-- Show output in quickfix
vim.fn.setqflist({}, 'r', {
title = 'Test Results',
lines = output,
})
vim.cmd('copen')
end
end)
end, { desc = 'Run Tests' })Interactive Job Control
local function run_with_progress(cmd)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile')
vim.api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
-- Open in split
vim.cmd('botright 15split')
vim.api.nvim_win_set_buf(0, buf)
local job_id = vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
vim.api.nvim_buf_set_lines(buf, -1, -1, false, data)
end
end,
on_stderr = function(_, data)
if data then
vim.api.nvim_buf_set_lines(buf, -1, -1, false, data)
end
end,
on_exit = function(_, exit_code)
local msg = exit_code == 0 and 'Success!' or 'Failed!'
vim.api.nvim_buf_set_lines(buf, -1, -1, false, {
'',
'----------------------------------------',
msg,
})
-- Auto-close on success after delay
if exit_code == 0 then
vim.defer_fn(function()
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_delete(buf, { force = true })
end
end, 2000)
end
end,
})
-- Allow canceling job
vim.keymap.set('n', 'q', function()
vim.fn.jobstop(job_id)
vim.cmd('bdelete!')
end, { buffer = buf, desc = 'Cancel Job' })
end
vim.keymap.set('n', '<leader>mb', function()
run_with_progress('npm run build')
end, { desc = 'Run Build' })Terminal Plugins
toggleterm.nvim
{
'akinsho/toggleterm.nvim',
version = '*',
config = function()
require('toggleterm').setup({
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_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,
highlights = {
border = 'Normal',
background = 'Normal',
},
},
})
-- Custom terminals
local Terminal = require('toggleterm.terminal').Terminal
-- LazyGit
local lazygit = Terminal:new({
cmd = 'lazygit',
hidden = true,
direction = 'float',
float_opts = {
border = 'none',
width = 100000,
height = 100000,
},
on_open = function(_)
vim.cmd('startinsert!')
end,
})
vim.keymap.set('n', '<leader>tg', function()
lazygit:toggle()
end, { desc = 'LazyGit' })
-- Python REPL
local python = Terminal:new({
cmd = 'python3',
hidden = true,
direction = 'horizontal',
})
vim.keymap.set('n', '<leader>tp', function()
python:toggle()
end, { desc = 'Python REPL' })
-- htop
local htop = Terminal:new({
cmd = 'htop',
hidden = true,
direction = 'float',
})
vim.keymap.set('n', '<leader>th', function()
htop:toggle()
end, { desc = 'htop' })
end,
}FTerm.nvim (Alternative)
{
'numToStr/FTerm.nvim',
config = function()
require('FTerm').setup({
border = 'rounded',
dimensions = {
height = 0.9,
width = 0.9,
},
})
vim.keymap.set('n', '<C-\\>', '<CMD>lua require("FTerm").toggle()<CR>')
vim.keymap.set('t', '<C-\\>', '<C-\\><C-n><CMD>lua require("FTerm").toggle()<CR>')
-- Custom terminal for lazygit
local fterm = require('FTerm')
local lazygit = fterm:new({
ft = 'fterm_lazygit',
cmd = 'lazygit',
dimensions = {
height = 0.9,
width = 0.9,
},
})
vim.keymap.set('n', '<leader>tg', function()
lazygit:toggle()
end, { desc = 'LazyGit' })
end,
}Terminal Integration Patterns
Build and Test Runners
local M = {}
function M.run_make_target(target)
vim.cmd('split | resize 15 | terminal make ' .. (target or ''))
end
function M.run_npm_script()
-- Parse package.json for scripts
local package_json = vim.fn.json_decode(vim.fn.readfile('package.json'))
local scripts = vim.tbl_keys(package_json.scripts or {})
vim.ui.select(scripts, {
prompt = 'Select npm script:',
}, function(choice)
if choice then
vim.cmd('split | resize 15 | terminal npm run ' .. choice)
end
end)
end
function M.run_cargo_command()
local commands = {
'build',
'run',
'test',
'check',
'clippy',
'fmt',
}
vim.ui.select(commands, {
prompt = 'Select cargo command:',
}, function(choice)
if choice then
vim.cmd('split | resize 15 | terminal cargo ' .. choice)
end
end)
end
-- Keymaps
vim.keymap.set('n', '<leader>mm', function()
M.run_make_target()
end, { desc = 'Make' })
vim.keymap.set('n', '<leader>mn', M.run_npm_script, { desc = 'NPM Script' })
vim.keymap.set('n', '<leader>mc', M.run_cargo_command, { desc = 'Cargo Command' })
return MGit Integration
local function git_command(cmd)
vim.cmd('split | resize 15 | terminal git ' .. cmd)
end
vim.keymap.set('n', '<leader>gs', function()
git_command('status')
end, { desc = 'Git Status' })
vim.keymap.set('n', '<leader>gl', function()
git_command('log --oneline -20')
end, { desc = 'Git Log' })
vim.keymap.set('n', '<leader>gd', function()
git_command('diff')
end, { desc = 'Git Diff' })
vim.keymap.set('n', '<leader>gb', function()
git_command('blame ' .. vim.fn.expand('%'))
end, { desc = 'Git Blame' })
-- Interactive git commands
vim.keymap.set('n', '<leader>gc', function()
vim.ui.input({ prompt = 'Git command: ' }, function(input)
if input then
git_command(input)
end
end)
end, { desc = 'Git Command' })Quick Terminal Actions
-- Open terminal in project root
vim.keymap.set('n', '<leader>tr', function()
local root = vim.fn.getcwd()
vim.cmd('split | terminal')
vim.fn.chansend(vim.b.terminal_job_id, 'cd ' .. root .. '\n')
end, { desc = 'Terminal at Root' })
-- Open terminal in current file's directory
vim.keymap.set('n', '<leader>td', function()
local dir = vim.fn.expand('%:p:h')
vim.cmd('split | terminal')
vim.fn.chansend(vim.b.terminal_job_id, 'cd ' .. dir .. '\n')
end, { desc = 'Terminal at Current Dir' })
-- Run last command
local last_command = ''
vim.keymap.set('n', '<leader>tc', function()
vim.ui.input({
prompt = 'Command: ',
default = last_command,
}, function(input)
if input then
last_command = input
vim.cmd('split | resize 15 | terminal ' .. input)
end
end)
end, { desc = 'Run Command' })
vim.keymap.set('n', '<leader>tR', function()
if last_command ~= '' then
vim.cmd('split | resize 15 | terminal ' .. last_command)
else
print('No previous command')
end
end, { desc = 'Repeat Last Command' })Terminal Scrollback
Scrollback Configuration
-- Set scrollback limit
vim.opt.scrollback = 10000
-- Easier scrollback navigation
vim.api.nvim_create_autocmd('TermOpen', {
callback = function()
local opts = { buffer = 0 }
-- Use normal mode for scrolling
vim.keymap.set('t', '<C-[>', '<C-\\><C-n>', opts)
-- Scroll up/down in terminal mode
vim.keymap.set('t', '<PageUp>', '<C-\\><C-n><PageUp>', opts)
vim.keymap.set('t', '<PageDown>', '<C-\\><C-n><PageDown>', opts)
-- Search in terminal buffer
vim.keymap.set('t', '<C-f>', '<C-\\><C-n>/', opts)
end,
})Summary: Chapter #35 – Terminal Integration
This chapter covered comprehensive terminal integration:
Built-in Terminal – Neovim’s native terminal emulator capabilities
Navigation – Efficient movement between terminal and editor modes
Window Management – Smart terminal positioning and toggling
Floating Terminals – Modal terminal overlays
Command Execution – Running commands and capturing output
REPL Integration – Interactive programming language shells
Named Terminals – Managing multiple persistent terminals
Job Control – Background processes and asynchronous execution
Plugin Options – toggleterm.nvim and FTerm.nvim
Common Patterns – Build runners, git integration, and workflow automation
Mastering terminal integration creates a seamless development environment where you can code, test, debug, and manage your project without leaving Neovim.
Chapter #36: Quality of Life Improvements
Small tweaks and configurations that dramatically improve your daily Neovim experience through enhanced usability, comfort, and efficiency.
Better Defaults
Essential Options
-- Better editing experience
vim.opt.number = true -- Show line numbers
vim.opt.relativenumber = true -- Relative line numbers
vim.opt.cursorline = true -- Highlight current line
vim.opt.signcolumn = 'yes' -- Always show sign column
vim.opt.wrap = false -- No line wrapping
-- Smarter search
vim.opt.ignorecase = true -- Case insensitive search
vim.opt.smartcase = true -- Unless capital letter used
vim.opt.hlsearch = true -- Highlight search matches
vim.opt.incsearch = true -- Show matches while typing
-- Better indentation
vim.opt.expandtab = true -- Use spaces instead of tabs
vim.opt.shiftwidth = 2 -- Indent width
vim.opt.tabstop = 2 -- Tab width
vim.opt.softtabstop = 2 -- Backspace removes this many spaces
vim.opt.smartindent = true -- Auto-indent new lines
-- Better splits
vim.opt.splitbelow = true -- Horizontal splits go below
vim.opt.splitright = true -- Vertical splits go right
-- Better completion
vim.opt.completeopt = {'menu', 'menuone', 'noselect'}
vim.opt.pumheight = 10 -- Max completion menu height
-- Better performance
vim.opt.updatetime = 250 -- Faster completion
vim.opt.timeoutlen = 300 -- Faster key sequences
vim.opt.lazyredraw = true -- Don't redraw during macros
-- Better visual feedback
vim.opt.showmode = false -- Don't show mode (in statusline)
vim.opt.showcmd = true -- Show command in bottom bar
vim.opt.cmdheight = 1 -- Command line height
vim.opt.ruler = true -- Show cursor position
-- Better file handling
vim.opt.hidden = true -- Keep buffers open
vim.opt.backup = false -- No backup files
vim.opt.writebackup = false -- No backup while editing
vim.opt.swapfile = false -- No swap files
vim.opt.autoread = true -- Auto-reload changed files
vim.opt.autowrite = true -- Auto-save before some commands
-- Better undo
vim.opt.undofile = true -- Persistent undo
vim.opt.undolevels = 10000 -- Maximum undo levels
-- Better mouse support
vim.opt.mouse = 'a' -- Enable mouse in all modes
-- Better clipboard
vim.opt.clipboard = 'unnamedplus' -- Use system clipboard
-- Better scrolling
vim.opt.scrolloff = 8 -- Keep 8 lines visible when scrolling
vim.opt.sidescrolloff = 8 -- Same for horizontal scrolling
-- Better wildcards
vim.opt.wildmode = {'longest:full', 'full'}
vim.opt.wildignore = {
'*.o', '*.obj', '*.pyc',
'*~', '*.swp',
'.git/*', 'node_modules/*', '__pycache__/*',
}Automatic Directory Setup
-- Create undo directory if it doesn't exist
local undodir = vim.fn.stdpath('data') .. '/undo'
if vim.fn.isdirectory(undodir) == 0 then
vim.fn.mkdir(undodir, 'p')
end
vim.opt.undodir = undodir
-- Auto-create parent directories when saving
vim.api.nvim_create_autocmd('BufWritePre', {
group = vim.api.nvim_create_augroup('auto-mkdir', { clear = true }),
callback = function(event)
local file = vim.loop.fs_realpath(event.match) or event.match
local dir = vim.fn.fnamemodify(file, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end,
})Smart Navigation Enhancements
Improved Line Movement
-- Move by visual lines when wrapping
vim.keymap.set({'n', 'v'}, 'j', 'gj', { desc = 'Move Down (Visual)' })
vim.keymap.set({'n', 'v'}, 'k', 'gk', { desc = 'Move Up (Visual)' })
vim.keymap.set({'n', 'v'}, 'gj', 'j', { desc = 'Move Down (Real)' })
vim.keymap.set({'n', 'v'}, 'gk', 'k', { desc = 'Move Up (Real)' })
-- Better line start/end
vim.keymap.set({'n', 'v'}, 'H', '^', { desc = 'Start of Line' })
vim.keymap.set({'n', 'v'}, 'L', '$', { desc = 'End of Line' })
-- Move lines up/down
vim.keymap.set('n', '<A-j>', ':m .+1<CR>==', { desc = 'Move Line Down' })
vim.keymap.set('n', '<A-k>', ':m .-2<CR>==', { desc = 'Move Line Up' })
vim.keymap.set('v', '<A-j>', ":m '>+1<CR>gv=gv", { desc = 'Move Selection Down' })
vim.keymap.set('v', '<A-k>', ":m '<-2<CR>gv=gv", { desc = 'Move Selection Up' })
-- Better page up/down (keep cursor centered)
vim.keymap.set('n', '<C-d>', '<C-d>zz', { desc = 'Page Down (Centered)' })
vim.keymap.set('n', '<C-u>', '<C-u>zz', { desc = 'Page Up (Centered)' })
-- Better search navigation (keep cursor centered)
vim.keymap.set('n', 'n', 'nzzzv', { desc = 'Next Match (Centered)' })
vim.keymap.set('n', 'N', 'Nzzzv', { desc = 'Prev Match (Centered)' })Quick Window Navigation
-- Simplified window movement
vim.keymap.set('n', '<C-h>', '<C-w>h', { desc = 'Go to Left Window' })
vim.keymap.set('n', '<C-j>', '<C-w>j', { desc = 'Go to Down Window' })
vim.keymap.set('n', '<C-k>', '<C-w>k', { desc = 'Go to Up Window' })
vim.keymap.set('n', '<C-l>', '<C-w>l', { desc = 'Go to Right Window' })
-- Window resizing
vim.keymap.set('n', '<C-Up>', ':resize +2<CR>', { desc = 'Increase Height' })
vim.keymap.set('n', '<C-Down>', ':resize -2<CR>', { desc = 'Decrease Height' })
vim.keymap.set('n', '<C-Left>', ':vertical resize -2<CR>', { desc = 'Decrease Width' })
vim.keymap.set('n', '<C-Right>', ':vertical resize +2<CR>', { desc = 'Increase Width' })
-- Equalize windows
vim.keymap.set('n', '<leader>we', '<C-w>=', { desc = 'Equalize Windows' })
-- Maximize current window
vim.keymap.set('n', '<leader>wm', '<C-w>|<C-w>_', { desc = 'Maximize Window' })Smart Editing Features
Better Indentation
-- Stay in indent mode
vim.keymap.set('v', '<', '<gv', { desc = 'Indent Left' })
vim.keymap.set('v', '>', '>gv', { desc = 'Indent Right' })
-- Auto-indent entire file and return to position
vim.keymap.set('n', '<leader>i', function()
local view = vim.fn.winsaveview()
vim.cmd('normal! gg=G')
vim.fn.winrestview(view)
end, { desc = 'Auto-Indent File' })Smart Paste
-- Paste without overwriting register in visual mode
vim.keymap.set('v', 'p', '"_dP', { desc = 'Paste (Keep Register)' })
-- Paste from system clipboard
vim.keymap.set({'n', 'v'}, '<leader>p', '"+p', { desc = 'Paste from Clipboard' })
vim.keymap.set({'n', 'v'}, '<leader>P', '"+P', { desc = 'Paste Before from Clipboard' })
-- Yank to system clipboard
vim.keymap.set({'n', 'v'}, '<leader>y', '"+y', { desc = 'Yank to Clipboard' })
vim.keymap.set('n', '<leader>Y', '"+Y', { desc = 'Yank Line to Clipboard' })Quick Deletion
-- Delete without yanking
vim.keymap.set({'n', 'v'}, '<leader>d', '"_d', { desc = 'Delete (No Yank)' })
vim.keymap.set('n', '<leader>D', '"_D', { desc = 'Delete to End (No Yank)' })
-- Change without yanking
vim.keymap.set({'n', 'v'}, '<leader>c', '"_c', { desc = 'Change (No Yank)' })
vim.keymap.set('n', '<leader>C', '"_C', { desc = 'Change to End (No Yank)' })
-- Delete blank lines
vim.keymap.set('n', '<leader>db', function()
vim.cmd([[g/^$/d]])
end, { desc = 'Delete Blank Lines' })Quick Text Operations
-- Join lines without moving cursor
vim.keymap.set('n', 'J', 'mzJ`z', { desc = 'Join Lines' })
-- Duplicate line or selection
vim.keymap.set('n', '<leader>ld', 'yyp', { desc = 'Duplicate Line' })
vim.keymap.set('v', '<leader>ld', 'y`>p', { desc = 'Duplicate Selection' })
-- Insert blank line above/below
vim.keymap.set('n', '[<Space>', ':put! =\'\'<CR>', { desc = 'Blank Line Above' })
vim.keymap.set('n', ']<Space>', ':put =\'\'<CR>', { desc = 'Blank Line Below' })
-- Quick substitution for word under cursor
vim.keymap.set('n', '<leader>s', [[:%s/\<<C-r><C-w>\>/<C-r><C-w>/gI<Left><Left><Left>]],
{ desc = 'Substitute Word' })
-- Convert case of word under cursor
vim.keymap.set('n', '<leader>u', 'viwu', { desc = 'Lowercase Word' })
vim.keymap.set('n', '<leader>U', 'viwU', { desc = 'Uppercase Word' })
-- Surround word with quotes
vim.keymap.set('n', '<leader>"', 'viw<Esc>a"<Esc>bi"<Esc>', { desc = 'Surround Double Quotes' })
vim.keymap.set('n', "<leader>'", "viw<Esc>a'<Esc>bi'<Esc>", { desc = 'Surround Single Quotes' })Better Visual Feedback
Highlight on Yank
vim.api.nvim_create_autocmd('TextYankPost', {
group = vim.api.nvim_create_augroup('highlight-yank', { clear = true }),
callback = function()
vim.highlight.on_yank({
higroup = 'IncSearch',
timeout = 200,
})
end,
})Clear Search Highlighting
-- Clear search highlight on <Esc>
vim.keymap.set('n', '<Esc>', '<cmd>nohlsearch<CR>', { desc = 'Clear Highlight' })
-- Auto-clear highlight after search
vim.api.nvim_create_autocmd('CursorMoved', {
group = vim.api.nvim_create_augroup('auto-hlsearch', { clear = true }),
callback = function()
if vim.v.hlsearch == 1 and vim.fn.searchcount().current == 0 then
vim.cmd.nohlsearch()
end
end,
})Better Diagnostics Display
-- Diagnostic configuration
vim.diagnostic.config({
virtual_text = {
prefix = '●',
spacing = 4,
},
signs = true,
underline = true,
update_in_insert = false,
severity_sort = true,
float = {
border = 'rounded',
source = 'always',
header = '',
prefix = '',
},
})
-- 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 = hl })
end
-- Diagnostic navigation
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, { desc = 'Prev Diagnostic' })
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, { desc = 'Next Diagnostic' })
vim.keymap.set('n', '<leader>e', vim.diagnostic.open_float, { desc = 'Show Diagnostic' })
vim.keymap.set('n', '<leader>q', vim.diagnostic.setloclist, { desc = 'Diagnostic List' })Buffer Management
Smart Buffer Navigation
-- Next/previous buffer
vim.keymap.set('n', ']b', ':bnext<CR>', { desc = 'Next Buffer' })
vim.keymap.set('n', '[b', ':bprevious<CR>', { desc = 'Prev Buffer' })
-- Delete buffer without closing window
vim.keymap.set('n', '<leader>bd', function()
local buf = vim.api.nvim_get_current_buf()
local alt_buf = vim.fn.bufnr('#')
-- Try to switch to alternate buffer
if alt_buf ~= -1 and vim.api.nvim_buf_is_valid(alt_buf) then
vim.cmd('buffer ' .. alt_buf)
else
vim.cmd('bprevious')
end
-- Delete the original buffer
vim.api.nvim_buf_delete(buf, { force = false })
end, { desc = 'Delete Buffer' })
-- Force delete buffer
vim.keymap.set('n', '<leader>bD', function()
vim.cmd('bdelete!')
end, { desc = 'Force Delete Buffer' })
-- Delete all buffers except current
vim.keymap.set('n', '<leader>bo', function()
local current = vim.api.nvim_get_current_buf()
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if buf ~= current and vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_delete(buf, { force = false })
end
end
end, { desc = 'Delete Other Buffers' })Buffer Utilities
-- Save all buffers
vim.keymap.set('n', '<leader>wa', ':wa<CR>', { desc = 'Save All' })
-- Close all buffers and quit
vim.keymap.set('n', '<leader>Q', ':qa<CR>', { desc = 'Quit All' })
-- List modified buffers
vim.keymap.set('n', '<leader>bm', function()
local modified = vim.tbl_filter(function(buf)
return vim.api.nvim_buf_get_option(buf, 'modified')
end, vim.api.nvim_list_bufs())
if #modified == 0 then
print('No modified buffers')
else
for _, buf in ipairs(modified) do
local name = vim.api.nvim_buf_get_name(buf)
print(name)
end
end
end, { desc = 'List Modified Buffers' })Command-Line Enhancements
Better Command Mode
-- Command-line navigation
vim.keymap.set('c', '<C-a>', '<Home>', { desc = 'Start of Line' })
vim.keymap.set('c', '<C-e>', '<End>', { desc = 'End of Line' })
vim.keymap.set('c', '<C-p>', '<Up>', { desc = 'Previous Command' })
vim.keymap.set('c', '<C-n>', '<Down>', { desc = 'Next Command' })
-- Expand %% to current directory
vim.keymap.set('c', '%%', function()
return vim.fn.expand('%:h') .. '/'
end, { expr = true, desc = 'Current Directory' })
-- Expand :: to current file
vim.keymap.set('c', '::', function()
return vim.fn.expand('%:p')
end, { expr = true, desc = 'Current File Path' })Quick Commands
-- Save as sudo
vim.keymap.set('n', '<leader>W', ':w !sudo tee % > /dev/null<CR>',
{ desc = 'Save as Sudo' })
-- Quick save and quit
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save' })
vim.keymap.set('n', '<leader>q', ':q<CR>', { desc = 'Quit' })
-- Source current file
vim.keymap.set('n', '<leader><leader>x', function()
vim.cmd('source %')
print('Sourced ' .. vim.fn.expand('%'))
end, { desc = 'Source File' })
-- Make current file executable
vim.keymap.set('n', '<leader>x', function()
local file = vim.fn.expand('%')
vim.fn.system('chmod +x ' .. file)
print('Made executable: ' .. file)
end, { desc = 'Make Executable' })File Management
Quick File Operations
-- Rename current file
vim.keymap.set('n', '<leader>fr', function()
local old_name = vim.fn.expand('%')
local new_name = vim.fn.input('New name: ', old_name)
if new_name ~= '' and new_name ~= old_name then
vim.cmd('saveas ' .. new_name)
vim.fn.delete(old_name)
vim.cmd('bdelete ' .. vim.fn.bufnr(old_name))
end
end, { desc = 'Rename File' })
-- Delete current file
vim.keymap.set('n', '<leader>fD', function()
local file = vim.fn.expand('%')
local choice = vim.fn.confirm('Delete ' .. file .. '?', '&Yes\n&No', 2)
if choice == 1 then
vim.fn.delete(file)
vim.cmd('bdelete!')
print('Deleted: ' .. file)
end
end, { desc = 'Delete File' })
-- Copy current file path
vim.keymap.set('n', '<leader>fp', function()
local path = vim.fn.expand('%:p')
vim.fn.setreg('+', path)
print('Copied: ' .. path)
end, { desc = 'Copy File Path' })
-- Copy current file name
vim.keymap.set('n', '<leader>fn', function()
local name = vim.fn.expand('%:t')
vim.fn.setreg('+', name)
print('Copied: ' .. name)
end, { desc = 'Copy File Name' })Recent Files
-- Quick access to recent files
vim.keymap.set('n', '<leader>fo', function()
vim.cmd('browse oldfiles')
end, { desc = 'Recent Files' })
-- Alternative using Telescope (if available)
vim.keymap.set('n', '<leader>fh', function()
if pcall(require, 'telescope') then
require('telescope.builtin').oldfiles()
else
vim.cmd('browse oldfiles')
end
end, { desc = 'File History' })Auto-Commands
File-Type Specific Settings
-- Auto-format on save for specific filetypes
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = {'*.go', '*.rs'},
callback = function()
vim.lsp.buf.format()
end,
})
-- Remove trailing whitespace on save
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*',
callback = function()
local view = vim.fn.winsaveview()
vim.cmd([[%s/\s\+$//e]])
vim.fn.winrestview(view)
end,
})
-- Return to last edit position
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function()
local mark = vim.api.nvim_buf_get_mark(0, '"')
local lcount = vim.api.nvim_buf_line_count(0)
if mark[1] > 0 and mark[1] <= lcount then
pcall(vim.api.nvim_win_set_cursor, 0, mark)
end
end,
})
-- Resize splits on window resize
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
vim.cmd('tabdo wincmd =')
end,
})
-- Close certain windows with 'q'
vim.api.nvim_create_autocmd('FileType', {
pattern = {
'qf', 'help', 'man', 'lspinfo',
'checkhealth', 'startuptime',
},
callback = function(event)
vim.bo[event.buf].buflisted = false
vim.keymap.set('n', 'q', '<cmd>close<CR>',
{ buffer = event.buf, silent = true })
end,
})
-- Highlight TODO comments
vim.api.nvim_create_autocmd({'BufEnter', 'BufWinEnter'}, {
pattern = '*',
callback = function()
vim.fn.matchadd('Todo', 'TODO\\|FIXME\\|NOTE\\|HACK\\|XXX')
end,
})Working with Numbers
Increment/Decrement Enhancement
-- Better increment/decrement
vim.keymap.set('n', '+', '<C-a>', { desc = 'Increment' })
vim.keymap.set('n', '-', '<C-x>', { desc = 'Decrement' })
vim.keymap.set('v', '+', 'g<C-a>', { desc = 'Increment (Visual)' })
vim.keymap.set('v', '-', 'g<C-x>', { desc = 'Decrement (Visual)' })
-- Create sequence
vim.keymap.set('v', '<leader>n', function()
vim.cmd("'<,'>s/\\d\\+/\\=line('.')-line(\"'<\")+1/")
end, { desc = 'Create Sequence' })Quick Toggles
Toggle Common Options
-- Toggle line numbers
vim.keymap.set('n', '<leader>tn', function()
vim.opt.number = not vim.opt.number:get()
vim.opt.relativenumber = not vim.opt.relativenumber:get()
end, { desc = 'Toggle Line Numbers' })
-- Toggle wrap
vim.keymap.set('n', '<leader>tw', function()
vim.opt.wrap = not vim.opt.wrap:get()
end, { desc = 'Toggle Wrap' })
-- Toggle spell check
vim.keymap.set('n', '<leader>ts', function()
vim.opt.spell = not vim.opt.spell:get()
end, { desc = 'Toggle Spell' })
-- Toggle cursor line
vim.keymap.set('n', '<leader>tc', function()
vim.opt.cursorline = not vim.opt.cursorline:get()
end, { desc = 'Toggle Cursor Line' })
-- Toggle list (show whitespace)
vim.keymap.set('n', '<leader>tl', function()
vim.opt.list = not vim.opt.list:get()
end, { desc = 'Toggle List Chars' })
-- Configure list characters
vim.opt.listchars = {
tab = '→ ',
trail = '·',
nbsp = '␣',
extends = '⟩',
precedes = '⟨',
}Session Management
Quick Session Helpers
-- Save session
vim.keymap.set('n', '<leader>ss', function()
local session_dir = vim.fn.stdpath('data') .. '/sessions/'
vim.fn.mkdir(session_dir, 'p')
local session_name = vim.fn.input('Session name: ', vim.fn.getcwd():match('[^/]+$'))
if session_name ~= '' then
vim.cmd('mksession! ' .. session_dir .. session_name .. '.vim')
print('Session saved: ' .. session_name)
end
end, { desc = 'Save Session' })
-- Load session
vim.keymap.set('n', '<leader>sl', function()
local session_dir = vim.fn.stdpath('data') .. '/sessions/'
local sessions = vim.fn.glob(session_dir .. '*.vim', false, true)
if #sessions == 0 then
print('No sessions found')
return
end
local names = vim.tbl_map(function(s)
return vim.fn.fnamemodify(s, ':t:r')
end, sessions)
vim.ui.select(names, {
prompt = 'Select session:',
}, function(choice)
if choice then
vim.cmd('source ' .. session_dir .. choice .. '.vim')
end
end)
end, { desc = 'Load Session' })Miscellaneous Enhancements
Quick Fixes
-- Fix common typos
vim.cmd([[
cnoreabbrev W! w!
cnoreabbrev W w
cnoreabbrev Q! q!
cnoreabbrev Q q
cnoreabbrev Qa qa
cnoreabbrev Qall qall
cnoreabbrev Wq wq
cnoreabbrev WQ wq
cnoreabbrev wQ wq
]])
-- Better mark jumping (center screen)
vim.keymap.set('n', "'", "m'`", { desc = 'Jump to Mark' })
vim.keymap.set('n', "`", "m``zz", { desc = 'Jump to Mark (Centered)' })
-- Quick macros
vim.keymap.set('n', 'Q', '@q', { desc = 'Execute Macro q' })
-- Visual shifting (keeps selection)
vim.keymap.set('x', '<', '<gv', { desc = 'Shift Left' })
vim.keymap.set('x', '>', '>gv', { desc = 'Shift Right' })
-- Reselect last paste
vim.keymap.set('n', 'gp', '`[v`]', { desc = 'Reselect Paste' })
-- Quick formatting
vim.keymap.set('n', '<leader>=', 'gg=G``', { desc = 'Format Buffer' })
-- Count matches
vim.keymap.set('n', '<leader>*', '*<C-O>:%s///gn<CR>', { desc = 'Count Matches' })Performance Helpers
-- Disable providers you don't use
vim.g.loaded_python3_provider = 0
vim.g.loaded_ruby_provider = 0
vim.g.loaded_perl_provider = 0
vim.g.loaded_node_provider = 0
-- Disable built-in plugins you don't need
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 ipairs(disabled_built_ins) do
vim.g['loaded_' .. plugin] = 1
endSummary: Chapter #36 – Quality of Life Improvements
This chapter covered essential quality-of-life enhancements:
Better Defaults – Sensible options for improved editing
Smart Navigation – Enhanced movement commands
Quick Editing – Faster text manipulation
Visual Feedback – Better highlighting and diagnostics
Buffer Management – Efficient buffer workflows
Command-Line – Enhanced command mode
File Operations – Quick file management
Auto-Commands – Automatic improvements
Quick Toggles – Fast option switching
Miscellaneous – Various helpful tweaks
These small improvements compound to create a significantly more comfortable and efficient editing environment. Each tweak addresses common friction points and speeds up daily workflows.
Chapter #37: Lesser-Known Features
Discover powerful but often overlooked Neovim capabilities that can transform your workflow. These features are built into Neovim but rarely discussed in typical tutorials.
Text Objects and Motions
Advanced Text Objects
-- Around and inside various delimiters
-- ia/aa - argument
-- ib/ab - () block
-- iB/aB - {} block
-- it/at - tag block
-- iq/aq - quote
-- Custom text object for entire buffer
vim.keymap.set({'o', 'x'}, 'ae', ':<C-u>normal! ggVG<CR>',
{ desc = 'Entire Buffer' })
-- Custom text object for current line (without whitespace)
vim.keymap.set({'o', 'x'}, 'il', ':<C-u>normal! ^v$h<CR>',
{ desc = 'Line Content' })
-- Custom text object for indentation level
vim.keymap.set({'o', 'x'}, 'ii', function()
local line = vim.fn.line('.')
local indent = vim.fn.indent(line)
-- Find start of block
local start = line
while start > 1 and vim.fn.indent(start - 1) >= indent do
start = start - 1
end
-- Find end of block
local last = vim.fn.line('$')
local finish = line
while finish < last and vim.fn.indent(finish + 1) >= indent do
finish = finish + 1
end
vim.cmd('normal! ' .. start .. 'GV' .. finish .. 'G')
end, { desc = 'Indent Block' })Powerful Built-in Motions
-- ]) - Go to next } in first column
-- [) - Go to previous } in first column
-- ]] - Go to next { in first column
-- [[ - Go to previous { in first column
-- ]m - Go to next method start
-- [m - Go to previous method start
-- ]M - Go to next method end
-- [M - Go to previous method end
-- Sentence and paragraph motions
-- ) - Next sentence
-- ( - Previous sentence
-- } - Next paragraph
-- { - Previous paragraph
-- Jump to matching items
-- % - Go to matching bracket/tag
-- [( - Go to previous unmatched (
-- ]) - Go to next unmatched )
-- Demo keymaps for these
vim.keymap.set('n', '<leader>h]', ']]', { desc = 'Next { (column 1)' })
vim.keymap.set('n', '<leader>h[', '[[', { desc = 'Prev { (column 1)' })The Power of g
Commands
Lesser-Known g
Commands
-- Useful g commands (normal mode):
-- gv - Reselect last visual selection
-- gn - Select next search match
-- gN - Select previous search match
-- gJ - Join lines without space
-- gq - Format text (respects textwidth)
-- gw - Format text (keep cursor position)
-- g& - Repeat last substitute on all lines
-- g; - Go to previous change position
-- g, - Go to next change position
-- gi - Go to last insert position and enter insert mode
-- gI - Insert at column 1
-- gf - Go to file under cursor
-- gx - Open URL under cursor
-- gd - Go to definition (LSP)
-- gD - Go to declaration
-- gu - Make lowercase (motion)
-- gU - Make uppercase (motion)
-- g~ - Toggle case (motion)
-- g? - ROT13 encode (motion)
-- g8 - Show UTF-8 byte sequence
-- ga - Show character code
-- gs - Sleep (useful in scripts)
-- Demonstrate some useful ones
vim.keymap.set('n', 'gV', '`[v`]', { desc = 'Select Last Paste' })
vim.keymap.set('n', 'gp', 'p`[v`]', { desc = 'Paste and Select' })
-- Use gn/gN for better search/replace workflow
vim.keymap.set('n', '<leader>sn', function()
vim.cmd('normal! *')
print('Press cgn to change and . to repeat')
end, { desc = 'Search Word (cgn ready)' })The g Command
Workflows
-- Search and replace with gn (better than :%s)
-- 1. Search with /pattern
-- 2. Use cgn to change first match
-- 3. Press . to repeat on next matches
-- 4. Press n to skip a match
-- Visual block with gv
-- 1. Make a visual selection
-- 2. Do something
-- 3. Press gv to reselect
-- 4. Do something else
-- Format with gq
vim.keymap.set('n', '<leader>gq', 'gqip', { desc = 'Format Paragraph' })
vim.keymap.set('v', '<leader>gq', 'gq', { desc = 'Format Selection' })The :g Global
Command
Powerful Global Commands
-- The :g command syntax: :[range]g/pattern/command
-- Delete all lines matching pattern
vim.keymap.set('n', '<leader>gd', function()
local pattern = vim.fn.input('Delete lines matching: ')
if pattern ~= '' then
vim.cmd('g/' .. pattern .. '/d')
end
end, { desc = 'Delete Matching Lines' })
-- Delete all lines NOT matching pattern
vim.keymap.set('n', '<leader>gD', function()
local pattern = vim.fn.input('Delete lines NOT matching: ')
if pattern ~= '' then
vim.cmd('g!/' .. pattern .. '/d')
end
end, { desc = 'Delete Non-Matching Lines' })
-- Copy all matching lines to register
vim.keymap.set('n', '<leader>gy', function()
local pattern = vim.fn.input('Yank lines matching: ')
if pattern ~= '' then
vim.cmd('g/' .. pattern .. '/y A')
print('Yanked matching lines to register a')
end
end, { desc = 'Yank Matching Lines' })
-- Move all matching lines to end of file
vim.keymap.set('n', '<leader>gm', function()
local pattern = vim.fn.input('Move lines matching: ')
if pattern ~= '' then
vim.cmd('g/' .. pattern .. '/m$')
end
end, { desc = 'Move Matching to End' })
-- Examples of useful :g commands:
-- :g/TODO/p - Print all TODO lines
-- :g/pattern/t$ - Copy matching lines to end
-- :g/^$/d - Delete empty lines
-- :g/pattern/normal @q - Run macro q on matching lines
-- :g/function/+1d - Delete line after each match
-- :g/pattern/s/old/new/g - Replace in matching lines onlyAdvanced :g Patterns
-- Create a command to show all TODO/FIXME/NOTE comments
vim.api.nvim_create_user_command('ShowTODO', function()
vim.cmd('g/\\(TODO\\|FIXME\\|NOTE\\|HACK\\|XXX\\)/p')
end, {})
-- Create a command to collect matching lines in new buffer
vim.api.nvim_create_user_command('Collect', function(opts)
local pattern = opts.args
if pattern == '' then
pattern = vim.fn.input('Pattern: ')
end
if pattern ~= '' then
-- Copy to register a
vim.cmd('g/' .. pattern .. '/y A')
-- Create new buffer
vim.cmd('new')
-- Paste contents
vim.cmd('put a')
-- Delete first blank line
vim.cmd('1d')
end
end, { nargs = '?' })The :norm Command
Execute Normal Mode Commands Programmatically
-- :norm allows running normal mode commands on multiple lines
-- Add semicolon to end of each line in range
vim.keymap.set('v', '<leader>;', ':norm A;<CR>',
{ desc = 'Add ; to Lines' })
-- Comment out lines (assuming # is comment char)
vim.keymap.set('v', '<leader>#', ':norm I# <CR>',
{ desc = 'Comment Lines' })
-- Wrap each line in quotes
vim.keymap.set('v', '<leader>"w', ':norm I"<CR>gv:norm A"<CR>',
{ desc = 'Wrap in Quotes' })
-- Execute macro on multiple lines
vim.keymap.set('v', '<leader>@', function()
local reg = vim.fn.input('Macro register: ')
if reg:match('[a-z]') then
vim.cmd("'<,'>norm @" .. reg)
end
end, { desc = 'Execute Macro on Lines' })
-- Examples of useful :norm commands:
-- :norm 0 - Go to start of each line
-- :norm $ - Go to end of each line
-- :norm I" - Insert " at start of line
-- :norm A; - Append ; at end of line
-- :norm 0D - Delete entire line contents
-- :norm >> - Indent line
-- :norm . - Repeat last change on each lineExpression Register
The = Register
for Calculations
-- In insert mode: <C-r>= to evaluate expression
-- Examples:
-- <C-r>=2+2<CR> - Insert "4"
-- <C-r>=strftime('%Y-%m-%d')<CR> - Insert current date
-- <C-r>=system('date')<CR> - Insert command output
-- Quick calculator
vim.keymap.set('i', '<C-c>', '<C-r>=', { desc = 'Calculator' })
-- Insert current date/time shortcuts
vim.keymap.set('i', '<leader>dd', function()
return vim.fn.strftime('%Y-%m-%d')
end, { expr = true, desc = 'Insert Date' })
vim.keymap.set('i', '<leader>dt', function()
return vim.fn.strftime('%H:%M:%S')
end, { expr = true, desc = 'Insert Time' })
vim.keymap.set('i', '<leader>dD', function()
return vim.fn.strftime('%Y-%m-%d %H:%M:%S')
end, { expr = true, desc = 'Insert DateTime' })
-- Insert from Lua expression
vim.keymap.set('i', '<C-l>', function()
local expr = vim.fn.input('Lua: ')
if expr ~= '' then
local result = loadstring('return ' .. expr)()
return tostring(result)
end
return ''
end, { expr = true, desc = 'Eval Lua' })Marks and Positions
Advanced Mark Usage
-- Lowercase marks (a-z): buffer-local
-- Uppercase marks (A-Z): global (cross-file)
-- Number marks (0-9): last exit positions
-- Special marks:
-- ` - position before last jump
-- " - position when last editing this file
-- [ - start of last changed/yanked text
-- ] - end of last changed/yanked text
-- < - start of last visual selection
-- > - end of last visual selection
-- . - position of last change
-- Quick mark jumps
vim.keymap.set('n', '<leader>m1', "mA", { desc = 'Set Global Mark A' })
vim.keymap.set('n', '<leader>m2', "mB", { desc = 'Set Global Mark B' })
vim.keymap.set('n', '<leader>m3', "mC", { desc = 'Set Global Mark C' })
vim.keymap.set('n', '<leader>`1', "`A", { desc = 'Jump to Mark A' })
vim.keymap.set('n', '<leader>`2', "`B", { desc = 'Jump to Mark B' })
vim.keymap.set('n', '<leader>`3', "`C", { desc = 'Jump to Mark C' })
-- Show all marks
vim.keymap.set('n', '<leader>ml', ':marks<CR>', { desc = 'List Marks' })
-- Delete all marks in buffer
vim.keymap.set('n', '<leader>md', ':delmarks!<CR>', { desc = 'Delete Marks' })
-- Jump to last change and edit
vim.keymap.set('n', 'g.', '`.', { desc = 'Jump to Last Change' })
-- Use marks for quick navigation between files
vim.api.nvim_create_user_command('MarkFile', function(opts)
local mark = opts.args
if mark:match('[A-Z]') then
vim.cmd('mark ' .. mark)
print('Set mark ' .. mark .. ' in ' .. vim.fn.expand('%'))
end
end, { nargs = 1 })The Power of :h
(Help)
Advanced Help Navigation
-- Help tag patterns:
-- :h pattern - Search help
-- :h 'option' - Help for option
-- :h :command - Help for Ex command
-- :h function() - Help for function
-- :h i_CTRL-R - Help for insert mode mapping
-- :h c_CTRL-R - Help for command mode mapping
-- :h v_motion - Help for visual mode motion
-- Quick help lookups
vim.keymap.set('n', '<leader>H', function()
local word = vim.fn.expand('<cword>')
vim.cmd('help ' .. word)
end, { desc = 'Help for Word' })
-- Help for visual selection
vim.keymap.set('v', '<leader>H', function()
local text = vim.fn.getregion(
vim.fn.getpos('v'),
vim.fn.getpos('.'),
{ type = vim.fn.mode() }
)[1]
vim.cmd('help ' .. text)
end, { desc = 'Help for Selection' })
-- Search help for topic
vim.keymap.set('n', '<leader>hs', function()
local topic = vim.fn.input('Help search: ')
if topic ~= '' then
vim.cmd('helpgrep ' .. topic)
end
end, { desc = 'Search Help' })
-- Open help in vertical split
vim.keymap.set('n', '<leader>hv', function()
local word = vim.fn.expand('<cword>')
vim.cmd('vert help ' .. word)
end, { desc = 'Help Vertical' })Quickfix and Location Lists
Advanced List Operations
-- Quickfix commands:
-- :copen - Open quickfix window
-- :cclose - Close quickfix window
-- :cnext - Next item
-- :cprev - Previous item
-- :cfirst - First item
-- :clast - Last item
-- :cnewer - Newer quickfix list
-- :colder - Older quickfix list
-- :cdo - Execute command on each quickfix entry
-- :cfdo - Execute command on each file in quickfix
-- Location list (buffer-local quickfix):
-- Same commands with 'l' instead of 'c'
-- :lopen, :lnext, :lprev, etc.
-- Navigate quickfix
vim.keymap.set('n', '[q', ':cprev<CR>', { desc = 'Prev Quickfix' })
vim.keymap.set('n', ']q', ':cnext<CR>', { desc = 'Next Quickfix' })
vim.keymap.set('n', '[Q', ':cfirst<CR>', { desc = 'First Quickfix' })
vim.keymap.set('n', ']Q', ':clast<CR>', { desc = 'Last Quickfix' })
-- Navigate location list
vim.keymap.set('n', '[l', ':lprev<CR>', { desc = 'Prev Location' })
vim.keymap.set('n', ']l', ':lnext<CR>', { desc = 'Next Location' })
-- Toggle quickfix
vim.keymap.set('n', '<leader>co', function()
if vim.fn.getqflist({winid = 0}).winid ~= 0 then
vim.cmd('cclose')
else
vim.cmd('copen')
end
end, { desc = 'Toggle Quickfix' })
-- Clear quickfix
vim.keymap.set('n', '<leader>cc', ':cexpr []<CR>',
{ desc = 'Clear Quickfix' })
-- Execute command on each quickfix entry
vim.api.nvim_create_user_command('Cdo', function(opts)
vim.cmd('cdo ' .. opts.args)
end, { nargs = '+' })
-- Example: Replace in all quickfix files
-- :vimgrep /pattern/ **/*.lua
-- :Cdo s/old/new/g | update
-- Save/restore quickfix list
vim.keymap.set('n', '<leader>cs', function()
local qf = vim.fn.getqflist()
vim.g.saved_qflist = qf
print('Quickfix list saved')
end, { desc = 'Save Quickfix' })
vim.keymap.set('n', '<leader>cr', function()
if vim.g.saved_qflist then
vim.fn.setqflist(vim.g.saved_qflist)
vim.cmd('copen')
print('Quickfix list restored')
end
end, { desc = 'Restore Quickfix' })Command-Line Window
The Secret Command Editor
-- Open command-line window:
-- q: - Command history window
-- q/ - Search forward history window
-- q? - Search backward history window
-- <C-f> in command mode - Open command window
-- In the command window:
-- - Edit commands like normal text
-- - Press Enter to execute
-- - Press <C-c> to cancel
-- Better command-line window
vim.keymap.set('n', 'q:', function()
vim.cmd('nohlsearch')
vim.cmd('call feedkeys("q:", "n")')
end, { desc = 'Command History' })
-- Configure command window
vim.api.nvim_create_autocmd('CmdwinEnter', {
callback = function()
vim.keymap.set('n', '<CR>', '<CR>', { buffer = true })
vim.keymap.set('n', 'q', ':q<CR>', { buffer = true })
vim.keymap.set('n', '<Esc>', ':q<CR>', { buffer = true })
end,
})
-- Quick command editing workflow:
-- 1. Press q:
-- 2. Navigate to command you want
-- 3. Edit it (use normal Vim commands)
-- 4. Press Enter to execute
-- 5. Or press q to cancelThe Black Hole Register
"_ - The
Deletion That Doesn’t Yank
-- Delete without affecting registers
-- "_d - Delete without yanking
-- "_c - Change without yanking
-- "_x - Delete char without yanking
-- Make these the default for certain operations
vim.keymap.set('n', 'x', '"_x', { desc = 'Delete Char (No Yank)' })
vim.keymap.set('n', 'X', '"_X', { desc = 'Delete Char Back (No Yank)' })
vim.keymap.set('v', 'x', '"_x', { desc = 'Delete Char (No Yank)' })
-- Change without yanking
vim.keymap.set('n', 'c', '"_c', { desc = 'Change (No Yank)' })
vim.keymap.set('n', 'C', '"_C', { desc = 'Change Line (No Yank)' })
vim.keymap.set('v', 'c', '"_c', { desc = 'Change (No Yank)' })
-- Keep original behavior with leader
vim.keymap.set('n', '<leader>c', 'c', { desc = 'Change (Yank)' })
vim.keymap.set('n', '<leader>C', 'C', { desc = 'Change Line (Yank)' })Diff Mode
Built-in Diff Capabilities
-- Diff commands:
-- :diffsplit file - Open file in diff mode
-- :diffthis - Make current window part of diff
-- :diffoff - Turn off diff mode
-- do - Obtain diff changes
-- dp - Put diff changes
-- ]c - Next change
-- [c - Previous change
-- :diffupdate - Update diff highlighting
-- Easy diff operations
vim.keymap.set('n', '<leader>dt', ':diffthis<CR>', { desc = 'Diff This' })
vim.keymap.set('n', '<leader>do', ':diffoff<CR>', { desc = 'Diff Off' })
vim.keymap.set('n', '<leader>du', ':diffupdate<CR>', { desc = 'Diff Update' })
-- Diff navigation
vim.keymap.set('n', '[c', '[c', { desc = 'Prev Change' })
vim.keymap.set('n', ']c', ']c', { desc = 'Next Change' })
-- Quick diff with buffer
vim.keymap.set('n', '<leader>db', function()
local bufnr = vim.fn.input('Buffer number: ')
if bufnr ~= '' then
vim.cmd('vert diffsplit #' .. bufnr)
end
end, { desc = 'Diff Buffer' })
-- Diff with file
vim.keymap.set('n', '<leader>df', function()
local file = vim.fn.input('File: ', '', 'file')
if file ~= '' then
vim.cmd('vert diffsplit ' .. file)
end
end, { desc = 'Diff File' })
-- Diff with git HEAD
vim.keymap.set('n', '<leader>dg', function()
local file = vim.fn.expand('%')
vim.cmd('vert diffsplit !git show HEAD:' .. file)
end, { desc = 'Diff Git HEAD' })The = Motion
(Auto-indent)
Powerful Formatting Motion
-- = is a motion, so it works with:
-- == - Format current line
-- =G - Format to end of file
-- =gg - Format to start of file
-- =ap - Format paragraph
-- =a{ - Format {} block
-- v= - Format selection
-- Format entire function
vim.keymap.set('n', '=f', 'va{=', { desc = 'Format Function' })
-- Format inside parentheses
vim.keymap.set('n', '=i', 'vi(=', { desc = 'Format () Block' })
-- Smart format (LSP or =)
vim.keymap.set('n', '<leader>=', function()
if vim.lsp.buf.format then
vim.lsp.buf.format()
else
vim.cmd('normal! gg=G``')
end
end, { desc = 'Format Buffer' })
-- Format and save
vim.keymap.set('n', '<leader>w=', function()
vim.cmd('normal! gg=G``')
vim.cmd('write')
end, { desc = 'Format and Save' })The . Command
(Repeat)
Making the Dot Command More Powerful
-- The dot command repeats the last change
-- Make your changes "dot-repeatable"
-- Example: Delete word and repeat with .
-- dw.... (deletes 4 words)
-- Example: Change word and repeat
-- cw<new><Esc>.... (changes 4 words to "new")
-- Make macro repeatable with dot
vim.keymap.set('n', 'Q', '@qj', { desc = 'Execute Macro q and Move Down' })
-- Now . will repeat @q on next line
-- Create "change inside quotes" that's dot-repeatable
vim.keymap.set('n', '<leader>ci"', 'ci"', { desc = 'Change Inside "' })
vim.keymap.set('n', "<leader>ci'", "ci'", { desc = "Change Inside '" })
-- Make plugin commands dot-repeatable (example)
vim.api.nvim_create_user_command('RepeatableChange', function()
-- Do something
vim.cmd([[normal! ciw]])
-- Make it repeatable
vim.fn['repeat#set']('\\<Plug>RepeatableChange')
end, {})Command Ranges
Powerful Range Specifications
-- Range patterns:
-- :5,10d - Lines 5-10
-- :5,$d - Line 5 to end
-- :%d - All lines
-- :.d - Current line
-- :.,+5d - Current line and next 5
-- :.-5,.d - Previous 5 lines to current
-- :/pattern/d - Next line matching pattern
-- :?pattern?d - Previous line matching pattern
-- :'<,'>d - Visual selection
-- :g/pattern/d - All lines matching pattern
-- Examples with custom commands
vim.api.nvim_create_user_command('CopyRange', function(opts)
local lines = vim.api.nvim_buf_get_lines(0,
opts.line1 - 1, opts.line2, false)
vim.fn.setreg('+', table.concat(lines, '\n'))
print('Copied ' .. #lines .. ' lines')
end, { range = true })
-- Use it: :'<,'>CopyRange or :5,10CopyRange
-- Delete lines in range matching pattern
vim.api.nvim_create_user_command('DeleteMatching', function(opts)
local pattern = opts.args
vim.cmd(opts.line1 .. ',' .. opts.line2 .. 'g/' .. pattern .. '/d')
end, { range = '%', nargs = 1 })
-- Use it: :%DeleteMatching TODO
-- Execute command on range with offset
-- :.,.+10s/old/new/g - Current line + next 10
-- :$-5,$s/old/new/g - Last 5 linesThe @: Command
Repeat Last Command
-- @: repeats the last Ex command
-- @@ repeats the last @ command
-- Quick repeat last command
vim.keymap.set('n', '<leader>.', '@:', { desc = 'Repeat Last Command' })
-- Repeat last substitution
vim.keymap.set('n', '<leader>&', ':&&<CR>', { desc = 'Repeat Substitution' })
-- Repeat last substitution with flags
vim.keymap.set('n', '<leader>&&', ':&&g<CR>',
{ desc = 'Repeat Substitution (global)' })
-- Store frequently used commands in registers
vim.keymap.set('n', '<leader>1', function()
vim.fn.setreg('1', ':source %\n')
print('Stored :source % in register 1')
end, { desc = 'Store Source Command' })
vim.keymap.set('n', '<leader>!', '@1', { desc = 'Execute Register 1' })The :read and
:write Commands
Lesser-Known File Operations
-- :read can:
-- :read file - Insert file contents
-- :read !command - Insert command output
-- :0read file - Insert at top
-- :$read file - Insert at bottom
-- :read !ls - Insert directory listing
-- :write can:
-- :write !command - Pipe buffer to command
-- :write !sudo tee % - Save as root
-- :'<,'>write file - Write selection to file
-- :'<,'>write >> file - Append selection to file
-- Insert file contents
vim.keymap.set('n', '<leader>rf', function()
local file = vim.fn.input('Read file: ', '', 'file')
if file ~= '' then
vim.cmd('read ' .. file)
end
end, { desc = 'Read File' })
-- Insert command output
vim.keymap.set('n', '<leader>rc', function()
local cmd = vim.fn.input('Command: ')
if cmd ~= '' then
vim.cmd('read !' .. cmd)
end
end, { desc = 'Read Command' })
-- Write selection to new file
vim.keymap.set('v', '<leader>wf', function()
local file = vim.fn.input('Write to: ', '', 'file')
if file ~= '' then
vim.cmd("'<,'>write " .. file)
end
end, { desc = 'Write Selection' })
-- Filter buffer through command
vim.keymap.set('n', '<leader>!', function()
local cmd = vim.fn.input('Filter through: ')
if cmd ~= '' then
vim.cmd('%!' .. cmd)
end
end, { desc = 'Filter Buffer' })
-- Filter selection through command
vim.keymap.set('v', '<leader>!', function()
local cmd = vim.fn.input('Filter through: ')
if cmd ~= '' then
vim.cmd("'<,'>!" .. cmd)
end
end, { desc = 'Filter Selection' })The Power of
:substitute Flags
Advanced Substitution Options
-- Substitute flags:
-- g - Replace all occurrences in line
-- c - Confirm each replacement
-- i - Case insensitive
-- I - Case sensitive
-- n - Report number of matches (don't replace)
-- e - No error if pattern not found
-- & - Reuse flags from previous substitute
-- Count matches without replacing
vim.keymap.set('n', '<leader>sc', function()
local pattern = vim.fn.input('Count pattern: ')
if pattern ~= '' then
vim.cmd('%s/' .. pattern .. '//gn')
end
end, { desc = 'Count Pattern' })
-- Interactive substitute
vim.keymap.set('n', '<leader>si', function()
local old = vim.fn.input('Old: ')
if old == '' then return end
local new = vim.fn.input('New: ')
vim.cmd('%s/' .. old .. '/' .. new .. '/gc')
end, { desc = 'Interactive Substitute' })
-- Substitute with confirmation in range
vim.keymap.set('v', '<leader>s', function()
local old = vim.fn.input('Old: ')
if old == '' then return end
local new = vim.fn.input('New: ')
vim.cmd("'<,'>s/" .. old .. '/' .. new .. '/gc')
end, { desc = 'Substitute (confirm)' })Summary: Chapter #37 – Lesser-Known Features
This chapter explored powerful but underutilized Neovim features:
Advanced Text Objects – Custom motions and selections
gCommands – Powerful operations starting withg:gGlobal Command – Batch operations on matching lines:normCommand – Programmatic normal mode executionExpression Register – Calculations and evaluations
Advanced Marks – Sophisticated position management
Help System – Advanced help navigation
Quickfix Lists – Powerful list management
Command-Line Window – Secret command editor
Black Hole Register – Clean deletions
Diff Mode – Built-in comparison tools
Ranges and Commands – Advanced command execution
These features exist in vanilla Neovim and require no plugins. Mastering them can significantly enhance your editing capabilities and understanding of Vim’s design philosophy.
Chapter #38: Performance Optimization and Profiling
Introduction
Neovim’s extensibility comes with a performance cost—each plugin, autocommand, and custom function adds overhead. This chapter teaches you to identify bottlenecks, optimize configurations, and maintain a responsive editing experience even with hundreds of plugins loaded.
Philosophy: Measure first, optimize second. Premature optimization wastes time; informed optimization based on profiling data yields real improvements.
Startup Time Profiling
Basic Startup Measurement
Generate a startup profile:
nvim --startuptime startup.log +quitAnalyze the output:
# View slowest operations
sort -k2 -n startup.log | tail -20
# Or use a more readable format
cat startup.log | awk '{print $2, $NF}' | sort -n | tail -20Understanding the log format:
004.123 002.456: sourcing /path/to/plugin.lua ^^^ ^^^ | Time spent in this operation (ms) Total time elapsed
Identifying Common Culprits
Typical slow operations:
Plugin loading – Too many plugins loaded at startup
TreeSitter parsing – Large grammars loaded unnecessarily
LSP attachment – Servers starting for every filetype
Colorscheme – Complex themes with many highlight groups
Autocommands – Excessive event handlers
Example analysis:
045.234 015.123: sourcing telescope.lua 060.456 012.345: TreeSitter loading parsers 075.789 010.234: LSP client initialization
This shows Telescope takes 15ms, TreeSitter 12ms, LSP 10ms—prioritize optimizing Telescope first.
Lazy Loading Strategies
Lazy.nvim Advanced Patterns
Load on specific events:
-- Load telescope only when needed
{
'nvim-telescope/telescope.nvim',
cmd = 'Telescope', -- Load on :Telescope command
keys = { -- Load on keybind
{ '<leader>ff', '<cmd>Telescope find_files<cr>' },
{ '<leader>/', '<cmd>Telescope live_grep<cr>' },
},
}Lazy load LSP configurations:
{
'neovim/nvim-lspconfig',
event = { 'BufReadPre', 'BufNewFile' }, -- Load before editing
dependencies = {
{ 'williamboman/mason.nvim', config = true },
},
}Conditional loading based on file size:
-- Disable heavy plugins for large files
vim.api.nvim_create_autocmd('BufReadPre', {
callback = function()
local max_size = 100 * 1024 -- 100KB
local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(0))
if ok and stats and stats.size > max_size then
vim.b.large_file = true
vim.cmd('syntax off')
vim.opt_local.foldmethod = 'manual'
vim.opt_local.spell = false
-- Prevent LSP attachment (covered later)
end
end,
})Module-based lazy loading:
-- Instead of requiring at startup:
-- local utils = require('my.utils')
-- Use on-demand loading:
local function get_utils()
return require('my.utils')
end
-- Call only when needed
vim.keymap.set('n', '<leader>x', function()
get_utils().execute_file()
end)Measuring Lazy Loading Impact
-- Before lazy loading
vim.cmd([[
profile start /tmp/nvim-profile.log
profile func *
profile file *
]])
-- After configuration loads
vim.defer_fn(function()
print("Startup time: " .. vim.fn.reltimefloat(vim.fn.reltime(vim.g.start_time)) .. "s")
end, 100)
-- Set start time in init.lua
vim.g.start_time = vim.fn.reltime()Runtime Performance Monitoring
Built-in Profiling
Profile a specific operation:
:profile start /tmp/profile.log
:profile func *
:profile file *
" Perform the slow operation
:Telescope find_files
:profile pause
:noautocmd qall!
Analyze the profile log:
# Find functions taking most time
grep "FUNCTION" /tmp/profile.log -A 3 | grep "Total time" | sort -k3 -n | tail -10
# Find most-called functions
grep "FUNCTION" /tmp/profile.log -A 3 | grep "Called" | sort -k2 -n | tail -10Custom Performance Tracking
-- ~/.config/nvim/lua/performance.lua
local M = {}
-- Simple timer utility
function M.measure(name, fn)
local start = vim.loop.hrtime()
local result = fn()
local elapsed = (vim.loop.hrtime() - start) / 1e6 -- Convert to ms
print(string.format("%s took %.2fms", name, elapsed))
return result
end
-- Track function execution times
M.timings = {}
function M.track(name)
return function(fn)
return function(...)
local start = vim.loop.hrtime()
local results = { fn(...) }
local elapsed = (vim.loop.hrtime() - start) / 1e6
M.timings[name] = M.timings[name] or { count = 0, total = 0 }
M.timings[name].count = M.timings[name].count + 1
M.timings[name].total = M.timings[name].total + elapsed
return unpack(results)
end
end
end
-- Display timing report
function M.report()
print("\n=== Performance Report ===")
local sorted = {}
for name, stats in pairs(M.timings) do
table.insert(sorted, {
name = name,
avg = stats.total / stats.count,
total = stats.total,
count = stats.count,
})
end
table.sort(sorted, function(a, b) return a.total > b.total end)
for _, stat in ipairs(sorted) do
print(string.format(
"%s: %.2fms total, %.2fms avg, %d calls",
stat.name, stat.total, stat.avg, stat.count
))
end
end
return MUsage:
local perf = require('performance')
-- Wrap a function to track it
local original_func = some_plugin.expensive_operation
some_plugin.expensive_operation = perf.track('expensive_op')(original_func)
-- Later, view the report
vim.keymap.set('n', '<leader>pr', function()
require('performance').report()
end, { desc = 'Performance report' })LSP Performance Tuning
Selective Server Attachment
-- ~/.config/nvim/lua/lsp/performance.lua
local M = {}
-- Check if LSP should attach based on context
function M.should_attach(client, bufnr)
local buf_name = vim.api.nvim_buf_get_name(bufnr)
-- Don't attach to large files
if vim.b[bufnr].large_file then
return false
end
-- Skip certain paths (node_modules, build directories)
local skip_patterns = {
'node_modules',
'%.min%.js$',
'%.min%.css$',
'dist/',
'build/',
}
for _, pattern in ipairs(skip_patterns) do
if buf_name:match(pattern) then
return false
end
end
return true
end
-- Optimize server capabilities
function M.optimize_capabilities(client)
-- Disable semantic tokens if not needed
if client.name == 'tsserver' then
client.server_capabilities.semanticTokensProvider = nil
end
-- Reduce document sync for specific servers
if client.name == 'pyright' then
client.server_capabilities.documentFormattingProvider = false
end
end
return MApply optimizations:
-- In your LSP on_attach function
local lsp_perf = require('lsp.performance')
local on_attach = function(client, bufnr)
if not lsp_perf.should_attach(client, bufnr) then
vim.lsp.buf_detach_client(bufnr, client.id)
return
end
lsp_perf.optimize_capabilities(client)
-- Rest of on_attach logic...
endDebouncing LSP Operations
-- Debounce document updates
local function debounce(fn, ms)
local timer = vim.loop.new_timer()
return function(...)
local args = { ... }
timer:start(ms, 0, vim.schedule_wrap(function()
fn(unpack(args))
end))
end
end
-- Apply to diagnostics updates
vim.lsp.handlers['textDocument/publishDiagnostics'] = vim.lsp.with(
vim.lsp.diagnostic.on_publish_diagnostics,
{
update_in_insert = false, -- Don't update while typing
virtual_text = {
spacing = 4,
prefix = '●',
},
}
)
-- Debounce formatting on save
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*.lua',
callback = debounce(function()
vim.lsp.buf.format({ async = false })
end, 200),
})Reduce LSP Memory Usage
-- Limit workspace folders
vim.lsp.start_client({
root_dir = require('lspconfig').util.root_pattern('.git'),
workspace_folders = nil, -- Don't automatically add folders
on_init = function(client)
-- Limit to single folder
client.workspace_folders = {
{
uri = vim.uri_from_fname(client.config.root_dir),
name = vim.fn.fnamemodify(client.config.root_dir, ':t'),
},
}
end,
})
-- Disable workspace symbol caching for large projects
vim.lsp.handlers['workspace/symbol'] = function(_, result, ctx)
-- Don't cache, process immediately
vim.lsp.handlers['workspace/symbol'](nil, result, ctx)
endTreeSitter Optimization
Selective Parser Loading
require('nvim-treesitter.configs').setup({
-- Only install parsers you actually use
ensure_installed = { 'lua', 'python', 'javascript', 'rust' },
-- Don't auto-install missing parsers
auto_install = false,
highlight = {
enable = true,
-- Disable for large files
disable = function(lang, buf)
local max_filesize = 100 * 1024 -- 100 KB
local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(buf))
if ok and stats and stats.size > max_filesize then
return true
end
end,
-- Limit parsing for very long lines
additional_vim_regex_highlighting = false,
},
-- Disable incremental selection for large files
incremental_selection = {
enable = true,
disable = function(lang, buf)
return vim.b[buf].large_file
end,
},
})Async Parsing Strategy
-- Parse large files asynchronously
local function parse_async(bufnr)
vim.defer_fn(function()
if vim.api.nvim_buf_is_loaded(bufnr) then
local parser = vim.treesitter.get_parser(bufnr)
if parser then
parser:parse()
end
end
end, 100) -- Delay parsing by 100ms
end
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function(args)
if vim.b[args.buf].large_file then
vim.opt_local.syntax = 'off'
parse_async(args.buf)
end
end,
})Memory Management
Buffer Lifecycle Management
-- Auto-close hidden buffers after timeout
local function setup_buffer_cleanup()
local timer = vim.loop.new_timer()
local check_interval = 60000 -- 1 minute
timer:start(check_interval, check_interval, vim.schedule_wrap(function()
local buffers = vim.api.nvim_list_bufs()
local current_time = os.time()
for _, buf in ipairs(buffers) do
if vim.api.nvim_buf_is_loaded(buf) and not vim.api.nvim_buf_get_option(buf, 'modified') then
-- Check if buffer hasn't been accessed recently
local last_used = vim.b[buf].last_used or 0
if current_time - last_used > 600 then -- 10 minutes
-- Check if not visible in any window
local windows = vim.fn.win_findbuf(buf)
if #windows == 0 then
vim.api.nvim_buf_delete(buf, { force = false })
end
end
end
end
end))
end
-- Track buffer access
vim.api.nvim_create_autocmd({ 'BufEnter', 'CursorHold' }, {
callback = function(args)
vim.b[args.buf].last_used = os.time()
end,
})
setup_buffer_cleanup()Shada (Shared Data) Optimization
-- Limit what gets saved to shada
vim.opt.shada = {
"!", -- Save global variables (starting with uppercase)
"'100", -- Marks for last 100 files
"<50", -- Lines per register (default 50)
"s10", -- Max item size 10KB
"h", -- Disable hlsearch on load
}
-- Clear old data periodically
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
vim.cmd('wshada!') -- Force write, clearing old data
end,
})Swap File Configuration
-- Use RAM for swap (faster but lost on crash)
vim.opt.directory = '/tmp/nvim-swap//'
-- Or disable swap entirely for speed (risky)
-- vim.opt.swapfile = false
-- Reduce swap write frequency
vim.opt.updatetime = 300 -- Write swap after 300ms of inactivity (default 4000)
vim.opt.updatecount = 100 -- Write swap after 100 characters typedAsync Operations with vim.loop
Background Job Processing
-- ~/.config/nvim/lua/async.lua
local M = {}
-- Run shell command asynchronously
function M.run_async(cmd, on_complete)
local stdout = vim.loop.new_pipe(false)
local stderr = vim.loop.new_pipe(false)
local output = {}
local errors = {}
local handle
handle = vim.loop.spawn('sh', {
args = { '-c', cmd },
stdio = { nil, stdout, stderr },
}, vim.schedule_wrap(function(code, signal)
stdout:close()
stderr:close()
handle:close()
if on_complete then
on_complete(code, output, errors)
end
end))
vim.loop.read_start(stdout, function(err, data)
if data then
table.insert(output, data)
end
end)
vim.loop.read_start(stderr, function(err, data)
if data then
table.insert(errors, data)
end
end)
end
-- Batch file operations
function M.process_files(files, processor, on_complete)
local completed = 0
local total = #files
local results = {}
for i, file in ipairs(files) do
vim.schedule(function()
results[i] = processor(file)
completed = completed + 1
if completed == total and on_complete then
on_complete(results)
end
end)
end
end
return MUsage example:
local async = require('async')
-- Run linter in background
vim.keymap.set('n', '<leader>l', function()
async.run_async('eslint .', function(code, output, errors)
if code == 0 then
print('Linting complete: ' .. table.concat(output))
else
vim.notify('Linting failed:\n' .. table.concat(errors), vim.log.levels.ERROR)
end
end)
end)Throttling Expensive Operations
-- Create a throttled version of a function
local function throttle(fn, ms)
local last_call = 0
local pending_timer = nil
return function(...)
local args = { ... }
local now = vim.loop.now()
if now - last_call >= ms then
last_call = now
fn(unpack(args))
else
if pending_timer then
pending_timer:stop()
end
pending_timer = vim.defer_fn(function()
last_call = vim.loop.now()
fn(unpack(args))
pending_timer = nil
end, ms - (now - last_call))
end
end
end
-- Apply to autocommands
local update_diagnostics = throttle(function()
vim.diagnostic.setqflist({ open = false })
end, 500)
vim.api.nvim_create_autocmd('DiagnosticChanged', {
callback = update_diagnostics,
})Plugin-Specific Optimizations
Telescope Performance
require('telescope').setup({
defaults = {
-- Limit results for faster searching
file_ignore_patterns = {
"node_modules",
"%.git/",
"dist/",
"build/",
"%.jpg",
"%.png",
},
-- Use faster sorting
sorting_strategy = "ascending",
-- Reduce preview window overhead
preview = {
treesitter = false, -- Disable treesitter in preview
timeout = 200, -- Limit preview render time
},
-- Optimize layout
layout_config = {
prompt_position = "top",
width = 0.8,
height = 0.8,
},
},
pickers = {
find_files = {
-- Use faster find implementation
find_command = { 'rg', '--files', '--hidden', '--glob', '!.git/*' },
},
live_grep = {
-- Limit results
max_results = 1000,
additional_args = function()
return { "--max-count=1000" }
end,
},
},
extensions = {
fzf = {
fuzzy = true,
override_generic_sorter = true,
override_file_sorter = true,
case_mode = "smart_case",
},
},
})
-- Load fzf-native for faster sorting
require('telescope').load_extension('fzf')Completion Performance (nvim-cmp)
local cmp = require('cmp')
cmp.setup({
performance = {
debounce = 100, -- Wait 100ms before showing suggestions
throttle = 50, -- Limit updates to every 50ms
fetching_timeout = 200, -- Cancel slow sources after 200ms
max_view_entries = 20, -- Limit visible entries
},
sources = cmp.config.sources({
{ name = 'nvim_lsp', priority = 10, max_item_count = 20 },
{ name = 'luasnip', priority = 8, max_item_count = 10 },
{
name = 'buffer',
priority = 5,
max_item_count = 10,
option = {
-- Only search visible buffers
get_bufnrs = function()
local bufs = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
bufs[vim.api.nvim_win_get_buf(win)] = true
end
return vim.tbl_keys(bufs)
end,
},
},
}),
-- Reduce formatting overhead
formatting = {
format = function(entry, vim_item)
vim_item.abbr = string.sub(vim_item.abbr, 1, 40) -- Truncate
vim_item.menu = ({
nvim_lsp = "[LSP]",
luasnip = "[Snip]",
buffer = "[Buf]",
})[entry.source.name]
return vim_item
end,
},
})Disabling Unused Features
Built-in Provider Cleanup
-- Disable providers you don't use
vim.g.loaded_node_provider = 0
vim.g.loaded_perl_provider = 0
vim.g.loaded_python3_provider = 0 -- Only if you don't use Python plugins
vim.g.loaded_ruby_provider = 0
-- Disable built-in plugins
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
endSelective Syntax Loading
-- Only load syntax for specific filetypes
vim.api.nvim_create_autocmd('FileType', {
pattern = { 'markdown', 'text', 'help' },
callback = function()
vim.opt_local.syntax = 'on'
end,
})
-- For everything else, rely on TreeSitter
vim.cmd('syntax off')Benchmarking Framework
Complete Benchmark Suite
-- ~/.config/nvim/lua/benchmark.lua
local M = {}
M.results = {}
-- Benchmark a function
function M.run(name, fn, iterations)
iterations = iterations or 100
local times = {}
-- Warmup
for _ = 1, 10 do
fn()
end
-- Actual benchmark
for i = 1, iterations do
local start = vim.loop.hrtime()
fn()
local elapsed = (vim.loop.hrtime() - start) / 1e6
table.insert(times, elapsed)
end
-- Calculate statistics
table.sort(times)
local total = 0
for _, t in ipairs(times) do
total = total + t
end
M.results[name] = {
min = times[1],
max = times[#times],
avg = total / #times,
median = times[math.floor(#times / 2)],
p95 = times[math.floor(#times * 0.95)],
iterations = iterations,
}
end
-- Compare two implementations
function M.compare(name1, fn1, name2, fn2, iterations)
M.run(name1, fn1, iterations)
M.run(name2, fn2, iterations)
local r1 = M.results[name1]
local r2 = M.results[name2]
print(string.format("\n=== Benchmark Comparison ==="))
print(string.format("%s: avg=%.2fms, p95=%.2fms", name1, r1.avg, r1.p95))
print(string.format("%s: avg=%.2fms, p95=%.2fms", name2, r2.avg, r2.p95))
print(string.format("Winner: %s (%.2fx faster)",
r1.avg < r2.avg and name1 or name2,
math.max(r1.avg, r2.avg) / math.min(r1.avg, r2.avg)
))
end
-- Print all results
function M.report()
print("\n=== Benchmark Results ===")
for name, result in pairs(M.results) do
print(string.format(
"%s:\n avg: %.2fms\n median: %.2fms\n p95: %.2fms\n min/max: %.2f/%.2fms",
name, result.avg, result.median, result.p95, result.min, result.max
))
end
end
return MExample usage:
local bench = require('benchmark')
-- Compare two search methods
bench.compare(
'builtin search',
function() vim.fn.search('pattern') end,
'regex search',
function() vim.fn.searchpos('\\vpattern') end,
1000
)
-- Benchmark a custom function
bench.run('my_custom_func', function()
require('my_module').do_something()
end, 500)
bench.report()Performance Monitoring Dashboard
Real-time Stats Display
-- ~/.config/nvim/lua/perf-monitor.lua
local M = {}
M.enabled = false
M.stats = {
startup_time = 0,
loaded_plugins = 0,
active_lsp_clients = 0,
buffer_count = 0,
memory_mb = 0,
}
-- Update stats
function M.update()
M.stats.loaded_plugins = #vim.tbl_keys(require('lazy').plugins())
M.stats.active_lsp_clients = #vim.lsp.get_active_clients()
M.stats.buffer_count = #vim.tbl_filter(
function(b) return vim.api.nvim_buf_is_loaded(b) end,
vim.api.nvim_list_bufs()
)
-- Approximate memory usage (Linux only)
local status_file = io.open('/proc/self/status', 'r')
if status_file then
for line in status_file:lines() do
local kb = line:match('^VmRSS:%s*(%d+)')
if kb then
M.stats.memory_mb = tonumber(kb) / 1024
break
end
end
status_file:close()
end
end
-- Display in statusline or floating window
function M.show()
M.update()
local lines = {
string.format("Startup: %.2fms", M.stats.startup_time),
string.format("Plugins: %d", M.stats.loaded_plugins),
string.format("LSP clients: %d", M.stats.active_lsp_clients),
string.format("Buffers: %d", M.stats.buffer_count),
string.format("Memory: %.1fMB", M.stats.memory_mb),
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
local width = 25
local height = #lines
local win = vim.api.nvim_open_win(buf, false, {
relative = 'editor',
width = width,
height = height,
row = 1,
col = vim.o.columns - width - 2,
style = 'minimal',
border = 'rounded',
})
vim.defer_fn(function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
end, 5000)
end
-- Toggle monitoring
function M.toggle()
M.enabled = not M.enabled
if M.enabled then
M.timer = vim.loop.new_timer()
M.timer:start(0, 5000, vim.schedule_wrap(M.update))
else
if M.timer then
M.timer:stop()
M.timer = nil
end
end
end
return MKeymap:
vim.keymap.set('n', '<leader>pm', function()
require('perf-monitor').show()
end, { desc = 'Show performance monitor' })Summary and Best Practices
Performance Checklist
Before optimization:
✅ Measure startup time baseline (
--startuptime)✅ Identify slowest plugins (analyze log)
✅ Profile runtime operations (
:profile)
Optimization priorities:
Lazy load plugins – Biggest impact on startup
Optimize LSP – Affects editing responsiveness
Limit TreeSitter – Reduce parsing overhead
Clean up autocommands – Remove unnecessary events
Manage memory – Buffer cleanup, shada limits
Measurement after changes:
✅ Re-run
--startuptime✅ Compare before/after times
✅ Verify functionality still works
✅ Check for regressions in specific workflows
Target Metrics
| Metric | Target | Excellent |
|---|---|---|
| Startup time | < 100ms | < 50ms |
| LSP response | < 200ms | < 100ms |
| Completion popup | < 100ms | < 50ms |
| File open | < 50ms | < 20ms |
| Memory usage | < 200MB | < 100MB |
Common Anti-Patterns
❌ Don’t:
Load all plugins at startup
Run expensive operations in
BufEnterEnable all LSP features unconditionally
Keep unlimited undo/shada history
Use synchronous operations in hot paths
✅ Do:
Lazy load with
lazy.nvimDebounce/throttle frequent operations
Conditionally attach LSP based on file size
Set reasonable history limits
Use
vim.schedulefor async work
Next Steps
In Chapter #39: Testing and Debugging Configurations, we’ll cover:
Writing tests for custom Lua modules
Debugging techniques for complex configurations
Bisecting configuration issues
Creating minimal reproducible examples
Automated config validation
The performance optimizations in this chapter ensure your editor remains fast even as your configuration grows in complexity. Combined with proper testing (next chapter), you’ll have a robust, production-ready setup.
Chapter #39: Testing and Debugging Configurations
Introduction
A well-tested Neovim configuration prevents regressions, catches errors early, and makes refactoring safe. This chapter covers debugging techniques, testing strategies, and tools for maintaining reliable configurations.
Philosophy: Treat your Neovim configuration as production code—test thoroughly, debug systematically, and document issues.
Debugging Lua Code
Basic Debugging Techniques
1. Print debugging (the reliable classic):
-- Simple print
print("Debug: variable value is", vim.inspect(my_variable))
-- More informative
local function debug_print(label, value)
print(string.format("[DEBUG] %s: %s", label, vim.inspect(value)))
end
debug_print("Config loaded", { plugin_count = 42, startup_time = 123 })2. Using vim.inspect() for complex
structures:
-- Inspect tables/functions
local config = {
plugins = { 'telescope', 'lsp' },
settings = { theme = 'catppuccin' }
}
-- Shallow inspection
print(vim.inspect(config))
-- Deep inspection with options
print(vim.inspect(config, { depth = 3, newline = '\n', indent = ' ' }))3. Conditional debugging:
-- Enable debug mode via environment variable
local DEBUG = os.getenv('NVIM_DEBUG') == '1'
local function dbg(...)
if DEBUG then
print('[DEBUG]', ...)
end
end
-- Usage
dbg('Loading plugin configuration')
dbg('Current buffer:', vim.api.nvim_get_current_buf())Advanced Debugging with DAP
Setup for debugging Neovim’s Lua runtime:
-- Install nvim-dap and one-small-step-for-vimkind
{
'mfussenegger/nvim-dap',
dependencies = {
'jbyuki/one-small-step-for-vimkind',
},
config = function()
local dap = require('dap')
-- Configure Lua adapter
dap.configurations.lua = {
{
type = 'nlua',
request = 'attach',
name = 'Attach to running Neovim instance',
}
}
dap.adapters.nlua = function(callback, config)
callback({ type = 'server', host = config.host or '127.0.0.1', port = config.port or 8086 })
end
-- Keymaps
vim.keymap.set('n', '<F5>', dap.continue)
vim.keymap.set('n', '<F10>', dap.step_over)
vim.keymap.set('n', '<F11>', dap.step_into)
vim.keymap.set('n', '<F12>', dap.step_out)
vim.keymap.set('n', '<leader>db', dap.toggle_breakpoint)
end,
}Debug a Lua module:
-- Set breakpoint in your code
require('dap').toggle_breakpoint()
-- Start debug server
require('osv').launch({ port = 8086 })
-- Now execute the code that hits the breakpointError Handling and Logging
Structured logging system:
-- ~/.config/nvim/lua/logger.lua
local M = {}
M.levels = {
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
}
M.current_level = M.levels.INFO
M.log_file = vim.fn.stdpath('cache') .. '/nvim-debug.log'
-- Core logging function
function M.log(level, message, context)
if level < M.current_level then
return
end
local level_names = { 'DEBUG', 'INFO', 'WARN', 'ERROR' }
local timestamp = os.date('%Y-%m-%d %H:%M:%S')
local level_name = level_names[level] or 'UNKNOWN'
local log_line = string.format(
'[%s] %s: %s',
timestamp,
level_name,
message
)
if context then
log_line = log_line .. '\n Context: ' .. vim.inspect(context)
end
-- Write to file
local file = io.open(M.log_file, 'a')
if file then
file:write(log_line .. '\n')
file:close()
end
-- Also print to console for errors
if level >= M.levels.ERROR then
vim.notify(message, vim.log.levels.ERROR)
end
end
-- Convenience functions
function M.debug(msg, ctx) M.log(M.levels.DEBUG, msg, ctx) end
function M.info(msg, ctx) M.log(M.levels.INFO, msg, ctx) end
function M.warn(msg, ctx) M.log(M.levels.WARN, msg, ctx) end
function M.error(msg, ctx) M.log(M.levels.ERROR, msg, ctx) end
-- Clear log file
function M.clear()
local file = io.open(M.log_file, 'w')
if file then
file:close()
end
end
-- View log file
function M.view()
vim.cmd('edit ' .. M.log_file)
end
return MUsage in configuration:
local log = require('logger')
-- Set log level via environment variable
if os.getenv('NVIM_LOG_LEVEL') == 'DEBUG' then
log.current_level = log.levels.DEBUG
end
-- Use throughout config
log.info('Loading LSP configuration')
vim.lsp.start_client({
name = 'my-lsp',
on_attach = function(client, bufnr)
log.debug('LSP attached', { client = client.name, buffer = bufnr })
end,
on_error = function(code, err)
log.error('LSP error', { code = code, error = err })
end,
})Keymap Testing and Verification
Detecting Keymap Conflicts
-- ~/.config/nvim/lua/keymap-checker.lua
local M = {}
-- Get all keymaps for a mode
function M.get_keymaps(mode)
local keymaps = vim.api.nvim_get_keymap(mode)
local buf_keymaps = vim.api.nvim_buf_get_keymap(0, mode)
-- Merge and organize
local all_maps = {}
for _, map in ipairs(keymaps) do
all_maps[map.lhs] = map
end
for _, map in ipairs(buf_keymaps) do
all_maps[map.lhs] = map
end
return all_maps
end
-- Find conflicts
function M.find_conflicts()
local modes = { 'n', 'i', 'v', 'x', 't' }
local conflicts = {}
for _, mode in ipairs(modes) do
local maps = M.get_keymaps(mode)
local seen = {}
for lhs, map in pairs(maps) do
local key = mode .. ':' .. lhs
if seen[key] then
table.insert(conflicts, {
mode = mode,
key = lhs,
original = seen[key],
override = map,
})
else
seen[key] = map
end
end
end
return conflicts
end
-- Display conflicts in a buffer
function M.show_conflicts()
local conflicts = M.find_conflicts()
if #conflicts == 0 then
print('No keymap conflicts found!')
return
end
local lines = { '=== Keymap Conflicts ===' , '' }
for _, conflict in ipairs(conflicts) do
table.insert(lines, string.format('Mode: %s, Key: %s', conflict.mode, conflict.key))
table.insert(lines, ' Original: ' .. (conflict.original.rhs or '<no rhs>'))
table.insert(lines, ' Override: ' .. (conflict.override.rhs or '<no rhs>'))
table.insert(lines, '')
end
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.api.nvim_buf_set_option(buf, 'filetype', 'text')
vim.cmd('split')
vim.api.nvim_win_set_buf(0, buf)
end
-- List all keymaps for a mode
function M.list_keymaps(mode)
local maps = M.get_keymaps(mode)
local lines = { string.format('=== Keymaps for mode: %s ===', mode), '' }
local sorted = {}
for lhs, map in pairs(maps) do
table.insert(sorted, { lhs = lhs, map = map })
end
table.sort(sorted, function(a, b) return a.lhs < b.lhs end)
for _, item in ipairs(sorted) do
local desc = item.map.desc or '<no description>'
table.insert(lines, string.format('%s -> %s (%s)',
item.lhs,
item.map.rhs or '<function>',
desc
))
end
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
vim.cmd('split')
vim.api.nvim_win_set_buf(0, buf)
end
return MCommands for testing:
vim.api.nvim_create_user_command('KeymapConflicts', function()
require('keymap-checker').show_conflicts()
end, {})
vim.api.nvim_create_user_command('KeymapList', function(opts)
local mode = opts.args ~= '' and opts.args or 'n'
require('keymap-checker').list_keymaps(mode)
end, { nargs = '?' })Keymap Testing Framework
-- ~/.config/nvim/lua/test-keymaps.lua
local M = {}
-- Simulate keypress
function M.feedkeys(keys, mode)
mode = mode or 'n'
vim.api.nvim_feedkeys(
vim.api.nvim_replace_termcodes(keys, true, false, true),
mode,
false
)
end
-- Test a keymap produces expected result
function M.test_keymap(test_name, keys, setup, verify)
local success, err = pcall(function()
-- Setup test environment
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(buf)
if setup then
setup()
end
-- Execute keymap
M.feedkeys(keys)
-- Wait for async operations
vim.wait(100)
-- Verify result
local result = verify()
-- Cleanup
vim.api.nvim_buf_delete(buf, { force = true })
return result
end)
if success and err then
print(string.format('✓ %s', test_name))
return true
else
print(string.format('✗ %s: %s', test_name, err or 'assertion failed'))
return false
end
end
-- Example test suite
function M.run_tests()
local passed = 0
local total = 0
-- Test 1: Leader key functionality
total = total + 1
if M.test_keymap(
'Leader + ff opens telescope',
'<leader>ff',
function()
-- Setup: ensure telescope is available
require('telescope')
end,
function()
-- Verify: check if telescope picker opened
local wins = vim.api.nvim_list_wins()
for _, win in ipairs(wins) do
local buf = vim.api.nvim_win_get_buf(win)
local ft = vim.api.nvim_buf_get_option(buf, 'filetype')
if ft == 'TelescopePrompt' then
return true
end
end
error('Telescope picker not found')
end
) then
passed = passed + 1
end
-- Test 2: Navigation
total = total + 1
if M.test_keymap(
'j moves cursor down',
'j',
function()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'line 1', 'line 2', 'line 3' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
end,
function()
local cursor = vim.api.nvim_win_get_cursor(0)
if cursor[1] == 2 then
return true
end
error(string.format('Expected row 2, got %d', cursor[1]))
end
) then
passed = passed + 1
end
print(string.format('\nResults: %d/%d tests passed', passed, total))
end
return MPlugin Debugging
Health Checks
Use built-in :checkhealth:
:checkhealth
:checkhealth telescope
:checkhealth lsp
Create custom health checks:
-- ~/.config/nvim/lua/health/myconfig.lua
local M = {}
function M.check()
vim.health.start('My Configuration')
-- Check Lua version
if jit then
vim.health.ok('LuaJIT ' .. jit.version)
else
vim.health.warn('Not using LuaJIT, performance may be degraded')
end
-- Check for required executables
local required_tools = { 'rg', 'fd', 'git' }
for _, tool in ipairs(required_tools) do
if vim.fn.executable(tool) == 1 then
vim.health.ok(tool .. ' found')
else
vim.health.error(tool .. ' not found', {
'Install ' .. tool .. ' for full functionality'
})
end
end
-- Check plugin directory
local plugin_dir = vim.fn.stdpath('data') .. '/lazy'
if vim.fn.isdirectory(plugin_dir) == 1 then
local count = #vim.fn.readdir(plugin_dir)
vim.health.ok(string.format('%d plugins installed', count))
else
vim.health.error('Plugin directory not found')
end
-- Check LSP servers
local lsp_servers = vim.lsp.get_active_clients()
if #lsp_servers > 0 then
vim.health.ok(string.format('%d LSP servers running', #lsp_servers))
for _, server in ipairs(lsp_servers) do
vim.health.info(' - ' .. server.name)
end
else
vim.health.warn('No LSP servers running')
end
end
return MRun with: :checkhealth myconfig
Analyzing Plugin Load Times
-- ~/.config/nvim/lua/plugin-profiler.lua
local M = {}
M.timings = {}
-- Wrap require to measure load time
local original_require = require
_G.require = function(modname)
local start = vim.loop.hrtime()
local result = original_require(modname)
local elapsed = (vim.loop.hrtime() - start) / 1e6
M.timings[modname] = (M.timings[modname] or 0) + elapsed
return result
end
-- Restore original require
function M.stop()
_G.require = original_require
end
-- Display report
function M.report()
local sorted = {}
for mod, time in pairs(M.timings) do
table.insert(sorted, { module = mod, time = time })
end
table.sort(sorted, function(a, b) return a.time > b.time end)
print('\n=== Plugin Load Times ===')
for i, item in ipairs(sorted) do
if i <= 20 then -- Top 20
print(string.format('%2d. %-40s %.2fms', i, item.module, item.time))
end
end
end
return MUsage:
-- At the top of init.lua
local profiler = require('plugin-profiler')
-- Your configuration here...
-- At the end of init.lua
vim.defer_fn(function()
profiler.stop()
profiler.report()
end, 1000)Debugging Messages and Logs
-- View all Neovim messages
vim.keymap.set('n', '<leader>dm', '<cmd>messages<cr>', { desc = 'Debug: Show messages' })
-- Clear messages
vim.keymap.set('n', '<leader>dc', '<cmd>messages clear<cr>', { desc = 'Debug: Clear messages' })
-- Capture messages to buffer
vim.api.nvim_create_user_command('MessagesToBuffer', function()
local messages = vim.split(vim.fn.execute('messages'), '\n')
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, messages)
vim.cmd('split')
vim.api.nvim_win_set_buf(0, buf)
end, {})LSP logging:
-- Enable LSP logging
vim.lsp.set_log_level('debug')
-- View LSP log
vim.keymap.set('n', '<leader>dl', function()
vim.cmd('edit ' .. vim.lsp.get_log_path())
end, { desc = 'Debug: LSP log' })Minimal Configuration Testing
Creating Minimal Configs
Minimal init.lua for testing:
-- ~/.config/nvim/minimal.lua
-- Use: nvim -u ~/.config/nvim/minimal.lua
-- Set up package path
local temp_dir = vim.fn.stdpath('cache') .. '/minimal'
local package_root = temp_dir .. '/lazy'
local install_path = package_root .. '/lazy.nvim'
-- Install lazy.nvim if not present
if not vim.loop.fs_stat(install_path) then
vim.fn.system({
'git', 'clone', '--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
install_path,
})
end
vim.opt.rtp:prepend(install_path)
-- Only load the plugin you're testing
require('lazy').setup({
{
'nvim-telescope/telescope.nvim',
dependencies = { 'nvim-lua/plenary.nvim' },
},
}, {
root = package_root,
lockfile = temp_dir .. '/lazy-lock.json',
})
-- Minimal settings
vim.opt.number = true
vim.opt.termguicolors = true
-- Test keymap
vim.keymap.set('n', '<leader>ff', '<cmd>Telescope find_files<cr>')
print('Minimal config loaded - Testing telescope')Use it:
nvim -u ~/.config/nvim/minimal.luaBisecting Configuration Issues
Binary search approach:
-- ~/.config/nvim/bisect.lua
-- Systematically disable half the config to find issues
local M = {}
M.disabled_plugins = {}
function M.disable_plugins(pattern)
local lazy = require('lazy')
for _, plugin in ipairs(lazy.plugins()) do
if plugin.name:match(pattern) then
table.insert(M.disabled_plugins, plugin.name)
lazy.disable(plugin.name)
end
end
print('Disabled ' .. #M.disabled_plugins .. ' plugins')
end
function M.enable_all()
local lazy = require('lazy')
for _, name in ipairs(M.disabled_plugins) do
lazy.enable(name)
end
M.disabled_plugins = {}
print('Re-enabled all plugins')
end
return MInteractive bisection:
vim.api.nvim_create_user_command('BisectStart', function()
local bisect = require('bisect')
-- Disable half the plugins
bisect.disable_plugins('.*') -- All plugins
print([[
Bisection started. Steps:
1. Test if the issue still occurs
2. If YES: The problem is in core config
3. If NO: Run :BisectContinue to test plugin subsets
]])
end, {})Automated Testing
Unit Testing Custom Modules
Using plenary.nvim’s test harness:
-- ~/.config/nvim/tests/utils_spec.lua
local utils = require('my.utils')
describe('Utils module', function()
describe('string functions', function()
it('should split strings correctly', function()
local result = utils.split('a,b,c', ',')
assert.are.same({ 'a', 'b', 'c' }, result)
end)
it('should handle empty strings', function()
local result = utils.split('', ',')
assert.are.same({ '' }, result)
end)
end)
describe('file operations', function()
it('should detect if file exists', function()
local exists = utils.file_exists('/etc/passwd') -- Unix system file
assert.is_true(exists)
end)
it('should return false for non-existent files', function()
local exists = utils.file_exists('/this/does/not/exist')
assert.is_false(exists)
end)
end)
describe('async operations', function()
it('should complete async function', function()
local completed = false
utils.async_operation(function()
completed = true
end)
-- Wait for async
vim.wait(1000, function() return completed end)
assert.is_true(completed)
end)
end)
end)Run tests:
nvim --headless -c "PlenaryBustedDirectory tests/ { minimal_init = 'minimal.lua' }"Integration Testing
Test full workflows:
-- ~/.config/nvim/tests/workflow_spec.lua
describe('LSP workflow', function()
local bufnr
before_each(function()
-- Create test buffer
bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
end)
after_each(function()
-- Cleanup
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end)
it('should attach LSP to Lua files', function()
vim.api.nvim_buf_set_name(bufnr, 'test.lua')
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'lua')
-- Wait for LSP attachment
vim.wait(2000, function()
return #vim.lsp.get_active_clients({ bufnr = bufnr }) > 0
end)
local clients = vim.lsp.get_active_clients({ bufnr = bufnr })
assert.is_true(#clients > 0)
assert.equals('lua_ls', clients[1].name)
end)
it('should provide completions', function()
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'lua')
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'vim.' })
-- Trigger completion
local line = vim.api.nvim_get_current_line()
local col = #line
local items = vim.lsp.buf_request_sync(
bufnr,
'textDocument/completion',
vim.lsp.util.make_position_params(),
1000
)
assert.is_not_nil(items)
end)
end)Continuous Testing in Development
-- Auto-run tests on save
vim.api.nvim_create_autocmd('BufWritePost', {
pattern = { '*/tests/*_spec.lua', '*/lua/my/*.lua' },
callback = function()
vim.cmd('PlenaryBustedFile %')
end,
})Configuration Validation
Schema Validation
-- ~/.config/nvim/lua/validate-config.lua
local M = {}
-- Define expected structure
M.schema = {
plugins = 'table',
options = {
number = 'boolean',
relativenumber = 'boolean',
tabstop = 'number',
},
keymaps = 'table',
autocommands = 'table',
}
-- Validate against schema
function M.validate(config, schema)
for key, expected_type in pairs(schema) do
local actual_value = config[key]
if type(expected_type) == 'table' then
-- Nested validation
if type(actual_value) ~= 'table' then
return false, string.format('%s must be a table', key)
end
local ok, err = M.validate(actual_value, expected_type)
if not ok then
return false, key .. '.' .. err
end
else
-- Direct type check
if type(actual_value) ~= expected_type then
return false, string.format(
'%s must be %s, got %s',
key, expected_type, type(actual_value)
)
end
end
end
return true
end
-- Validate current configuration
function M.check()
local config = {
plugins = require('lazy').plugins(),
options = {
number = vim.o.number,
relativenumber = vim.o.relativenumber,
tabstop = vim.o.tabstop,
},
keymaps = vim.api.nvim_get_keymap('n'),
autocommands = vim.api.nvim_get_autocmds({}),
}
local ok, err = M.validate(config, M.schema)
if ok then
print('✓ Configuration validation passed')
else
print('✗ Configuration validation failed: ' .. err)
end
end
return MRuntime Assertions
-- ~/.config/nvim/lua/my/utils.lua with assertions
local M = {}
function M.open_file(path)
assert(type(path) == 'string', 'path must be string')
assert(path ~= '', 'path cannot be empty')
local exists = vim.fn.filereadable(path) == 1
assert(exists, 'file does not exist: ' .. path)
vim.cmd('edit ' .. path)
end
function M.safe_require(module)
local ok, result = pcall(require, module)
if not ok then
vim.notify(
string.format('Failed to load module %s: %s', module, result),
vim.log.levels.ERROR
)
return nil
end
return result
end
return MError Recovery
Graceful Degradation
-- ~/.config/nvim/lua/safe-setup.lua
local M = {}
-- Safely setup a plugin
function M.setup(plugin_name, config)
local ok, plugin = pcall(require, plugin_name)
if not ok then
vim.notify(
string.format('Plugin %s not found, skipping setup', plugin_name),
vim.log.levels.WARN
)
return false
end
local setup_ok, err = pcall(plugin.setup, config)
if not setup_ok then
vim.notify(
string.format('Failed to setup %s: %s', plugin_name, err),
vim.log.levels.ERROR
)
return false
end
return true
end
-- Setup multiple plugins with fallback
function M.setup_fallback(primary, fallback, config)
if not M.setup(primary, config) then
vim.notify('Falling back to ' .. fallback, vim.log.levels.INFO)
M.setup(fallback, config)
end
end
return MUsage:
local safe = require('safe-setup')
-- Try telescope, fallback to fzf
safe.setup_fallback('telescope', 'fzf-lua', {
defaults = { -- config
}
})Protected Calls Wrapper
-- Wrap all plugin setups
local function protected_setup()
local success_count = 0
local fail_count = 0
local plugins = {
{ 'telescope', telescope_config },
{ 'lspconfig', lsp_config },
{ 'cmp', cmp_config },
}
for _, plugin_info in ipairs(plugins) do
local name, config = plugin_info[1], plugin_info[2]
local ok, err = pcall(function()
require(name).setup(config)
end)
if ok then
success_count = success_count + 1
else
fail_count = fail_count + 1
vim.notify(
string.format('Failed to setup %s: %s', name, err),
vim.log.levels.ERROR
)
end
end
print(string.format(
'Plugin setup complete: %d successful, %d failed',
success_count, fail_count
))
end
protected_setup()Debugging Commands Reference
Essential Debug Commands
-- Create debug command palette
vim.api.nvim_create_user_command('DebugInfo', function()
print('=== Debug Information ===')
print('Neovim version: ' .. vim.fn.execute('version'))
print('Config path: ' .. vim.fn.stdpath('config'))
print('Data path: ' .. vim.fn.stdpath('data'))
print('Loaded plugins: ' .. #require('lazy').plugins())
print('Active LSP clients: ' .. #vim.lsp.get_active_clients())
print('Current buffer: ' .. vim.api.nvim_buf_get_name(0))
print('Filetype: ' .. vim.bo.filetype)
end, {})
-- Inspect value under cursor (visual mode)
vim.keymap.set('v', '<leader>di', function()
local start_pos = vim.fn.getpos("'<")
local end_pos = vim.fn.getpos("'>")
local lines = vim.api.nvim_buf_get_lines(
0,
start_pos[2] - 1,
end_pos[2],
false
)
local text = table.concat(lines, '\n')
-- Try to evaluate as Lua
local chunk, err = loadstring('return ' .. text)
if chunk then
local ok, result = pcall(chunk)
if ok then
print(vim.inspect(result))
else
print('Error evaluating: ' .. result)
end
else
print('Not valid Lua: ' .. err)
end
end, { desc = 'Debug: Inspect selection' })Summary
Debugging Workflow
Reproduce the issue – Consistent reproduction is key
Isolate – Use minimal config to eliminate variables
Measure – Add logging/profiling to understand behavior
Hypothesize – Form theories about the cause
Test – Verify theories with targeted changes
Fix – Apply solution and test thoroughly
Document – Record the issue and solution
Testing Checklist
✅ Unit tests for custom Lua modules
✅ Integration tests for plugin interactions
✅ Keymap conflict detection
✅ Health checks for dependencies
✅ Performance regression tests
✅ Configuration validation on startup
Common Issues and Solutions
| Issue | Debug Approach |
|---|---|
| Slow startup | --startuptime, profile plugins |
| LSP not attaching | Check :LspInfo, review logs |
| Keymap not working | Check :map <key> for conflicts |
| Plugin error | :messages, check :checkhealth |
| High memory | Profile buffer/window count |
| Unexpected behavior | Minimal config + bisection |
Next Steps
In Chapter #40: Custom Statusline and UI Components, we’ll explore:
Building statuslines from scratch with Lua
Creating custom floating windows
Implementing custom pickers and selectors
Advanced UI patterns with virtual text
Performance-optimized UI components
The debugging and testing techniques from this chapter ensure you can maintain complex configurations reliably. Combined with the performance optimizations from Chapter #38, you have a solid foundation for building advanced UI features.