Modern Neovim from Scratch: Lua Modular Architecture, Lazy.nvim, LSP, and Treesitter
A practical guide to configuring a fast, modular Neovim development environment using Lua, Lazy.nvim, native LSP with Mason, nvim-cmp, and Treesitter.
Vim configurations historically relied on monolithic init.vim files written in Vimscript, external node processes like coc.nvim, and plugin managers like vim-plug.
Neovim v0.9+ replaces all three with a native Lua runtime, an embedded Language Server Protocol (LSP) client, and syntax parsing through Tree-sitter.
The config below is split across five files so that a broken plugin is a broken file, not a broken editor.
~/.config/nvim/
├── init.lua
└── lua/
├── config/
│ ├── options.lua # Editor defaults and UI behaviors
│ ├── keymaps.lua # Keybindings and ergonomics
│ └── lazy.lua # Lazy.nvim bootstrapper
└── plugins/
├── lsp.lua # Mason, nvim-lspconfig, and nvim-cmp
├── treesitter.lua # AST syntax parsing and highlighting
├── telescope.lua # Fuzzy finding and ripgrep search
└── colorscheme.luaStep 1: Set the entry point and editor defaults
~/.config/nvim/init.lua is the entry point. It does nothing except require the modules under lua/.
-- ~/.config/nvim/init.lua
require("config.options")
require("config.keymaps")
require("config.lazy")Editor options (lua/config/options.lua)
Define standard buffer and window behavior using vim.opt:
-- ~/.config/nvim/lua/config/options.lua
local opt = vim.opt
-- Line numbers
opt.number = true
opt.relativenumber = true
-- Tabs and Indentation
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.autoindent = true
opt.smartindent = true
-- Search behavior
opt.ignorecase = true
opt.smartcase = true
-- Visuals and Splits
opt.termguicolors = true
opt.signcolumn = "yes"
opt.scrolloff = 8
opt.splitright = true
opt.splitbelow = true
opt.cursorline = true
-- System integration
opt.clipboard = "unnamedplus"
opt.undofile = true -- Persistent undo history across editor restarts
opt.updatetime = 250
opt.timeoutlen = 300Ergonomic keymaps (lua/config/keymaps.lua)
Set the spacebar as the leader key and map window, buffer, and navigation shortcuts:
-- ~/.config/nvim/lua/config/keymaps.lua
vim.g.mapleader = " "
vim.g.maplocalleader = " "
local map = vim.keymap.set
-- Clear search highlights on pressing Escape in normal mode
map("n", "<Esc>", "<cmd>nohlsearch<CR>")
-- Window navigation using Ctrl + hjkl
map("n", "<C-h>", "<C-w>h", { desc = "Move to left window" })
map("n", "<C-j>", "<C-w>j", { desc = "Move to lower window" })
map("n", "<C-k>", "<C-w>k", { desc = "Move to upper window" })
map("n", "<C-l>", "<C-w>l", { desc = "Move to right window" })
-- Keep cursor centered during half-page scrolling and search jumps
map("n", "<C-d>", "<C-d>zz")
map("n", "<C-u>", "<C-u>zz")
map("n", "n", "nzzzv")
map("n", "N", "Nzzzv")
-- Move selected text blocks up and down in visual mode
map("v", "J", ":m '>+1<CR>gv=gv")
map("v", "K", ":m '<-2<CR>gv=gv")
-- Buffer management
map("n", "<leader>bd", "<cmd>bdelete<CR>", { desc = "Delete buffer" })
map("n", "[b", "<cmd>bprevious<CR>", { desc = "Previous buffer" })
map("n", "]b", "<cmd>bnext<CR>", { desc = "Next buffer" })Step 2: Bootstrap package management with Lazy.nvim
lazy.nvim provides fast plugin loading with automatic lazy-loading on keymaps, filetypes, or events.
Create lua/config/lazy.lua to clone and initialize the package manager automatically:
-- ~/.config/nvim/lua/config/lazy.lua
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({
spec = {
{ import = "plugins" },
},
defaults = {
lazy = true,
},
install = {
colorscheme = { "tokyonight", "habamax" },
},
})Step 3: Replace regex highlighting with Tree-sitter
Traditional Vim syntax highlighting relies on regular expressions, which can be slow and fail to parse complex nested language scopes.
Tree-sitter generates a concrete syntax tree (AST) directly in memory, updating incrementally on every keystroke:
-- ~/.config/nvim/lua/plugins/treesitter.lua
return {
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
event = { "BufReadPost", "BufNewFile" },
config = function()
require("nvim-treesitter.configs").setup({
ensure_installed = {
"c", "cpp", "lua", "vim", "vimdoc",
"python", "rust", "go", "typescript",
"tsx", "javascript", "html", "css", "json",
},
auto_install = true,
highlight = {
enable = true,
additional_vim_regex_highlighting = false,
},
indent = {
enable = true,
},
})
end,
}Step 4: Wire fuzzy finding to ripgrep with Telescope
Telescope uses ripgrep and fd to scan files, git status, and code symbols:
-- ~/.config/nvim/lua/plugins/telescope.lua
return {
"nvim-telescope/telescope.nvim",
tag = "0.1.6",
dependencies = {
"nvim-lua/plenary.nvim",
{
"nvim-telescope/telescope-fzf-native.nvim",
build = "make",
cond = function()
return vim.fn.executable("make") == 1
end,
},
},
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 = "Find Buffers" },
{ "<leader>fh", "<cmd>Telescope help_tags<CR>", desc = "Help Tags" },
},
config = function()
local telescope = require("telescope")
telescope.setup({
defaults = {
path_display = { "truncate" },
file_ignore_patterns = { "node_modules", ".git/" },
},
})
pcall(telescope.load_extension, "fzf")
end,
}Step 5: Attach native LSP and autocompletion
Language Server Protocol integration requires three coordinating layers:
mason.nvim: Manages language server binaries (gopls,pyright,ts_ls,rust-analyzer).nvim-lspconfig: Configures client options and hooks keymaps on server attachment (on_attach).nvim-cmp: Renders autocompletion popups, snippet expansions, and signature help.
-- ~/.config/nvim/lua/plugins/lsp.lua
return {
-- LSP Configuration
{
"neovim/nvim-lspconfig",
event = { "BufReadPre", "BufNewFile" },
dependencies = {
"williamboman/mason.nvim",
"williamboman/mason-lspconfig.nvim",
"hrsh7th/cmp-nvim-lsp",
},
config = function()
local lspconfig = require("lspconfig")
local capabilities = require("cmp_nvim_lsp").default_capabilities()
-- Keybindings applied when LSP attaches to buffer
local on_attach = function(_, bufnr)
local nmap = function(keys, func, desc)
vim.keymap.set("n", keys, func, { buffer = bufnr, desc = "LSP: " .. desc })
end
nmap("gd", vim.lsp.buf.definition, "Goto Definition")
nmap("gr", vim.lsp.buf.references, "Goto References")
nmap("gi", vim.lsp.buf.implementation, "Goto Implementation")
nmap("K", vim.lsp.buf.hover, "Hover Documentation")
nmap("<leader>rn", vim.lsp.buf.rename, "Rename Symbol")
nmap("<leader>ca", vim.lsp.buf.code_action, "Code Action")
nmap("<leader>d", vim.diagnostic.open_float, "Show Diagnostic")
nmap("[d", vim.diagnostic.goto_prev, "Previous Diagnostic")
nmap("]d", vim.diagnostic.goto_next, "Next Diagnostic")
end
require("mason").setup()
require("mason-lspconfig").setup({
ensure_installed = {
"lua_ls",
"pyright",
"ts_ls",
"gopls",
"rust_analyzer",
},
handlers = {
function(server_name)
lspconfig[server_name].setup({
capabilities = capabilities,
on_attach = on_attach,
})
end,
["lua_ls"] = function()
lspconfig.lua_ls.setup({
capabilities = capabilities,
on_attach = on_attach,
settings = {
Lua = {
diagnostics = {
globals = { "vim" },
},
},
},
})
end,
},
})
end,
},
-- Autocompletion Engine
{
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-nvim-lsp",
"hrsh7th/cmp-buffer",
"hrsh7th/cmp-path",
"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(),
["<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" },
}),
})
end,
},
}Step 6: Pick a colorscheme
Install Tokyo Night for a high-contrast terminal interface:
-- ~/.config/nvim/lua/plugins/colorscheme.lua
return {
{
"folke/tokyonight.nvim",
lazy = false,
priority = 1000,
config = function()
vim.cmd.colorscheme("tokyonight-night")
end,
},
}Verifying startup performance
Profile your startup time using Neovim's built-in profiler flag:
nvim --startuptime /tmp/nvim_startup.log
tail -n 10 /tmp/nvim_startup.logWith lazy-loading across syntax, LSP, and search, cold start stays under 30 milliseconds. Everything except the colorscheme is deferred until a buffer, a keymap, or an insert-mode event asks for it.
When this is the wrong choice
- You edit files on machines you don't control. This config assumes
git,make,ripgrep, andfdare present and that Mason can reach the network to download server binaries. On a locked-down jump host, none of that holds, and stock vim with no plugins is the thing that always starts. - You already have a config you know by heart. The payoff here is startup time and a file layout you can bisect. Neither is worth trading away the muscle memory of a setup that already works.
- Your language has no Tree-sitter grammar and no language server. Steps 3 and 5 are most of the value. Without them you have installed a plugin manager, a fuzzy finder, and a colorscheme.