Configuration

Everything starts with ~/.config/nvim/init.lua. This guide covers the core configuration patterns: options, keymaps, autocommands, and user commands.

Minimal init.lua

-- Set leader key (must be before any keymaps that use <leader>)
vim.g.mapleader = " "
vim.g.maplocalleader = " "

-- Editor options
vim.o.number = true          -- Line numbers
vim.o.relativenumber = true  -- Relative line numbers
vim.o.signcolumn = "yes"     -- Always show sign column
vim.o.clipboard = "unnamedplus"  -- System clipboard
vim.o.undofile = true        -- Persistent undo
vim.o.ignorecase = true      -- Case-insensitive search...
vim.o.smartcase = true       -- ...unless uppercase used
vim.o.splitright = true      -- Open vertical splits right
vim.o.splitbelow = true      -- Open horizontal splits below
vim.o.updatetime = 250       -- Faster CursorHold events
vim.o.cursorline = true      -- Highlight current line

-- Indentation
vim.o.tabstop = 2
vim.o.shiftwidth = 2
vim.o.expandtab = true       -- Spaces, not tabs

Gotcha: vim.g.mapleader must be set BEFORE any keymap that uses <leader>. If you set it after, those keymaps will use the old leader (backslash by default).

Options

NeoVim has three option accessors. Use the right one:

AccessorWhen to useExample
vim.oSimple values (string, number, boolean)vim.o.number = true
vim.optList/map options that need append/removevim.opt.wildignore:append("*.o")
vim.bo / vim.woBuffer/window-specific overridesvim.bo.filetype = "lua"
-- vim.opt handles list options nicely
vim.opt.completeopt = { "menu", "menuone", "noselect" }
vim.opt.shortmess:append("c")  -- Don't show completion messages
vim.opt.path:append("**")      -- Search subdirectories

-- Check current value
print(vim.inspect(vim.opt.completeopt:get()))
-- { "menu", "menuone", "noselect" }

Keymaps

Use vim.keymap.set() for all keymaps:

-- Basic pattern: vim.keymap.set(mode, lhs, rhs, opts)

-- Normal mode
vim.keymap.set("n", "<leader>w", "<cmd>write<cr>", { desc = "Save file" })

-- Multiple modes
vim.keymap.set({ "n", "v" }, "<leader>y", '"+y', { desc = "Copy to clipboard" })

-- Lua function as handler
vim.keymap.set("n", "<leader>q", function()
  local wins = vim.api.nvim_list_wins()
  if #wins > 1 then
    vim.cmd.close()
  else
    vim.cmd.quit()
  end
end, { desc = "Smart close" })

-- Buffer-local keymap
vim.keymap.set("n", "K", vim.lsp.buf.hover, { buffer = true, desc = "Hover docs" })

-- Silent and no remap (defaults, but explicit for clarity)
vim.keymap.set("n", "j", "gj", { silent = true, noremap = true })

Common options:

OptionDefaultPurpose
descnilDescription (shown in :map, which-key, etc.)
buffernilBuffer number or true for current
silentfalseDon’t echo the command
noremaptrueDon’t remap recursively
exprfalseRHS is an expression
remapfalseAllow recursive mapping

Tip: Always set desc on keymaps. It shows up in :map, telescope keymaps picker, and which-key. Your future self will thank you.

Autocommands

-- Basic autocommand
vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*",
  callback = function()
    -- Remove trailing whitespace
    local cursor = vim.api.nvim_win_get_cursor(0)
    vim.cmd([[%s/\s\+$//e]])
    vim.api.nvim_win_set_cursor(0, cursor)
  end,
})

-- Autocommand groups (prevent duplicates on config reload)
local augroup = vim.api.nvim_create_augroup("MyConfig", { clear = true })

vim.api.nvim_create_autocmd("TextYankPost", {
  group = augroup,
  callback = function()
    vim.hl.on_yank({ timeout = 200 })  -- Highlight yanked text briefly
  end,
})

vim.api.nvim_create_autocmd("FileType", {
  group = augroup,
  pattern = { "go", "rust" },
  callback = function()
    vim.bo.tabstop = 4
    vim.bo.shiftwidth = 4
    vim.bo.expandtab = false
  end,
})

-- Pattern matching
vim.api.nvim_create_autocmd("BufRead", {
  group = augroup,
  pattern = "*.md",
  callback = function()
    vim.wo.wrap = true
    vim.wo.linebreak = true
  end,
})

Gotcha: Always use autocommand groups with { clear = true }. Without this, re-sourcing your config creates duplicate autocommands that fire multiple times.

User Commands

-- Simple command
vim.api.nvim_create_user_command("Config", function()
  vim.cmd.edit("~/.config/nvim/init.lua")
end, { desc = "Edit NeoVim config" })

-- Command with arguments
vim.api.nvim_create_user_command("Grep", function(opts)
  vim.cmd("silent grep! " .. opts.args)
  vim.cmd.copen()
end, { nargs = "+", desc = "Grep and open quickfix" })

-- Command with completion
vim.api.nvim_create_user_command("Theme", function(opts)
  vim.cmd.colorscheme(opts.args)
end, {
  nargs = 1,
  complete = "color",  -- Built-in colorscheme completion
  desc = "Switch colorscheme",
})

File Organization

For larger configs, split into modules:

~/.config/nvim/
  init.lua              -- Entry point, sources everything
  lua/
    config/
      options.lua       -- vim.o.* settings
      keymaps.lua       -- vim.keymap.set() calls
      autocmds.lua      -- autocommands
    plugins/
      lsp.lua           -- LSP configuration
      treesitter.lua    -- Tree-sitter setup
      telescope.lua     -- Fuzzy finder config
-- init.lua
vim.g.mapleader = " "
require("config.options")
require("config.keymaps")
require("config.autocmds")
-- lua/config/options.lua
vim.o.number = true
vim.o.relativenumber = true
-- ... rest of options

Tip: Files in lua/ are loaded via require(). Files in plugin/ auto-run on startup. Keep your config in lua/ and require() it from init.lua for explicit control over load order.

Diagnostic Display

Configure how diagnostics (errors, warnings) appear:

vim.diagnostic.config({
  virtual_text = {
    prefix = ">>",
    spacing = 2,
  },
  signs = true,
  underline = true,
  update_in_insert = false,  -- Don't update while typing
  severity_sort = true,      -- Show errors before warnings
  float = {
    border = "rounded",
    source = true,           -- Show diagnostic source
  },
})