Neovim Projects in Practice

Build 10 production ready Neovim configurations, language specific workflows, and team development setups with modern Lua

Scott S. Jackson

Copyright © 2025 Scott S. Jackson

All rights reserved.

No part of this publication may be reproduced, stored in a retrieval system, or transmitted in any form or by any means, electronic, mechanical, photocopying, recording, or otherwise, without prior written permission from the publisher, except in the case of brief quotations in reviews or articles.

Neovim is a trademark of its respective owners. All other product names, company names, and logos mentioned in this book are the property of their respective owners. Use of these names is for identification purposes only and does not imply endorsement.

The information in this book is provided on an “as is” basis. The author and publisher have made every effort to ensure the accuracy of the content at the time of publication. Software evolves, and configuration defaults, APIs, and plugin behavior can change over time. The author and publisher disclaim any liability for errors, omissions, or any loss or damage arising from the use of the material in this book.

All examples, configuration snippets, and code samples are provided for educational purposes. You are responsible for reviewing and testing any commands, scripts, or configuration changes before using them in production environments or on critical systems.

Preface

This book grew out of a simple need. Developers wanted to use Neovim as a serious editor for real projects, but most examples stopped at personal dotfiles or screenshots. Teams work in large repositories, with multiple languages, strict review processes, and shared constraints. They need Neovim setups that hold up under that pressure.

Neovim Projects in Practice focuses on that gap. The book treats your configuration as a codebase, not as a collection of random snippets. Each chapter builds a complete, production focused setup around a concrete context, such as a TypeScript web stack, a Python backend, systems work in Go and Rust, or a mixed language monorepo. You see the full path from blank config to daily workflow, including the trade offs behind each choice.

List of Chapters

Chapter 1: A Minimal, Reliable Neovim Core
A clean starting configuration, directory layout, a delegated init.lua, core options, keymaps, and essential autocommands for daily editing.

Chapter 2: Web Development Setup (TypeScript, React, Node.js)
Treesitter and LSP for JavaScript, TypeScript, JSX, and TSX, completion and snippets, Prettier and lint integration, fuzzy finding, project navigation, and Jest or Vitest workflows for React features.

Chapter 3: Python Backend and Data Engineering Environment
Python tooling with virtual environments, formatting with Black and isort, linting, pytest workflows, integrated terminals and REPLs, and an example debugging and refactoring session.

Chapter 4: Go and Rust Systems Programming Workspace
Compiler and language server setup with gopls and rust-analyzer, Treesitter and LSP for Go, Rust, and related filetypes, build and test integration, Git workflows, and an example CLI or service loop.

Chapter 5: Full Stack Monorepo Setup
Monorepo goals and challenges, multi language plugin selection, Treesitter and LSP coverage for backend, frontend, and infrastructure, large tree search strategies, per project configuration, and an example full stack change.

Chapter 6: Neovim for Writing, Notes, and Documentation
Writing oriented goals, Markdown and documentation plugins, UI tuning for prose, folding and outlines, templates and snippets, and a multi file writing and revision workflow.

Chapter 7: Tmux and Neovim Integrated Terminal Workflow
Role separation between tmux and Neovim, base layouts, integrated terminals and toggle keys, navigation between Neovim windows and tmux panes, running tests, builds, and logs, and an example day with services and code together.

Chapter 8: Authoring and Publishing a Small Neovim Plugin
Defining a plugin idea and scope, project layout, public API design with setup, options, commands, and keymaps, implementing behavior with buffers and scratch views, autocommands and docs, and releasing and iterating via Git.

Chapter 9: Team Ready Neovim Configuration Template
Goals and constraints for a shared config, per user and per project overrides, standard team keymaps, LSP and Git workflows, version control and review for the config itself, and an onboarding flow for new team members.

Chapter 10: Performance Hardened Neovim for Large Codebases
Goals and symptoms of slow configs, startup profiling, refactoring slow modules and autocommands, aggressive lazy loading and feature toggles, tuned LSP and completion, and an example work session in a huge monorepo with the optimized setup.

Table of content

Preface

Chapter 1: Minimal, Fast, and Reliable Neovim Core

1.1 Goals, requirements, and starting from a blank config

1.2 Directory layout and a clean init.lua that delegates to modules

1.3 Core options, keymaps, and autocommands for daily editing

1.4 Minimal plugin manager setup, first plugins, and basic Treesitter/LSP

1.5 Simple UI polish, statusline, theme, and example daily workflow

Chapter 2: Web Development Setup (TypeScript, React, Node.js)

2.1 Project goals, environment, and web focused plugin selection

2.2 Treesitter and LSP for JavaScript, TypeScript, JSX, and TSX

2.3 Completion, snippets, formatting with Prettier, and lint integration

2.4 Fuzzy finding, file explorers, and project root detection for web repos

2.5 Jest or Vitest test workflows and an example React feature flow

Chapter 3: Python Backend and Data Engineering Environment

3.1 Goals, Python toolchain, and plugin selection

3.2 Treesitter and LSP configuration for Python and common config files

3.3 Virtual environments, formatting (Black, isort), and linting

3.4 Test workflows with pytest and integrated terminals/REPLs

3.5 Example backend debugging and refactoring session

Chapter 4: Go and Rust Systems Programming Workspace

4.1 Goals, compilers, and language servers (gopls, rust-analyzer)

4.2 Treesitter and LSP setup for Go, Rust, and related filetypes

4.3 Build and test integration with tasks, quickfix, and diagnostics

4.4 Git workflows tuned for frequent small commits and code review

4.5 Example implementation and test loop for a CLI or service

Chapter 5: Full Stack Monorepo Setup

5.1 Monorepo challenges, goals, and multi language plugin set

5.2 Treesitter and LSP coverage plan across backend, frontend, and infra

5.3 Fuzzy find, live grep, and symbol search optimized for large trees

5.4 Per project configuration, root detection, and local overrides

5.5 Example full stack change across backend, frontend, and infrastructure

Chapter 6: Neovim for Writing, Notes, and Documentation

6.1 Writing goals and differences from coding oriented setups

6.2 Lightweight plugins for Markdown, notes, and documentation

6.3 UI tuning for prose, including themes, statusline, and line settings

6.4 Folding, outlines, templates, and snippets for documents and notes

6.5 Example writing and revision workflow for a multi file document

Chapter 7: Tmux and Neovim Integrated Terminal Workflow

7.1 Role separation between tmux and Neovim, and base layout

7.2 Integrated terminals, toggle keys, and split strategies

7.3 Navigation mappings between Neovim windows and tmux panes

7.4 Running tests, builds, and logs in embedded and floating terminals

7.5 Example day working with services, logs, and code together

Chapter 8: Authoring and Publishing a Small Neovim Plugin

8.1 Defining the plugin idea, scope, and project layout

8.2 Public API design with setup, options, commands, and keymaps

8.3 Implementing behavior with buffers, windows, and scratch views

8.4 Autocommands, events, docs, and README for user facing behavior

8.5 Tagging versions, hosting on Git, and iterating based on feedback

Chapter 9: Team Ready Neovim Configuration Template

9.1 Goals, constraints, and core module set for a shared config

9.2 Structure for per user and per project overrides

9.3 Standard keymaps, LSP settings, and Git workflows for the team

9.4 Version control, code review, and change management for the config

9.5 Example onboarding flow for a new team member using the template

Chapter 10: Performance Hardened Neovim for Large Codebases

10.1 Goals and symptoms of slow configs in big projects

10.2 Startup profiling with startuptime and plugin profiling tools

10.3 Identifyig and refactoring slow modules, autocommands, and plugins

10.4 Aggressive lazy loading, feature toggles, and tuned LSP/completion

10.5 Example work session in a huge monorepo with the optimized setup

Chapter 1: Minimal, Fast, and Reliable Neovim Core

1.1 Goals, requirements, and starting from a blank config

You will set up Neovim so it starts from a clean, controlled state.
You define what loads, where it lives on disk, and how it behaves.

By the end of this section you will be able to:

●        Start Neovim from a truly blank configuration.

●        Point Neovim to a Lua based config in the correct directory.

●        Prove that Neovim runs your Lua code.

●        Prepare a folder structure for later modules and plugins.

●        Keep any old configuration safe but out of the way.

Requirements

You need a basic development setup, not a full toolchain.

Required tools:

●        A terminal on your system

●        Neovim 0.9 or newer

●        Git installed

●        Internet access for fetching plugins later

●        Basic command line usage

Check Neovim and Git:

nvim --version

git --version

The first line of each command shows the version.
If Neovim is older than 0.9, upgrade before you continue. Later chapters rely on newer Lua APIs.

Where Neovim Looks for Configuration

Neovim searches standard paths to load user configuration.

On Linux and macOS:

●        Config directory: ~/.config/nvim

●        Main config file: ~/.config/nvim/init.lua

On Windows:

●        Config directory: %LOCALAPPDATA%\nvim

●        Main config file: %LOCALAPPDATA%\nvim\init.lua

Neovim uses this order:

If init.lua exists, load it as Lua.

Else if init.vim exists, load it as Vimscript.

Else use built in defaults.

This book uses init.lua and Lua modules only.
If you have an init.vim, it will be ignored once init.lua appears.

Backing Up Any Existing Config

You move existing configs out of the way, not delete them.
This avoids conflicts and gives you a clean starting point.

On Linux and macOS:

mv ~/.config/nvim ~/.config/nvim_backup 2>/dev/null

mv ~/.vim ~/.vim_backup 2>/dev/null

Explanation:

●        mv ~/.config/nvim ~/.config/nvim_backup
Renames the Neovim config directory to nvim_backup.

●        2>/dev/null hides the error if the directory does not exist.

●        The second command does the same for a classic Vim configuration.

On Windows (PowerShell):

if (Test-Path $env:LOCALAPPDATA\nvim) {

Move-Item $env:LOCALAPPDATA\nvim "$env:LOCALAPPDATA\nvim_backup"

}

Explanation:

●        Test-Path checks if the directory exists.

●        Move-Item renames nvim to nvim_backup.

●        You keep the backup so you can copy pieces into the new config later if you want.

After this step, Neovim will not see your old configuration.
You have a safe backup and a clean slate.

Running Neovim With Zero Config

Sometimes you need to confirm whether a bug comes from Neovim itself or from your config.
For that, you run Neovim without any user files.

nvim -u NONE

Explanation:

●        -u NONE tells Neovim to skip all user configuration files.

●        No init.lua, no init.vim, no plugins.

●        Only built in defaults are active.

You will use this mode later when you debug slow startup or broken plugins.

Creating a Blank Lua Based Config

You now create the smallest possible configuration that Neovim can load.

On Linux and macOS:

mkdir -p ~/.config/nvim

touch ~/.config/nvim/init.lua

Explanation:

●        mkdir -p ~/.config/nvim
Creates the Neovim config directory if it is missing.
The -p flag avoids errors if the directory already exists.

●        touch ~/.config/nvim/init.lua
Creates an empty init.lua file if it does not exist.
If the file exists, touch updates its timestamp but keeps the content.

On Windows (PowerShell):

mkdir $env:LOCALAPPDATA\nvim -ErrorAction SilentlyContinue

ni $env:LOCALAPPDATA\nvim\init.lua -ItemType File -Force

Explanation:

●        mkdir creates the nvim directory.

●        -ErrorAction SilentlyContinue hides errors if the folder already exists.

●        ni is short for New-Item.

●        -ItemType File creates a file, not a directory.

●        -Force overwrites an empty file if it already exists.

Open Neovim:

nvim

Neovim now loads init.lua.
The file is empty, so behavior still matches the built in defaults.

Adding the First Lua Option

You need a visible change that proves your Lua config runs.
A simple choice is enabling line numbers.

Open init.lua and add:

-- Turn on line numbers in all normal windows.

-- vim.o is the table for global options.

-- Setting vim.o.number = true enables the "number" option.

vim.o.number = true

Line by line:

●        The first comment explains the feature in plain English.

●        The second comment states that vim.o controls global options.

●        The third comment connects the specific option name to the behavior.

●        vim.o.number = true sets the global option number to true, which displays absolute line numbers in every window.

Save the file and restart Neovim.

If line numbers appear on the left edge:

●        Neovim found your config directory.

●        Neovim loaded init.lua.

●        Lua executed without error.

If you see no line numbers:

●        Confirm the path to init.lua.

●        Confirm there is no typo in the file name.

●        On Windows, confirm that the config lives in %LOCALAPPDATA%\nvim.

Why Start From a Blank Config

Starting from zero gives you full control.

Key advantages:

No hidden behavior
Every option, keymap, and plugin exists because you put it there.
You do not chase side effects from large starter templates.

Easier debugging
When a problem appears, you can comment out modules one by one.
You know how each piece is wired.

Portable patterns
Once you understand a clean base, you can repeat the pattern for new languages and projects.
You avoid cargo cult configuration.

Team use
A small, clear Lua codebase is easier to share in a repository.
Teammates see readable modules instead of a random pile of snippets.

Preparing the Lua Module Structure

Neovim adds a lua directory inside your config to the Lua search path.
Files in that directory become modules that you load with require.

Create the directory.

On Linux and macOS:

mkdir -p ~/.config/nvim/lua

On Windows (PowerShell):

mkdir $env:LOCALAPPDATA\nvim\lua -ErrorAction SilentlyContinue

Folder layout so far:

●        ~/.config/nvim/init.lua or %LOCALAPPDATA%\nvim\init.lua

●        ~/.config/nvim/lua/ or %LOCALAPPDATA%\nvim\lua\

You will later add modules such as:

●        lua/options.lua for core options

●        lua/keymaps.lua for key mappings

●        lua/plugins.lua for plugin manager setup

●        lua/lsp/ for language server configuration

●        lua/lang/ for language specific workflows

For now, you do not need these files.
You only need the structure ready for later chapters.

Example: Preparing init.lua To Use Modules Later

You can also prepare init.lua so it is ready to call modules when they exist, while still staying valid today.

init.lua:

-- Enable basic options immediately.

vim.o.number = true       -- Show line numbers so navigation is easier.

vim.o.relativenumber = true -- Show relative numbers for motion commands.

-- Later in the book you add modules like:

-- local ok, _ = pcall(require, "options")

-- if ok then

--   -- The options module will apply more detailed settings.

-- end

--

-- These require lines are commented out for now.

-- This keeps the file valid and avoids errors when the modules do not exist yet.

Explanation:

●        You set another option, vim.o.relativenumber, to show relative line numbers.

●        This combination is common. The current line shows its absolute number, other lines show distance from the cursor.

●        The commented require block shows how you will later load modules in a safe way using pcall.

●        You keep it commented so Neovim does not throw an error about missing modules.

This prepares readers for how the config will grow, without breaking the current minimal setup.

Checking That Everything Works

At this point you should be able to:

●        Start Neovim normally with nvim.

●        See line numbers and relative numbers in the left gutter.

●        Confirm that only options you set in init.lua are active.

●        Know that your old configuration lives in a backup folder.

You now have a clean, controlled base configuration that runs Lua successfully.
From here, you will start introducing structured modules, plugin management, and language tooling, always building on this blank but working foundation.

1.2 Directory layout and a clean init.lua that delegates to modules

A clear directory layout makes your Neovim configuration easier to read, maintain, and extend. Modern Neovim uses Lua for configuration, so you organize your files like a small software project. This structure separates responsibilities, keeps the main init.lua simple, and moves real logic into modules.

Neovim configuration directory

Neovim loads configuration files from a specific directory. On Linux and macOS this is usually ~/.config/nvim. On Windows this is usually ~/AppData/Local/nvim. Inside this directory you place your init.lua and a lua directory that holds all your modules.

Example structure:

nvim/

init.lua

lua/

core/

options.lua

keymaps.lua

autocmds.lua

plugins/

init.lua

lsp.lua

treesitter.lua

completion.lua

utils/

helpers.lua

This layout groups related code. The core directory sets basic editor behavior. The plugins directory configures plugin setups. The utils directory stores helper functions you reuse across multiple modules.

Why use modules

Modules let you split configuration into smaller files. Neovim loads modules with require(). Each module returns a Lua table or runs setup code directly. You avoid a large init.lua file that mixes options, keymaps, and plugin settings. Smaller files reduce debugging time and improve clarity.

Designing a clean init.lua

The goal is to keep init.lua as short as possible. It serves as an entry point that loads modules. It does not hold configuration logic. This keeps the editor fast to load and easy to understand.

Example init.lua:

-- Load editor options from core/options.lua

require("core.options")

-- Load custom keymaps from core/keymaps.lua

require("core.keymaps")

-- Load autocommands from core/autocmds.lua

require("core.autocmds")

-- Load plugin configurations from lua/plugins/init.lua

require("plugins")

Each call to require() loads a module. Neovim executes the module once and caches it. When you call it again, it uses the cached version, which avoids unnecessary reloading. This helps keep startup time stable.

Core module structure

A module file defines configuration for one area. For example, options.lua sets basic editor behavior.

Example lua/core/options.lua:

-- Get Neovim's option handler

local o = vim.opt

-- Enable line numbers so you track code positions

o.number = true

-- Enable relative numbers to support movement commands

o.relativenumber = true

-- Set tab width to 4 spaces for consistent formatting

o.tabstop = 4

-- Set indentation width to 4 spaces for code alignment

o.shiftwidth = 4

-- Convert tabs to spaces to avoid mixed indentation

o.expandtab = true

This file contains only option settings. Each line adjusts one behavior. The o variable shortens access to vim.opt and keeps the file readable.

Example lua/core/keymaps.lua:

-- Shortcut for Neovim's keymap API

local map = vim.keymap.set

-- Set space as the leader key

map("n", "<Space>", "", {})

-- Clear previous leader key mapping

vim.g.mapleader = " "

-- Save file with Ctrl s in normal mode

map("n", "<C-s>", ":w<CR>", {})

-- Quit with Ctrl q in normal mode

map("n", "<C-q>", ":q<CR>", {})

Each line defines a keymap. The first argument is the mode. The second is the key. The third is the command. The fourth holds options.

Example lua/core/autocmds.lua:

-- Create a group for user autocommands

local group = vim.api.nvim_create_augroup("UserGroup", {})

-- Highlight text on yank for visual feedback

vim.api.nvim_create_autocmd("TextYankPost", {

group = group,                   -- assign to the group

callback = function()            -- function that runs on yank

vim.highlight.on_yank()        -- call built in highlight function

end,

})

This file defines automation that reacts to events. Autocommands run logic when conditions occur, such as file writes or cursor movements.

Plugin module structure

Plugin configuration lives inside the plugins directory. An init.lua file inside this directory loads each plugin module. This keeps one place responsible for plugin startup.

Example lua/plugins/init.lua:

-- Load LSP configuration

require("plugins.lsp")

-- Load Treesitter configuration

require("plugins.treesitter")

-- Load completion engine configuration

require("plugins.completion")

Each module configures one plugin or one related group of plugins. This improves readability and lets you add or remove plugins without editing unrelated files.

Example lua/plugins/lsp.lua:

-- Load lspconfig plugin

local lspconfig = require("lspconfig")

-- Configure Lua language server

lspconfig.lua_ls.setup({

settings = {

Lua = {

diagnostics = {

globals = { "vim" },    -- avoid warnings for Neovim globals

},

},

},

})

The module loads the plugin and applies configuration. The structure is easy to navigate because each file handles one topic.

Why this layout matters

This structure helps you treat your Neovim configuration like maintainable code. Each file stays short and focused. You avoid duplication. You update or replace modules without breaking unrelated parts. Teams benefit from this layout because each contributor understands where configuration belongs.

When you follow this design, your Neovim setup grows in a controlled way. You keep a stable editor, a clear directory tree, and a simple init.lua that directs the rest of the system.

1.3 Core options, keymaps, and autocommands for daily editing

Core settings shape how Neovim behaves during daily work. Options control editor behavior. Keymaps define shortcuts that speed up actions. Autocommands run tasks when specific events occur. Together they form the foundation of an efficient editing environment.

Core options

Options adjust Neovim features such as indentation, search behavior, and interface layout. You set options through the vim.opt API. Grouping them inside a module keeps the configuration clear.

Example options.lua:

-- Short reference to Neovim's option table

local o = vim.opt

-- Show line numbers for navigation

o.number = true

-- Show relative numbers to aid movement commands

o.relativenumber = true

-- Convert tabs into spaces for consistent formatting

o.expandtab = true

-- Set indentation width to 4 spaces

o.shiftwidth = 4

-- Set tab width to 4 spaces

o.tabstop = 4

-- Enable smart indentation for nested code blocks

o.smartindent = true

-- Highlight search results

o.hlsearch = true

-- Enable incremental search so matches appear as you type

o.incsearch = true

-- Improve terminal colors

o.termguicolors = true

This module defines predictable defaults. Line numbers support navigation. Indentation settings ensure formatting stays consistent across files. Search options provide visibility when scanning through text. Color settings improve readability in modern terminals.

Keymaps

Keymaps assign actions to keystrokes. They reduce friction by replacing long commands with simple shortcuts. Neovim exposes the mapping API through vim.keymap.set. You place keymaps in their own module to keep them organized.

Example keymaps.lua:

-- Short reference to the keymap function

local map = vim.keymap.set

-- Set space as the leader key for custom shortcuts

vim.g.mapleader = " "

-- Save the current file with Ctrl s in normal mode

map("n", "<C-s>", ":w<CR>", {})

-- Quit the current window with Ctrl q in normal mode

map("n", "<C-q>", ":q<CR>", {})

-- Clear search highlights with leader h

map("n", "<leader>h", ":nohlsearch<CR>", {})

-- Split window vertically with leader v

map("n", "<leader>v", ":vsplit<CR>", {})

-- Split window horizontally with leader s

map("n", "<leader>s", ":split<CR>", {})

Each mapping specifies a mode, a key, a command, and options. The first argument selects the mode. The second sets the shortcut. The third executes a command. Empty braces represent default settings. These mappings remove slow repetitive commands from daily work.

Autocommands

Autocommands run specific actions when events occur. They automate tasks such as highlighting yanked text or adjusting settings per file type. You group autocommands in a module using vim.api.nvim_create_autocmd.

Example autocmds.lua:

-- Create a group for user autocommands

local group = vim.api.nvim_create_augroup("UserGroup", {})

-- Highlight text after a yank operation

vim.api.nvim_create_autocmd("TextYankPost", {

group = group,                -- assign autocommand to the group

callback = function()         -- run a function after the event

vim.highlight.on_yank()     -- apply temporary highlight to yanked text

end,

})

-- Set text width for Git commit messages

vim.api.nvim_create_autocmd("FileType", {

group = group,                -- place in the same group

pattern = "gitcommit",        -- trigger only for gitcommit file type

callback = function()         -- run adjustment when file opens

vim.opt_local.textwidth = 72

end,

})

The first autocommand improves feedback by highlighting text after a yank. The second adjusts the text width for Git commit messages. Each autocommand runs only when the event matches the pattern or type.

Applying these components

Options provide consistent behavior. Keymaps reduce friction. Autocommands remove repetitive tasks. Placed together inside the core directory, they form a strong foundation for your editing workflow. With these pieces in place, your daily editing environment becomes predictable, clear, and efficient, ready to support more advanced configuration in later sections.

1.4 Minimal plugin manager setup, first plugins, and basic Treesitter/LSP

A plugin manager loads external tools that extend Neovim. A small and stable setup helps you keep control of your configuration. You add plugins through a single entry point, then configure each plugin in its own module. This approach keeps your system modular and predictable.

Choosing a plugin manager

A simple option is lazy.nvim. It loads plugins on demand and keeps startup time low. It installs itself in your Neovim data directory. After installation, you list plugins inside a plugins module.

Installing lazy.nvim

Create a file named lua/plugins/init.lua. Add a bootstrap block that installs lazy.nvim if it is missing.

-- Path to lazy.nvim inside the Neovim data directory

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"

-- Install lazy.nvim if the directory does not exist

if not vim.loop.fs_stat(lazypath) then

-- Clone the plugin from GitHub

vim.fn.system({

"git",

"clone",

"--filter=blob:none",

"https://github.com/folke/lazy.nvim",

lazypath,

})

end

-- Add lazy.nvim to the runtime path so Neovim loads it

vim.opt.rtp.prepend(lazypath)

-- Load the plugin list from this module

require("lazy").setup("plugins.modules")

The bootstrap step checks if the lazy.nvim directory exists. If not, it runs a Git clone. The runtime path line tells Neovim where the plugin manager lives. The last line loads plugin definitions from a directory named plugins/modules.

Declaring your first plugins

Create a directory lua/plugins/modules. Inside it, create a file named init.lua that returns a table of plugin specifications.

Example lua/plugins/modules/init.lua:

return {

-- Treesitter for better syntax highlighting

{

"nvim-treesitter/nvim-treesitter",

build = ":TSUpdate",          -- update parsers after install

},

-- LSP configuration support

{

"neovim/nvim-lspconfig",

},

}

Each entry is a table that describes a plugin. The first field is the plugin repository. The build field runs a command after installation. Returning a table lets lazy.nvim load everything at once.

Basic Treesitter setup

Treesitter improves syntax highlighting and code structure awareness. You configure it inside a dedicated module.

Example lua/plugins/modules/treesitter.lua:

-- Load the Treesitter configuration plugin

local ts = require("nvim-treesitter.configs")

ts.setup({

ensure_installed = { "lua", "python", "javascript" },  -- parsers to install

highlight = {

enable = true,                                       -- turn on highlighting

},

})

The module loads the Treesitter configuration API. The ensure_installed list tells Treesitter which parsers to install. The highlight section turns on syntax highlighting. This improves accuracy in code files.

Basic LSP setup

The Language Server Protocol adds features such as diagnostics and code navigation. The nvim-lspconfig plugin provides setup functions for each server.

Example lua/plugins/modules/lsp.lua:

-- Load Neovim's LSP configuration module

local lspconfig = require("lspconfig")

-- Configure the Lua language server

lspconfig.lua_ls.setup({

settings = {

Lua = {

diagnostics = {

globals = { "vim" },      -- avoid warnings for Neovim globals

},

},

},

})

The module loads the LSP configuration API. The setup function enables the Lua language server. The diagnostics section stops warnings about the global vim variable. This produces cleaner feedback while editing Lua files.

Connecting modules to the system

Make sure your main plugin loader imports all plugin modules. Update lua/plugins/modules/init.lua to load them.

return {

{ "nvim-treesitter/nvim-treesitter", build = ":TSUpdate" },

{ "neovim/nvim-lspconfig" },

-- Load configuration files

{ import = "plugins.modules.treesitter" },

{ import = "plugins.modules.lsp" },

}

The import field tells lazy.nvim to load configuration modules from the directory. Each module performs setup for its related plugin.

With a small plugin list, a simple manager, and basic Treesitter and LSP settings, your environment gains strong editing support without unnecessary weight. This structure prepares your configuration for more advanced tools in later chapters.

1.5 Simple UI polish, statusline, theme, and example daily workflow

A clean user interface reduces friction while you work. Small visual tweaks make buffers easier to read, help you track context, and surface useful information without noise. In this section you set up basic UI options, a theme, a statusline, and then walk through a simple daily workflow that uses them.

Core UI polish with options

You start with built in options. These do not require plugins and give immediate improvements.

Example lua/core/ui.lua:

-- Short reference to Neovim's option table

local o = vim.opt

-- Always show line numbers

o.number = true

-- Show relative numbers for faster movement

o.relativenumber = true

-- Keep a permanent sign column for diagnostics and git signs

o.signcolumn = "yes"

-- Highlight the line under the cursor to track position

o.cursorline = true

-- Keep some context above and below the cursor when scrolling

o.scrolloff = 4

-- Keep some context on the left and right when scrolling sideways

o.sidescrolloff = 8

-- Use a column at 80 characters as a soft width guide

o.colorcolumn = "80"

-- Use true color support in the terminal

o.termguicolors = true

-- Do not show the default mode text since the statusline will show mode

o.showmode = false

This module focuses on appearance. Line numbers and relative numbers help you jump around buffers with motions like 5j or 10k. The sign column reserves space for diagnostic and version control signs so the text area does not shift when signs appear. The cursor line highlights your current row, useful in wide files.

Scroll offsets keep some context around the cursor, which reduces eye movement during navigation. The color column at 80 characters acts as a visual guide for line length. True color support enables rich themes. Disabling showmode prevents duplication when a custom statusline already displays the current mode.

To load this file, add it to your init.lua or core/init module:

-- Load general UI options

require("core.ui")

This links the configuration into your main setup.

Installing a simple theme

A theme ensures consistent colors across buffers, statusline, and UI elements. You manage it as a plugin so updates stay simple.

Add a theme plugin to lua/plugins/modules/init.lua:

return {

-- Treesitter for syntax

{ "nvim-treesitter/nvim-treesitter", build = ":TSUpdate" },

-- LSP support

{ "neovim/nvim-lspconfig" },

-- Color theme

{

"folke/tokyonight.nvim",

lazy = false,            -- load during startup

priority = 1000,         -- load before other plugins that use colors

},

}

This entry tells lazy.nvim to install tokyonight.nvim. The lazy field set to false loads the theme at startup. The high priority value makes sure the theme loads before plugins that depend on highlight groups.

Next, create a theme module to apply the colorscheme.

Example lua/core/theme.lua:

-- Ensure true color support for the theme

vim.opt.termguicolors = true

-- Optional theme settings, for example style "night" or "storm"

vim.g.tokyonight_style = "night"

-- Apply the tokyonight colorscheme

vim.cmd.colorscheme("tokyonight")

This module first enforces true color support. Then it sets a global variable that controls the theme style. Finally it runs the colorscheme command to apply the theme. When Neovim starts, all buffers and UI elements use this color palette.

Load the theme from your init.lua after options and before statusline setup:

-- Core editor behavior

require("core.options")

require("core.ui")

-- Theme configuration

require("core.theme")

This order ensures base options are set before applying colors, which helps highlight groups work as expected.

Statusline with lualine

A statusline shows buffer name, file type, git branch, diagnostics, and mode. The goal is a clear bar that surfaces key information without clutter. You use lualine.nvim for this. It offers a simple configuration model and integrates with many themes.

Add lualine to lua/plugins/modules/init.lua:

return {

{ "nvim-treesitter/nvim-treesitter", build = ":TSUpdate" },

{ "neovim/nvim-lspconfig" },

{

"folke/tokyonight.nvim",

lazy = false,

priority = 1000,

},

{

"nvim-lualine/lualine.nvim",

dependencies = {

"nvim-tree/nvim-web-devicons",   -- optional icons for file types

},

},

}

The dependencies field tells lazy.nvim to install file icons used in the statusline.

Next, create a dedicated lualine configuration module.

Example lua/core/statusline.lua:

-- Load the lualine plugin

local lualine = require("lualine")

-- Configure lualine

lualine.setup({

options = {

theme = "tokyonight",       -- match the colorscheme

icons_enabled = true,       -- show icons if available

section_separators = "",    -- remove separators for a flat look

component_separators = "",  -- remove separators between components

},

sections = {

lualine_a = { "mode" },                 -- current mode, for example NORMAL or INSERT

lualine_b = { "branch" },               -- git branch name

lualine_c = { "filename" },             -- current file name

lualine_x = { "filetype" },             -- file type, for example lua or python

lualine_y = { "progress" },             -- progress in the file, for example 45 percent

lualine_z = { "location" },             -- current line and column

},

})

This setup loads lualine and calls setup with a configuration table. The options section links lualine to the tokyonight theme, enables icons, and removes separators for a flat bar. The sections table defines what appears in each part of the statusline. You see mode, branch, file name, file type, progress, and cursor position at a glance.

Load the statusline after the theme so lualine uses the correct colors:

-- Core behavior and UI

require("core.options")

require("core.ui")

require("core.theme")

-- Statusline configuration

require("core.statusline")

This order ensures the theme and termguicolors are already active when lualine draws the bar.

Putting UI pieces together

At this point your configuration structure might look like this:

~/.config/nvim:

●        init.lua

●        lua/core/options.lua

●        lua/core/ui.lua

●        lua/core/theme.lua

●        lua/core/statusline.lua

●        lua/plugins/init.lua

●        lua/plugins/modules/init.lua

●        lua/plugins/modules/treesitter.lua

●        lua/plugins/modules/lsp.lua

Each module covers one responsibility. Options control core behavior. UI enforces visual readability. Theme and statusline finish the interface. Plugin modules deliver syntax and language support.

Example daily workflow using this setup

This section walks through a simple day of editing with this configuration. The goal is to show how each part supports real work.

Open a project directory from the terminal:

cd ~/projects/sample-app
nvim .

Neovim starts inside the project. The theme loads. Lualine appears at the bottom with the current mode and git branch.

Open a file with a path or filename:

:edit src/main.lua

Treesitter highlights Lua syntax. Relative line numbers appear on the left. The cursor line marks the current row. The statusline shows NORMAL mode, the filename, and the cursor location.

Navigate and edit using the UI cues:

○        Use 5j or 10k guided by relative numbers for jumps.

○        Keep line length near the 80 column guide.

○        Watch the cursor line to stay on the current block.

Scroll offsets keep a small margin around the cursor, useful in long functions.

Rely on LSP features while writing code:

○        Hover diagnostics show in the sign column.

○        The statusline still displays branch and file type so context stays visible.

The sign column prevents layout shifts when diagnostics appear or disappear.

Split the window to compare files:

○        :vsplit src/utils.lua

○        Use <C-w>w to switch between splits.

The theme applies consistent colors across splits. Lualine displays filenames for each window, which reduces confusion.

Save and quit:

○        :w to write changes.

○        :q or :bd to close buffers.

Throughout this session, the interface stays steady. Colors do not flicker. Columns and signs do not shift. Statusline information stays in a fixed place that your eyes learn quickly.

Simple polish in the UI builds trust in the editor and reduces friction throughout the day. With these options, a clear theme, and a focused statusline in place, the environment feels stable and informative, ready for the more advanced workflows developed in later chapters.

Chapter 2: Web Development Setup (TypeScript, React, Node.js)

2.1 Project goals, environment, and web focused plugin selection

Web development with TypeScript, React, and Node.js demands fast navigation, strong language support, and reliable tooling. Your Neovim setup should help you move through large projects, read complex component trees, and work with modern JavaScript features. This section defines clear goals, prepares the environment, and selects plugins that support daily work across frontend and backend codebases.

Project goals

You prepare Neovim to handle three areas.

• TypeScript and JavaScript editing with strong autocomplete, diagnostics, and navigation
• React file structures with JSX and TSX awareness
• Node.js backend files with quick access to definitions and imports

These goals guide your plugin choices. Each plugin must solve a specific problem in web workflows. You avoid tools that sit idle or duplicate features.

Environment requirements

A clean environment prevents errors during development. You install three tools on your system.

• Node.js for running language servers
• npm or yarn for installing project dependencies
• TypeScript for language features and server support

You confirm your environment with simple checks.

node -v        // prints Node.js version

npm -v         // prints npm version

tsc -v         // prints TypeScript version

Neovim uses these tools under the hood. If they are present, language servers and formatters work without interruption.

Plugin selection strategy

You group plugins by purpose. This keeps the configuration modular and reduces conflicts.

• Language server support
• Syntax highlighting with Treesitter
• React and TypeScript extensions
• Navigation helpers
• Formatting and linting integration

Each plugin supports one responsibility. You avoid broad packs that mix unrelated features.

Core plugin list

You add these plugins to lua/plugins/modules/init.lua.

return {

-- Treesitter for syntax and structure

{

"nvim-treesitter/nvim-treesitter",

build = ":TSUpdate",

},

-- LSP support for TypeScript and JavaScript

{

"neovim/nvim-lspconfig",

},

-- Additional tools for managing LSP servers

{

"williamboman/mason.nvim",

build = ":MasonUpdate",

},

-- Bridge between mason and lspconfig

{

"williamboman/mason-lspconfig.nvim",

},

-- React and JSX improvements

{

"windwp/nvim-ts-autotag",

},

-- File navigation

{

"nvim-telescope/telescope.nvim",

dependencies = { "nvim-lua/plenary.nvim" },

},

-- Formatting support

{

"jose-elias-alvarez/null-ls.nvim",

},

}

This list includes Treesitter for accurate highlighting, LSP tools for strong TypeScript support, and helper plugins for navigation and formatting. Each entry has a single purpose.

Configuring Treesitter

Treesitter provides structure aware highlighting for TypeScript, JavaScript, and JSX. You configure it in a separate module.

Example lua/plugins/modules/treesitter.lua:

-- Load Treesitter configuration

local ts = require("nvim-treesitter.configs")

ts.setup({

ensure_installed = {

"typescript",   -- TypeScript parser

"javascript",   -- JavaScript parser

"tsx",          -- React TSX parser

"json",         -- JSON parser for config files

"css",          -- CSS parser for styling files

"html",         -- HTML parser for template files

},

highlight = {

enable = true,   -- enable syntax highlighting

},

autotag = {

enable = true,   -- auto close and rename JSX tags

},

})

The ensure_installed table lists parsers required for web projects. The highlight section turns on syntax highlighting. The autotag section works with React components. It inserts closing tags and updates tag names during edits.

Configuring the TypeScript language server

The TypeScript language server offers diagnostics, refactoring tools, code navigation, and intelligent suggestions. You set it up using mason and lspconfig.

Example lua/plugins/modules/lsp.lua:

-- Load mason

require("mason").setup({})

-- Load mason bridge

require("mason-lspconfig").setup({

ensure_installed = { "tsserver" },   -- install TypeScript server

})

-- Load LSP configuration API

local lspconfig = require("lspconfig")

-- Configure the TypeScript server

lspconfig.tsserver.setup({

settings = {

typescript = {

preferences = {

importModuleSpecifier = "relative",  -- control import paths

},

},

javascript = {

preferences = {

importModuleSpecifier = "relative",  -- match JavaScript behavior

},

},

},

})

mason installs language servers system wide. mason-lspconfig ensures the TypeScript server is present. lspconfig loads the server and applies settings. The import preference lines define how import paths appear in new files.

Navigation support with Telescope

Web development involves jumping between components, files, and definitions. Telescope gives fuzzy search with a simple interface.

Example lua/plugins/modules/telescope.lua:

-- Load telescope plugin

local telescope = require("telescope")

-- Configure telescope

telescope.setup({

defaults = {

sorting_strategy = "ascending",    -- sort results for faster scanning

layout_strategy = "flex",          -- adapt layout to window size

},

})

This setup provides a starter configuration. It sorts results in an order that helps you scan lists quickly and adapts the layout for any terminal size.

Common mappings:

:Files        // search files

:LiveGrep     // search text in project

:Buffers      // switch between open files

These commands help you move through large React projects and Node.js backends without scanning directories manually.

Formatter integration

You integrate formatters through null-ls.nvim. This tool exposes formatters and linters as language servers so Neovim interacts with them through the same interface.

Example lua/plugins/modules/formatting.lua:

-- Load null-ls API

local null_ls = require("null-ls")

null_ls.setup({

sources = {

null_ls.builtins.formatting.prettier,     -- format JS, TS, JSX, TSX, JSON

null_ls.builtins.diagnostics.eslint,      -- lint project files

},

})

Prettier formats code. ESLint provides diagnostics through the LSP engine. This keeps all feedback consistent across languages.

With these goals, environment checks, and carefully selected plugins in place, your Neovim setup supports modern web work without noise. You now have a stable foundation ready for the React and Node.js workflows developed in the next sections.

2.2 Treesitter and LSP for JavaScript, TypeScript, JSX, and TSX

Treesitter and the Language Server Protocol work together to make JavaScript and TypeScript editing precise and fast. Treesitter gives structure aware parsing and highlighting. LSP adds navigation, diagnostics, and refactoring. In this section, you set them up for JavaScript, TypeScript, JSX, and TSX, then see how to use them in daily work.

Treesitter focuses on how code is structured.
LSP focuses on what the code means.

Both are important for modern React and Node.js projects.

Core Treesitter setup for web languages

You configure Treesitter once, then enable parsers for all web languages you use.

Example lua/plugins/modules/treesitter.lua:

-- Load Treesitter configuration API

local ts = require("nvim-treesitter.configs")

ts.setup({

-- List of language parsers to install

ensure_installed = {

"javascript",    -- for .js files

"typescript",    -- for .ts files

"tsx",           -- for .tsx React TypeScript files

"json",          -- for package.json and config files

"css",           -- for styling

"html",          -- for markup

},

-- Core highlighting settings

highlight = {

enable = true,   -- turn on Treesitter based highlighting

},

-- Optional incremental selection using Treesitter nodes

incremental_selection = {

enable = true,   -- allow selecting syntax nodes

keymaps = {

init_selection = "gnn",     -- start selection

node_incremental = "grn",   -- expand to next node

node_decremental = "grm",   -- shrink selection

},

},

})

Line by line:

●        require("nvim-treesitter.configs") loads the Treesitter configuration module.

●        ensure_installed lists the languages you want Treesitter to support. This ensures you always have parsers for your web stack.

●        highlight.enable = true switches Treesitter highlighting on. This replaces the older regex based system with a structure aware parser.

●        incremental_selection lets you expand selection by syntax node, not by character. This matters when you edit JSX trees or nested TypeScript types.

When this file is loaded at startup, Treesitter installs missing parsers and applies these settings for every buffer that matches the listed languages.

JSX and TSX tag helpers with nvim-ts-autotag

JSX and TSX use nested tags for components and HTML like structures. Managing opening and closing tags by hand is slow. The nvim-ts-autotag plugin uses Treesitter to insert and update tags.

Example lua/plugins/modules/treesitter.lua with autotag:

local ts = require("nvim-treesitter.configs")

ts.setup({

ensure_installed = {

"javascript",

"typescript",

"tsx",

"json",

"css",

"html",

},

highlight = {

enable = true,

},

-- Integrate autotag with Treesitter

autotag = {

enable = true,   -- enable automatic JSX and TSX tag handling

},

})

Behavior:

●        When you type <div> in a JSX or TSX file, autotag adds </div> automatically.

●        When you rename the opening tag, the closing tag updates.

●        When you delete one side, the other is removed.

This works because Treesitter understands the tag tree and can track matching pairs. This reduces structural errors in React components.

Basic LSP concepts for JavaScript and TypeScript

The TypeScript language server (often referred to as tsserver) provides language intelligence for both JavaScript and TypeScript. It reads your project files, TypeScript configuration, and type information, then exposes features through LSP.

Common features:

●        Go to definition for functions, components, and variables.

●        Find references across the project.

●        Inline diagnostics for type errors and runtime issues.

●        Rename symbols across files.

●        Code actions for quick fixes.

Neovim talks to the language server through the nvim-lspconfig plugin.

LSP server installation with mason and mason-lspconfig

You use mason.nvim to install and manage the TypeScript server. mason-lspconfig connects mason to lspconfig.

Example lua/plugins/modules/lsp.lua:

-- Load mason, the LSP installer

require("mason").setup({})

-- Connect mason with lspconfig and ensure TypeScript server is installed

require("mason-lspconfig").setup({

ensure_installed = { "tsserver" },  -- install TypeScript and JavaScript server

})

Line by line:

●        require("mason").setup({}) initializes mason with default settings.

●        require("mason-lspconfig").setup links mason to lspconfig and ensures that tsserver is installed on your system.

●        ensure_installed = { "tsserver" } guarantees that the TypeScript server is available any time you open a project.

LSP configuration for tsserver

Next, you configure Neovim to use the TypeScript server through lspconfig.

Example lua/plugins/modules/lsp_ts.lua:

-- Load LSP configuration module

local lspconfig = require("lspconfig")

-- Shared on_attach function for setting keymaps when a server starts

local function on_attach(client, bufnr)

-- Convenience local function to set buffer local keymaps

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

-- Go to definition

map("n", "gd", vim.lsp.buf.definition)

-- Show references

map("n", "gr", vim.lsp.buf.references)

-- Show hover documentation

map("n", "K", vim.lsp.buf.hover)

-- Rename symbol

map("n", "<leader>rn", vim.lsp.buf.rename)

-- Show code actions

map("n", "<leader>ca", vim.lsp.buf.code_action)

end

-- Configure the TypeScript and JavaScript server

lspconfig.tsserver.setup({

on_attach = on_attach,      -- run keymap setup when server attaches to a buffer

settings = {

typescript = {

preferences = {

importModuleSpecifier = "relative",  -- prefer relative imports

},

},

javascript = {

preferences = {

importModuleSpecifier = "relative",  -- keep behavior consistent for JS

},

},

},

})

Explanation:

●        local lspconfig = require("lspconfig") loads the main LSP configuration API.

●        on_attach runs when the server attaches to a buffer. This is the right place to define keymaps that only apply when LSP features are available.

●        vim.keymap.set with { buffer = bufnr } creates buffer local mappings. They only exist in buffers handled by tsserver, which avoids conflicts in other file types.

●        gd jumps to the definition of the symbol under the cursor.

●        gr shows all references in the project.

●        K opens hover documentation with type information and comments.

●        <leader>rn renames a symbol, updating all uses.

●        <leader>ca shows code actions such as quick fixes.

The settings block tweaks TypeScript and JavaScript behavior. Here it controls how import paths are generated during refactors or auto imports. Setting importModuleSpecifier to "relative" keeps imports predictable in many codebases.

Filetype behavior for JSX and TSX

JSX and TSX files use the tsx parser with Treesitter and the same tsserver instance with LSP. Your Neovim only needs to detect the filetype correctly.

Common default behavior:

●        Files ending in .tsx use filetype = "typescriptreact".

●        Files ending in .jsx use filetype = "javascriptreact".

Treesitter and tsserver both understand these filetypes when configured with the tsx parser and tsserver. If needed, you can set or adjust filetypes in an autocommand.

Example filetype override (optional):

-- Ensure .tsx and .jsx get the correct filetypes

vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {

pattern = { "*.tsx" },

callback = function()

vim.bo.filetype = "typescriptreact"   -- TSX files

end,

})

vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {

pattern = { "*.jsx" },

callback = function()

vim.bo.filetype = "javascriptreact"   -- JSX files

end,

})

This is only needed if your setup does not already detect these correctly. It ensures Treesitter and LSP both use the right language definitions.

Using Treesitter and LSP together in daily work

With this setup in place, you edit web code with both structural and semantic support.

A typical session in a React TypeScript project:

●        Open a TSX component file. Treesitter highlights JSX and TSX with precise scopes. Autotag manages tags for you.

●        Use gd to jump to a component definition. Use gr to view where it is used. This lets you trace through props flow.

●        Use K to view type details for props or functions. This helps you understand external APIs or shared modules.

●        Use <leader>rn to rename a prop or component and update all uses across the project.

●        Use incremental selection, for example gnn then grn, to select a JSX element or a function body as a syntax node, then edit or move it.

In Node.js files, you use the same keys and features. Types come from JavaScript JSDoc comments or TypeScript definition files. Treesitter keeps highlighting stable even in large files, and LSP gives navigation and diagnostics across the whole backend.

When Treesitter and LSP are configured in this focused way, Neovim becomes a consistent environment for JavaScript, TypeScript, JSX, and TSX. You work with the same keys and feedback patterns in both frontend and backend code, which makes large web projects easier to understand and maintain over time.

2.3 Completion, snippets, formatting with Prettier, and lint integration

Completion, snippets, formatting, and linting turn Neovim into a productive web editor. In this section you connect four pieces.

Completion suggests symbols and imports.
Snippets insert common code shapes.
Prettier keeps layout consistent.
ESLint reports code quality problems.

The goal is a smooth loop while editing TypeScript, JavaScript, JSX, and TSX.

Goals for this setup

This configuration aims for four clear outcomes.

Language aware completion from LSP, buffers, and file paths.

Snippets for React and common web patterns.

One command and one keybinding for Prettier formatting.

Live ESLint diagnostics inside Neovim.

You will extend the plugin set from earlier sections with completion and snippet plugins, then connect formatting and linting through null-ls.

Plugin selection for completion and snippets

You add three main plugins on top of your existing stack.

●        nvim-cmp for completion.

●        LuaSnip for snippets.

●        friendly-snippets for a large snippet collection.

Example lua/plugins/modules/init.lua additions:

return {

-- Existing entries ...

-- Completion engine

{

"hrsh7th/nvim-cmp",

},

-- LSP completion source

{

"hrsh7th/cmp-nvim-lsp",

},

-- Buffer completion source

{

"hrsh7th/cmp-buffer",

},

-- Path completion source

{

"hrsh7th/cmp-path",

},

-- Snippet engine

{

"L3MON4D3/LuaSnip",

},

-- Snippet collection

{

"rafamadriz/friendly-snippets",

},

}

Explanation:

●        nvim-cmp provides the completion menu.

●        cmp-nvim-lsp feeds suggestions from language servers.

●        cmp-buffer uses words from open buffers.

●        cmp-path suggests filesystem paths.

●        LuaSnip runs snippets.

●        friendly-snippets adds ready made snippets for React and many other stacks.

These plugins focus on completion and snippet behavior, separate from Treesitter and LSP.

Completion setup with nvim-cmp

You configure completion in a dedicated module. It wires cmp to LSP, buffers, paths, and LuaSnip.

Example lua/core/completion.lua:

-- Load the completion plugin

local cmp = require("cmp")

-- Load the LuaSnip snippet engine

local luasnip = require("luasnip")

-- Load snippet collections from friendly-snippets

require("luasnip.loaders.from_vscode").lazy_load()

-- Configure nvim-cmp

cmp.setup({

-- Define how to expand snippets inside completion entries

snippet = {

expand = function(args)

-- Use LuaSnip to insert the selected snippet body

luasnip.lsp_expand(args.body)

end,

},

-- Define key mappings for the completion menu

mapping = {

-- Confirm the selected completion item with Enter

["<CR>"] = cmp.mapping.confirm({ select = true }),

-- Move to the next item in the list with Ctrl n

["<C-n>"] = cmp.mapping.select_next_item(),

-- Move to the previous item with Ctrl p

["<C-p>"] = cmp.mapping.select_prev_item(),

-- Scroll documentation window down with Ctrl f

["<C-f>"] = cmp.mapping.scroll_docs(4),

-- Scroll documentation window up with Ctrl b

["<C-b>"] = cmp.mapping.scroll_docs(-4),

},

-- Define completion sources

sources = {

{ name = "nvim_lsp" },  -- suggestions from LSP servers

{ name = "luasnip" },   -- snippet suggestions

{ name = "buffer" },    -- words from current buffers

{ name = "path" },      -- file paths

},

})

Line by line:

●        require("cmp") loads the completion engine.

●        require("luasnip") loads the snippet engine.

●        loaders.from_vscode.lazy_load loads snippets shipped by friendly-snippets on demand to keep startup light.

●        snippet.expand points cmp to LuaSnip. Whenever a completion entry includes a snippet, LuaSnip handles expansion.

●        The mapping table defines how you interact with the completion menu.

○        <CR> confirms the current item and inserts it. select = true picks the first item if none is highlighted.

○        <C-n> and <C-p> step through completion items.

○        <C-f> and <C-b> scroll documentation attached to completion entries.

●        The sources list sets priority. LSP suggestions come first, followed by snippets, buffer words, then paths.

With this setup, completion integrates LSP data, snippets, and existing text.

Connecting LSP to nvim-cmp

Your LSP configuration needs to expose capabilities so cmp can use them. You adjust the tsserver setup.

Example lua/plugins/modules/lsp_ts.lua update:

-- Load LSP configuration module

local lspconfig = require("lspconfig")

-- Load cmp LSP capabilities helper

local cmp_lsp = require("cmp_nvim_lsp")

-- Create default capabilities extended for nvim-cmp

local capabilities = cmp_lsp.default_capabilities()

-- Shared on_attach for LSP keymaps

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

map("n", "gd", vim.lsp.buf.definition)

map("n", "gr", vim.lsp.buf.references)

map("n", "K", vim.lsp.buf.hover)

map("n", "<leader>rn", vim.lsp.buf.rename)

map("n", "<leader>ca", vim.lsp.buf.code_action)

end

-- Configure tsserver with cmp aware capabilities

lspconfig.tsserver.setup({

capabilities = capabilities,  -- tell the server about completion features

on_attach = on_attach,        -- apply shared keymaps on attach

settings = {

typescript = {

preferences = {

importModuleSpecifier = "relative",

},

},

javascript = {

preferences = {

importModuleSpecifier = "relative",

},

},

},

})

Here cmp_nvim_lsp.default_capabilities() ensures the language server knows about advanced completion features. This improves detail and behavior of suggestions for JavaScript and TypeScript.

Snippet setup with LuaSnip

LuaSnip supports many formats. You already loaded friendly-snippets in the completion module. You also define project specific snippets for React and Node.js code.

Example lua/core/snippets.lua:

-- Load LuaSnip core module

local ls = require("luasnip")

-- Shortcut constructors for snippet parts

local s = ls.snippet            -- defines a new snippet

local t = ls.text_node          -- static text node

local i = ls.insert_node        -- insertion point

-- Define a simple React functional component snippet for TSX

ls.add_snippets("typescriptreact", {

s("rfc", {

t("import React from 'react'"), t({ "", "" }),       -- import line and blank line

t("type "), i(1, "Props"), t(" = {"), t({ "", "}" }), t({ "", "" }),  -- props type

t("const "), i(2, "ComponentName"), t(": React.FC<"), i(3, "Props"), t("> = ("), t("{"), t({ "", "}" }), t(") => {"), t({ "", "  " }),

t("return ("), t({ "", "    " }),

t("<div>"), i(4, "content"), t("</div>"), t({ "", "  " }),

t(")"), t({ "", "}" }), t({ "", "" }),

t("export default "), i(2, "ComponentName"),

}),

})

This snippet:

●        Targets typescriptreact filetype, which covers TSX files.

●        Creates a snippet named rfc.

●        Inserts a React import, a props type, a functional component, a basic div, and an export.

●        Uses insert_node positions so you tab through Props, ComponentName, and inner content.

Usage:

●        In a .tsx file, type rfc in insert mode, then trigger snippet expansion through the key you configure for LuaSnip (often <Tab> or a leader mapping).

●        Fill in each placeholder and move through them with your chosen jump keys.

This saves repeated typing of the same structure for each new component.

Formatting with Prettier through null-ls

Prettier handles formatting for JavaScript, TypeScript, JSX, TSX, JSON, and other web formats. You use null-ls to expose Prettier as a formatting source for Neovim.

First ensure Prettier exists in your environment, either globally or in the project.

Global installation example:

npm install -g prettier

Then configure null-ls.

Example lua/plugins/modules/formatting.lua:

-- Load null-ls core module

local null_ls = require("null-ls")

-- Define a common on_attach for formatting

local function on_attach(client, bufnr)

-- Only set up formatting if the server supports it

if client.supports_method("textDocument/formatting") then

-- Create a buffer local command :Format

vim.api.nvim_buf_create_user_command(bufnr, "Format", function()

-- Run LSP formatting for the current buffer

vim.lsp.buf.format({ async = false })

end, {})

-- Optional format on save

vim.api.nvim_create_autocmd("BufWritePre", {

buffer = bufnr,                     -- only for this buffer

callback = function()

vim.lsp.buf.format({ async = false })   -- format before writing file

end,

})

end

end

-- Configure null-ls with Prettier and ESLint

null_ls.setup({

sources = {

-- Prettier formatter for web files

null_ls.builtins.formatting.prettier.with({

filetypes = {

"javascript",

"javascriptreact",

"typescript",

"typescriptreact",

"json",

"css",

"html",

"yaml",

},

}),

},

on_attach = on_attach,   -- apply formatting setup when attached

})

Explanation:

●        require("null-ls") loads the null-ls plugin.

●        on_attach checks if the null-ls client supports formatting. If it does, Neovim sets up a :Format command for the buffer and a BufWritePre autocommand that formats before saving.

●        null_ls.builtins.formatting.prettier registers Prettier as a formatter. The filetypes list restricts formatting to specific languages.

●        null_ls.setup applies these sources and the on_attach logic.

You now format a buffer with :Format or simply by saving, depending on whether you keep the BufWritePre hook.

Lint integration with ESLint

ESLint reports code smells and rule violations. null-ls exposes its diagnostics through the same interface as LSP diagnostics.

Extend the same null_ls.setup configuration.

Example lua/plugins/modules/formatting.lua with ESLint:

local null_ls = require("null-ls")

local function on_attach(client, bufnr)

if client.supports_method("textDocument/formatting") then

vim.api.nvim_buf_create_user_command(bufnr, "Format", function()

vim.lsp.buf.format({ async = false })

end, {})

vim.api.nvim_create_autocmd("BufWritePre", {

buffer = bufnr,

callback = function()

vim.lsp.buf.format({ async = false })

end,

})

end

end

null_ls.setup({

sources = {

-- Prettier formatter

null_ls.builtins.formatting.prettier,

-- ESLint diagnostics

null_ls.builtins.diagnostics.eslint_d.with({

-- Prefer eslint_d for speed if installed

condition = function(utils)

-- Only enable if eslint_d exists in the project or system

return utils.executable("eslint_d")

end,

}),

},

on_attach = on_attach,

})

Explanation:

●        null_ls.builtins.diagnostics.eslint_d registers ESLint diagnostics through the eslint_d wrapper, which keeps a daemon for faster runs.

●        The condition function checks if eslint_d is available. This avoids errors in projects without ESLint.

●        When ESLint reports problems, Neovim shows them as diagnostics with signs in the sign column and entries in the location list.

With this setup, saving a file runs Prettier formatting and updates ESLint diagnostics. You see formatting and lint feedback in one place.

Daily workflow with completion, snippets, Prettier, and ESLint

A typical editing loop in a React TypeScript project looks like this.

You open a .tsx file. Treesitter highlights the syntax and LSP attaches for language features. Completion from nvim-cmp shows props, hooks, and imported functions as you type. You trigger a component snippet to create the component shell, then fill in props and JSX using completion again. After editing, you save the file. Prettier formats imports and layout. ESLint diagnostics refresh in the sign column and underlines highlight rule issues. If a variable name feels wrong, you call rename through <leader>rn and let LSP update all references. This combined flow keeps the codebase consistent and readable while you move quickly through features, supported by the tools configured in this chapter.

2.4 Fuzzy finding, file explorers, and project root detection for web repos

Web projects often contain many folders, configuration files, and component paths. You need fast ways to move across this structure. Fuzzy finding helps you open files and search project text with short commands. A file explorer helps you inspect folder layout and perform simple file actions. Project root detection ensures all tools operate from the correct directory so searches, formatters, and language servers read the right configuration.

Fuzzy finding with Telescope

Telescope provides a quick search interface for files, text, buffers, and history. You trigger a small floating window, type a few characters, and jump straight to your target. This improves navigation in large JavaScript and TypeScript projects where file paths grow deep.

Add Telescope to your plugin list.

{

"nvim-telescope/telescope.nvim",

dependencies = { "nvim-lua/plenary.nvim" },

}

This installs Telescope and its helper library. Next, create a configuration module that sets layout and defines key mappings.

-- Load Telescope

local telescope = require("telescope")

local builtin = require("telescope.builtin")

telescope.setup({

defaults = {

layout_config = {

prompt_position = "top",      -- place search prompt at top of window

},

sorting_strategy = "ascending", -- sort matches so best results appear at top

dynamic_preview_title = true,   -- show preview title based on highlighted file

},

})

-- Shortcuts for common searches

local map = vim.keymap.set

map("n", "<leader>ff", builtin.find_files, { desc = "Find files" })

map("n", "<leader>fg", builtin.live_grep, { desc = "Search project text" })

map("n", "<leader>fb", builtin.buffers, { desc = "List open buffers" })

map("n", "<leader>fr", builtin.oldfiles, { desc = "Find recent files" })

Each line in the mapping section links a command to a Telescope picker. builtin.find_files searches file paths. builtin.live_grep searches text across the entire project. These entries help you reach any file or symbol without scanning folders manually.

File explorer for project structure

A file explorer offers a tree view of your repository. This is useful when you want to check folder layout, rename files, or create new paths for React components or Node.js modules. A lightweight option is nvim-tree.lua.

Add it to your plugin list.

{

"nvim-tree/nvim-tree.lua",

dependencies = { "nvim-tree/nvim-web-devicons" },

}

Then configure it in a core module.

-- Load nvim-tree

local nvim_tree = require("nvim-tree")

nvim_tree.setup({

sort_by = "case_sensitive",   -- sort file names predictably

view = {

width = 30,                 -- explorer width

side = "left",              -- place tree on the left side

},

renderer = {

group_empty = true,         -- group empty folders to reduce noise

},

filters = {

dotfiles = false,           -- show dotfiles such as .eslintrc or .gitignore

},

})

-- Toggle explorer window

vim.keymap.set("n", "<leader>e", ":NvimTreeToggle<CR>", { desc = "Toggle explorer" })

-- Jump to current file inside the explorer

vim.keymap.set("n", "<leader>fe", ":NvimTreeFindFile<CR>", { desc = "Locate file in explorer" })

This configuration keeps the tree simple. Grouped empty folders reduce clutter in deep structures. Dotfiles remain visible because web repos depend on them for build and lint settings. The two mappings let you open the explorer or focus the current file quickly.

Project root detection for web repos

Many tools depend on the correct working directory. LSP servers read tsconfig.json. Formatters read .prettierrc files. Linters read .eslintrc files. Fuzzy search tools benefit from the correct root because searches need to cover the whole project.

You define a helper module that detects the project root by scanning upward for common markers.

local M = {}

local root_markers = {

"package.json",

"tsconfig.json",

"jsconfig.json",

".git",

}

local function find_root(startpath)

local path = vim.fn.fnamemodify(startpath, ":p")

while path and path ~= "/" do

for _, marker in ipairs(root_markers) do

local marker_path = path .. "/" .. marker

if vim.loop.fs_stat(marker_path) then

return path          -- return directory once a marker exists

end

end

path = vim.fn.fnamemodify(path, ":h")   -- move one directory up

end

return vim.loop.cwd()                     -- fallback if no markers found

end

function M.get()

local bufname = vim.api.nvim_buf_get_name(0)

if bufname == "" then

return vim.loop.cwd()                   -- unnamed buffers use current directory

end

return find_root(bufname)

end

return M

This module works by:

●        Expanding the file path for the current buffer.

●        Checking each ancestor directory for project markers.

●        Returning the first directory that matches.

●        Falling back to the current directory if nothing matches.

This logic mirrors how many JavaScript and TypeScript tools find their configuration.

Using project root in Telescope

You wrap Telescope pickers to always run from the detected project root. This ensures file search and text search cover the entire repository.

local root = require("core.root")

local builtin = require("telescope.builtin")

local function project_files()

builtin.find_files({ cwd = root.get() })    -- search from root

end

local function project_live_grep()

builtin.live_grep({ cwd = root.get() })     -- live grep from root

end

vim.keymap.set("n", "<leader>ff", project_files, { desc = "Find files in project" })

vim.keymap.set("n", "<leader>fg", project_live_grep, { desc = "Search project text" })

These wrappers change only the working directory. Everything else remains the same. This single adjustment keeps searches accurate in monorepos or deeply nested directories.

Using project root with the file explorer

You adjust the explorer toggle so it always opens at the project root.

local root = require("core.root")

vim.keymap.set("n", "<leader>e", function()

vim.cmd("cd " .. root.get())         -- switch to project root

vim.cmd("NvimTreeToggle")

end, { desc = "Toggle explorer at root" })

This ensures the tree shows the full repository even when you opened Neovim inside a nested path.

With fuzzy finding, a clear file explorer, and reliable project root detection in place, you move through web codebases with far less friction. Each tool contributes to a smooth navigation workflow that supports frontend, backend, and shared files across all types of JavaScript and TypeScript projects.

2.5 Jest or Vitest test workflows and an example React feature flow

Testing keeps React and TypeScript projects stable while they grow. Jest and Vitest both provide a fast feedback loop for components and hooks. Neovim helps by giving quick ways to run tests, inspect failures, and jump between source and test files without leaving the editor.

This section sets up a small test workflow around Jest or Vitest, then walks through a full React feature from code to passing tests.

Choosing Jest or Vitest

Jest is common in Create React App and older setups. Vitest fits Vite based projects and aims for faster startup with native ESM support.

For Neovim workflows both tools expose a similar interface.

●        Run tests for a whole project.

●        Run tests for a single file.

●        Run the nearest test under the cursor.

The difference lives mostly in the project tooling, not the editor side. The Neovim configuration focuses on plugging each runner into the same test interface.

Plugin selection for test workflows

A practical way to drive tests from Neovim is neotest. It provides a Lua API, a results view, and per file commands. Separate adapters handle Jest and Vitest.

Add these plugins to your list.

{

"nvim-neotest/neotest",                 -- core test framework

dependencies = {

"nvim-lua/plenary.nvim",              -- utility library

"nvim-treesitter/nvim-treesitter",    -- structure aware parsing for tests

"antoinemadec/FixCursorHold.nvim",    -- improves CursorHold behavior

"haydenmeade/neotest-jest",           -- Jest adapter

"nvim-neotest/neotest-vitest",        -- Vitest adapter

},

}

This keeps the test stack small and focused. Treesitter helps neotest understand test blocks. The adapters know how to call each test runner with the right arguments.

Configuring neotest for Jest and Vitest

Create a module that wires neotest to the adapters and defines key mappings.

-- Load neotest core

local neotest = require("neotest")

-- Configure neotest with Jest and Vitest

neotest.setup({

adapters = {

require("neotest-jest")({

jestCommand = "npm test",        -- command for Jest projects

jestConfigFile = "jest.config.js",

env = { CI = false },

cwd = function(path)

return vim.fn.getcwd()        -- run from current working directory

end,

}),

require("neotest-vitest")({

vitestCommand = "npm test",      -- or "npx vitest" in some projects

filter_dir = function(name)

return name ~= "node_modules" -- skip node_modules for speed

end,

}),

},

})

Explanation:

●        require("neotest") loads the main testing engine.

●        adapters registers both Jest and Vitest. Only one will match in a given project, based on files present.

●        jestCommand and vitestCommand set the shell commands. These match your package scripts.

●        jestConfigFile tells the adapter where Jest config lives.

●        filter_dir for Vitest excludes node_modules during test discovery, which reduces work.

Next, define key mappings for common actions.

local map = vim.keymap.set

-- Run tests in current file

map("n", "<leader>tf", function()

neotest.run.run(vim.fn.expand("%"))   -- run tests for current buffer

end, { desc = "Test file" })

-- Run nearest test under the cursor

map("n", "<leader>tn", function()

neotest.run.run()                     -- neotest selects nearest test

end, { desc = "Test nearest" })

-- Run all tests in the project

map("n", "<leader>ta", function()

neotest.run.run("/")                  -- run from project root

end, { desc = "Test all" })

-- Open or toggle the test summary panel

map("n", "<leader>ts", function()

neotest.summary.toggle()

end, { desc = "Test summary" })

-- Open output for the last test run

map("n", "<leader>to", function()

neotest.output.open({ enter = true })

end, { desc = "Test output" })

These mappings provide a unified interface. Whether the project uses Jest or Vitest, the keys stay the same.

Example React component and test

Consider a small React feature, a button that shows a loading state and disables itself during an async action.

Example src/components/SaveButton.tsx:

import React from "react"

type SaveButtonProps = {

loading: boolean          // true when a save request is in progress

onClick: () => void       // callback when user clicks the button

}

export function SaveButton({ loading, onClick }: SaveButtonProps) {

return (

<button

disabled={loading}               // disable while loading is true

onClick={onClick}               // forward click to parent handler

>

{loading ? "Saving..." : "Save"}  // show label based on state

</button>

)

}

This component receives a loading flag and an onClick handler. It disables the button while loading and switches the label from “Save” to “Saving...”. The logic is simple, yet bugs here would hurt usability.

Example Jest or Vitest test file src/components/SaveButton.test.tsx:

import React from "react"

import { render, screen, fireEvent } from "@testing-library/react"

import { SaveButton } from "./SaveButton"

test("shows Save when not loading and calls onClick", () => {

const handleClick = vi.fn()          // use jest.fn() when using Jest

render(<SaveButton loading={false} onClick={handleClick} />)

const button = screen.getByRole("button", { name = "Save" })

fireEvent.click(button)              // simulate click

expect(handleClick).toHaveBeenCalledTimes(1)

})

test("disables button and shows Saving when loading", () => {

render(<SaveButton loading={true} onClick={() => {}} />)

const button = screen.getByRole("button", { name = "Saving..." })

expect(button).toBeDisabled()

})

In a Vitest setup, vi.fn() creates a mocked function. In Jest, jest.fn() plays that role. The tests verify both text and behavior:

●        The first test checks the default state and that the click handler triggers exactly once.

●        The second test checks that the button disables itself and shows the loading label.

Example Neovim driven test flow

A typical feature flow looks like this.

You create SaveButton.tsx and its test file, then open the test in Neovim. With the cursor inside the first test, you press <leader>tn. Neotest runs the nearest test. If the import path is wrong or the role query fails, the summary panel lists the failure and <leader>to opens the full output. You fix the issue, run <leader>tn again, and move to the second test. Once both tests pass, you switch to <leader>tf to run the whole file or <leader>ta for the project. This rhythm keeps feedback tight while you grow the feature, and the same commands work whether the project uses Jest or Vitest.

Chapter 3: Python Backend and Data Engineering Environment

3.1 Goals, Python toolchain, and plugin selection

Python backend and data engineering work often mixes web APIs, batch jobs, notebooks, and one off scripts. Your Neovim setup should support all of these without feeling heavy. In this section you define clear goals, set up a reliable Python toolchain, and choose plugins that match real backend and data workflows.

Goals for a Python focused Neovim setup

You want your editor to support four main activities.

Backend services with frameworks like FastAPI or Django.

Data pipelines using libraries such as pandas and SQLAlchemy.

Environment aware editing with the correct virtualenv per project.

Fast feedback through formatting, linting, and tests.

Every tool and plugin you add should support at least one of these. Anything that does not help here is noise.

Python toolchain on your system

Before Neovim enters the picture you need a clean Python base.

A common setup is:

●        pyenv to manage multiple Python versions.

●        python -m venv or virtualenv to create per project environments.

●        pip or poetry to install dependencies.

●        python-lsp tools like pyright, black, and ruff.

You check your basics first.

python --version      # show active Python version

pip --version         # show pip version and location

Then install core tools. For example:

pip install black ruff

npm install -g pyright

Black formats code. Ruff lints and enforces style. Pyright provides static analysis and powers LSP features in Neovim.

These tools matter because Neovim does not ship Python intelligence itself. It delegates to language servers and CLI tools.

Plugin selection strategy for Python

You group plugins by purpose. This keeps configuration simple and debuggable.

●        Treesitter for syntax and structure.

●        LSP for analysis and navigation.

●        Formatting and linting integration.

●        Test runners.

●        REPL or Jupyter helpers if you work with data interactively.

A minimal plugin set for Python can look like this.

return {

-- Treesitter for Python syntax

{

"nvim-treesitter/nvim-treesitter",

build = ":TSUpdate",

},

-- LSP support

{

"neovim/nvim-lspconfig",

},

-- LSP server management

{

"williamboman/mason.nvim",

build = ":MasonUpdate",

},

{

"williamboman/mason-lspconfig.nvim",

},

-- Formatting and linting through null-ls

{

"nvimtools/none-ls.nvim",   -- fork of null-ls

},

-- Test runner integration

{

"nvim-neotest/neotest",

dependencies = {

"nvim-lua/plenary.nvim",

"nvim-treesitter/nvim-treesitter",

"nvim-neotest/neotest-python",

},

},

}

Each entry does one job. Treesitter covers parsing. LSPconfig and mason handle servers. none-ls connects tools like black and ruff. Neotest adds testing support.

Treesitter for Python structure

Treesitter understands Python syntax and block structure better than regex based highlighting. You configure it once for multiple languages, including Python.

local ts = require("nvim-treesitter.configs")

ts.setup({

ensure_installed = {

"python",         -- for .py files

"lua",            -- for your config

"json",           -- for settings files

},

highlight = {

enable = true,    -- enable parser based highlighting

},

})

This code:

●        Loads the Treesitter config API.

●        Ensures the Python parser is installed.

●        Enables highlighting based on the parser tree.

This matters for backend work where indentation and blocks are critical. Correct highlighting helps you see where functions and context managers begin and end.

LSP with Pyright

Pyright gives fast type checking and navigation for Python. You connect it through mason and lspconfig.

-- Initialize mason

require("mason").setup({})

-- Ensure pyright is installed

require("mason-lspconfig").setup({

ensure_installed = { "pyright" },

})

This ensures the server is available. Then configure it.

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")      -- if you use nvim-cmp

local capabilities = cmp_lsp.default_capabilities()

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

map("n", "gd", vim.lsp.buf.definition)      -- jump to definition

map("n", "gr", vim.lsp.buf.references)      -- list references

map("n", "K", vim.lsp.buf.hover)           -- show type info

map("n", "<leader>rn", vim.lsp.buf.rename) -- rename symbol

end

lspconfig.pyright.setup({

capabilities = capabilities,   -- advertise completion support

on_attach = on_attach,         -- set LSP keymaps when attached

})

This setup:

●        Tells Pyright about completion capabilities if you use nvim-cmp.

●        Adds buffer local keymaps when the server attaches.

●        Provides quick jumps and refactors for functions, models, and pipeline steps.

This matters when you move between API endpoints, service layers, and data access code.

Formatting and linting with Black and Ruff

Formatting and linting should run through a common Neovim interface. none-ls gives that by wrapping CLI tools as LSP sources.

local null_ls = require("null-ls")

local function on_attach(client, bufnr)

if client.supports_method("textDocument/formatting") then

vim.api.nvim_buf_create_user_command(bufnr, "Format", function()

vim.lsp.buf.format({ async = false })   -- format current buffer

end, {})

vim.api.nvim_create_autocmd("BufWritePre", {

buffer = bufnr,

callback = function()

vim.lsp.buf.format({ async = false }) -- format before save

end,

})

end

end

null_ls.setup({

sources = {

null_ls.builtins.formatting.black,   -- use black for formatting

null_ls.builtins.diagnostics.ruff,   -- use ruff for lint diagnostics

},

on_attach = on_attach,

})

This configuration:

●        Registers black as a formatter for Python files.

●        Registers ruff as a diagnostics source.

●        Adds a :Format command for buffers where formatting is supported.

●        Formats on save by default.

This keeps style consistent across backend modules and data jobs. It also surfaces errors early.

Plugin support for tests and data tasks

For backend tests you can use neotest with its Python adapter. That gives per file and per test commands, similar to the Jest setup you saw earlier.

local neotest = require("neotest")

neotest.setup({

adapters = {

require("neotest-python")({

dap = { justMyCode = true },   -- debug only your code if you add DAP later

}),

},

})

You then bind keys to run tests in the current file or nearest function. This matters for services and pipelines where a fast loop between code and tests keeps regressions out.

With these goals, tools, and plugins in place your Neovim configuration is ready to support Python backend services and data engineering work side by side. The next sections build on this base and show concrete workflows for APIs, jobs, and analysis scripts inside this environment.

3.2 Treesitter and LSP configuration for Python and common config files

Python backend and data projects rely on more than .py files. You work with pyproject.toml, YAML pipelines, JSON schemas, environment files, and tool specific configs. Your Neovim setup should treat these files as first class citizens. Treesitter gives structure aware highlighting. LSP servers give validation, completion, and navigation for each type.

This section configures Treesitter and LSP for Python and the most common config formats you will see in backend and data engineering work.

Treesitter setup for Python and config formats

You use Treesitter to parse and highlight code and configs. You configure it in a single module.

Example lua/plugins/modules/treesitter.lua:

-- Load Treesitter configuration API

local ts = require("nvim-treesitter.configs")

ts.setup({

-- Install parsers for Python and common config formats

ensure_installed = {

"python",      -- Python source files

"lua",         -- Lua for Neovim config

"json",        -- JSON config, responses, schemas

"yaml",        -- YAML for CI, Kubernetes, Airflow

"toml",        -- pyproject.toml and tool configs

"bash",        -- shell scripts for jobs and tooling

},

highlight = {

enable = true,     -- use Treesitter for syntax highlighting

},

indent = {

enable = true,     -- use Treesitter to assist indentation

},

})

Line by line:

require("nvim-treesitter.configs") loads the configuration module so you can call setup.

ensure_installed lists all languages you want Treesitter to support.
The Python parser improves highlighting for functions, classes, and blocks.
The JSON and YAML parsers help for API specs, pipeline configs, and Docker or CI files.
The TOML parser is important for pyproject.toml, which centralizes modern Python project metadata.
The bash parser is useful for glue scripts around jobs and deployments.

highlight.enable = true switches from regex based syntax to parser based syntax. This gives stable highlighting even in long or nested files.
indent.enable = true lets Treesitter help with indentation, which is important for Python and YAML where structure depends on whitespace.

You load this module from your plugin bootstrap so Treesitter is active for every relevant buffer.

LSP configuration for Python with Pyright

Pyright provides type checking, go to definition, hover info, and refactors for Python. You configure it through nvim-lspconfig, usually together with mason to handle installation.

First ensure Pyright is installed using mason, as shown in the previous section. Then create an LSP module.

Example lua/plugins/modules/lsp_python.lua:

-- Load core LSP configuration

local lspconfig = require("lspconfig")

-- Optional, integrate with nvim-cmp for completion

local cmp_lsp = require("cmp_nvim_lsp")

local capabilities = cmp_lsp.default_capabilities()

-- Function to run after Pyright attaches to a buffer

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

-- Jump to the definition of a symbol

map("n", "gd", vim.lsp.buf.definition)

-- List all references of the symbol

map("n", "gr", vim.lsp.buf.references)

-- Show type information and docstring

map("n", "K", vim.lsp.buf.hover)

-- Rename a function, class, or variable across the project

map("n", "<leader>rn", vim.lsp.buf.rename)

-- Show available code actions, such as quick fixes

map("n", "<leader>ca", vim.lsp.buf.code_action)

end

-- Configure Pyright

lspconfig.pyright.setup({

capabilities = capabilities,   -- advertise completion features

on_attach = on_attach,         -- apply keymaps when server starts

})

Explanation:

lspconfig is the core interface to language servers.
cmp_nvim_lsp prepares capability flags so the server knows you support advanced completion features.

on_attach sets keymaps only for buffers where Pyright is active. This prevents conflicts with non Python files.
gd and gr help you move around models, services, and utility modules.
K shows type hints and docstrings, useful when reading unfamiliar code.
<leader>rn and <leader>ca support refactors and fixes without leaving Neovim.

pyright.setup links these behaviors to the Pyright server. After this, any *.py buffer in a project with a valid environment gets full LSP features.

LSP configuration for YAML

YAML is common in CI workflows, Kubernetes manifests, Airflow DAG configs, and data pipeline definitions. A YAML language server validates structure and supports schema aware hints.

You use yaml-language-server through lspconfig.

Example lua/plugins/modules/lsp_yaml.lua:

local lspconfig = require("lspconfig")

lspconfig.yamlls.setup({

settings = {

yaml = {

validate = true,         -- enable YAML validation

hover = true,            -- show info on hover

completion = true,       -- enable key completion

schemas = {

-- Link schemas to file patterns

kubernetes = "*.k8s.yaml",

["https://json.schemastore.org/github-workflow.json"] = ".github/workflows/*.yml",

},

},

},

})

This configuration:

Enables validation, hover, and completion for YAML.
Links known schemas to file patterns.
For Kubernetes and GitHub Actions files you get better validation and completion based on the schema.
This matters for backend and data engineers who rely on these files for deployment and automation.

LSP configuration for JSON and TOML

JSON appears in API responses, OpenAPI specs, and tool configs. TOML is common in pyproject.toml and sometimes in application settings.

For JSON you can use jsonls with built in schemas.

lspconfig.jsonls.setup({

settings = {

json = {

schemas = require("schemastore").json.schemas(),  -- optional schemastore integration

validate = { enable = true },

},

},

})

This gives validation and completion for many common JSON config formats, including tools you use in data and backend work.

For TOML, many setups use taplo:

lspconfig.taplo.setup({})

Taplo adds structure, validation, and formatting for pyproject.toml and other TOML configs. This reduces mistakes in tool versions, dependency names, and configuration keys.

With Treesitter parsing Python and configs, and LSP servers handling Python, YAML, JSON, and TOML, Neovim becomes a reliable control center for your backend and data projects. You read code, adjust settings, and validate pipelines using the same editor and keymaps, which keeps your focus on the work instead of the tools.

3.3 Virtual environments, formatting (Black, isort), and linting

Python backend and data projects depend on consistent environments and strict style rules. Virtual environments isolate dependencies per project. Black and isort keep code style predictable. Linters detect issues early. In this section you connect all three to Neovim so each project uses the correct interpreter and tooling without extra effort.

VIRTUAL ENVIRONMENTS AND WHY THEY MATTER

A virtual environment holds project specific packages and tools. Each backend or data project uses its own environment. This avoids version conflicts between libraries such as Django, FastAPI, pandas, or SQLAlchemy. Your editor should talk to the same environment that your tests and scripts use.

A simple venv workflow looks like this in the shell:

python -m venv .venv           # create a virtual environment in .venv

source .venv/bin/activate      # activate on Linux or macOS

.venv\Scripts\activate         # activate on Windows PowerShell or CMD

Inside the environment you install tools:

pip install black isort ruff   # formatter, import sorter, linter

pip install fastapi uvicorn    # example backend stack

pip install pandas             # example data stack

These commands give each project its own copy of Black, isort, and Ruff. Neovim should use the Python from this environment when it runs the LSP server and other Python tools.

CONNECTING NEOVIM TO THE PROJECT PYTHON

Neovim uses a Python host for some plugins and tools. You point Neovim at the Python inside your project environment. A common pattern is to set the Python host path dynamically based on a local .venv folder.

Example Lua module lua/core/python.lua:

local uv = vim.loop

-- Try to find a .venv directory starting from cwd and going up

local function find_venv(startpath)

local path = vim.fn.fnamemodify(startpath, ":p")

while path and path ~= "/" do

local candidate = path .. "/.venv/bin/python"

if uv.fs_stat(candidate) then

return candidate       -- found a Python binary inside .venv

end

path = vim.fn.fnamemodify(path, ":h")   -- go up one directory

end

return nil                 -- no venv detected

end

local function setup_python_host()

local bufname = vim.api.nvim_buf_get_name(0)

local start = bufname ~= "" and bufname or vim.loop.cwd()

local python = find_venv(start)

if python then

vim.g.python3_host_prog = python    -- tell Neovim which Python to use

end

end

-- Run once on startup

setup_python_host()

Explanation:

uv references Neovim’s libuv wrapper for filesystem checks.
find_venv walks up from the current file path and looks for .venv/bin/python. On Windows you would adjust the path to .venv/Scripts/python.exe.
If the function finds a match, it returns that Python path.
setup_python_host chooses a start path. If there is a buffer name, it uses that directory. Otherwise it uses the current working directory. It calls find_venv and, if a Python binary appears, sets vim.g.python3_host_prog.

This links Neovim’s Python host to the project environment. LSP servers and tools that rely on Python will then see the same site packages as your tests and scripts.

FORMATTING WITH BLACK AND ISORT THROUGH NONE-LS

Formatting and import sorting should happen through a single interface. The none-ls plugin (a maintained fork of null-ls) exposes CLI tools as LSP formatters. You use it to drive Black and isort from Neovim with one command.

Example lua/plugins/modules/formatting.lua:

local null_ls = require("null-ls")

-- Run when a formatting capable client attaches

local function on_attach(client, bufnr)

if client.supports_method("textDocument/formatting") then

-- Define a :Format command for this buffer

vim.api.nvim_buf_create_user_command(bufnr, "Format", function()

vim.lsp.buf.format({ async = false })      -- format current buffer

end, {})

-- Optional format on save

vim.api.nvim_create_autocmd("BufWritePre", {

buffer = bufnr,

callback = function()

vim.lsp.buf.format({ async = false })   -- format before writing

end,

})

end

end

null_ls.setup({

sources = {

-- Black for Python code style

null_ls.builtins.formatting.black.with({

extra_args = { "--line-length", "88" },   -- match project style

}),

-- isort for import ordering

null_ls.builtins.formatting.isort.with({

extra_args = { "--profile", "black" },    -- align with Black rules

}),

},

on_attach = on_attach,

})

Explanation:

require("null-ls") loads the plugin module.
on_attach runs when none-ls attaches to a buffer. It checks whether the client supports formatting. If that is true, it creates a :Format command for that buffer and an optional BufWritePre autocommand that formats before saving.
The sources table registers Black and isort as formatters.
Black receives a line length argument that matches your project style.
isort receives a profile argument that aligns its import grouping with Black’s expectations.

This approach sends a single format request to all active formatters. For Python files, none-ls runs isort then Black in the same pipeline.

LINTING WITH RUFF

Ruff performs fast linting and basic style checks. none-ls also exposes it as a diagnostics source.

Extend the previous setup:

null_ls.setup({

sources = {

null_ls.builtins.formatting.black.with({

extra_args = { "--line-length", "88" },

}),

null_ls.builtins.formatting.isort.with({

extra_args = { "--profile", "black" },

}),

-- Ruff for lint diagnostics

null_ls.builtins.diagnostics.ruff.with({

diagnostics_format = "[{c}] {m}",   -- include Ruff code in message

}),

},

on_attach = on_attach,

})

With this configuration, Ruff runs on each LSP diagnostics pass. Warnings show in the sign column and as virtual text or in the location list, depending on your Neovim settings. You keep one interface for both formatting and linting, and tools run inside the project’s virtual environment through the Python host setup.

When virtual environments, Black, isort, and Ruff work together like this, each project stays isolated, style remains consistent, and issues surface early inside Neovim. Your focus stays on backend code and data pipelines while the editor quietly enforces the rules around you

3.4 Test workflows with pytest and integrated terminals/REPLs

Python backend and data work depends on fast feedback. You write a function, run tests, inspect failures, then explore edge cases in a REPL. Neovim can host this entire loop so you do not switch between multiple tools. In this section you wire pytest into Neovim and add integrated terminals and REPLs that match typical backend and data workflows.

Goals for pytest and interactive work

You want three things from your setup.

Run pytest at different scopes, such as nearest test, current file, or full suite.

See failures inside Neovim with quick navigation to failing lines.

Drop into a Python REPL or IPython session with the current project environment.

The plugin choices and code in this section support these goals.

pytest workflow with neotest

You use neotest to drive pytest from inside Neovim. The Python adapter understands pytest structure, test markers, and parametrized tests.

Add neotest and the Python adapter to your plugin list.

{

"nvim-neotest/neotest",

dependencies = {

"nvim-lua/plenary.nvim",

"nvim-treesitter/nvim-treesitter",

"nvim-neotest/neotest-python",

},

}

This installs the core neotest framework, a utility library, Treesitter for parsing, and the Python adapter.

Next, configure neotest in a separate module.

-- Load neotest core

local neotest = require("neotest")

neotest.setup({

adapters = {

require("neotest-python")({

dap = { justMyCode = true },       -- only debug your code when you add DAP

runner = "pytest",                 -- use pytest as the test runner

}),

},

})

Explanation:

require("neotest") loads the main testing engine.
adapters registers the Python adapter and tells it to use pytest.
The dap config prepares later debugging integration by limiting focus to your code, not library internals.

With this setup, neotest discovers test functions that follow pytest naming rules, such as test_ prefixes.

Key mappings for pytest actions

You control tests through a small set of mappings. Place these in a core testing module.

local neotest = require("neotest")

local map = vim.keymap.set

-- Run tests in the current file

map("n", "<leader>tf", function()

neotest.run.run(vim.fn.expand("%"))   -- run pytest on current buffer

end, { desc = "Test file" })

-- Run the nearest test under the cursor

map("n", "<leader>tn", function()

neotest.run.run()                     -- neotest picks the closest test node

end, { desc = "Test nearest" })

-- Run the full test suite

map("n", "<leader>ta", function()

neotest.run.run("/")                  -- run tests from project root

end, { desc = "Test all" })

-- Toggle summary panel with test tree and status

map("n", "<leader>ts", function()

neotest.summary.toggle()

end, { desc = "Test summary" })

-- Show detailed output of the last test

map("n", "<leader>to", function()

neotest.output.open({ enter = true })

end, { desc = "Test output" })

These mappings let you:

Run the nearest test while writing a function.
Run the current file when working on a module.
Run the entire suite before a commit.
Inspect failures in the summary panel or detailed output view.

You can keep your hands on the keyboard and never leave Neovim during this loop.

Example pytest test file

Consider a small backend utility that validates user ages.

app/validators.py:

def is_adult(age: int) -> bool:

if age < 0:

raise ValueError("age must be non-negative")

return age >= 18

tests/test_validators.py:

import pytest

from app.validators import is_adult

def test_is_adult_true_for_18_and_above():

assert is_adult(18) is True

assert is_adult(30) is True

def test_is_adult_false_for_underage():

assert is_adult(17) is False

assert is_adult(0) is False

def test_is_adult_rejects_negative_age():

with pytest.raises(ValueError):

is_adult(-1)

In Neovim, you open test_validators.py, place the cursor inside test_is_adult_rejects_negative_age, and press <leader>tn. neotest runs only that test and marks the result inline. If it fails, you use <leader>to to read the traceback and adjust the implementation.

Integrated terminal for pytest and tools

Sometimes you want to see raw pytest output, run a one off command, or start a long running process such as a development server. An integrated terminal keeps you inside Neovim.

You can use a plugin like toggleterm, or rely on Neovim’s built in terminal. A simple Lua helper around the built in terminal looks like this:

-- Open a terminal in a horizontal split and run pytest

vim.keymap.set("n", "<leader>tp", function()

vim.cmd("split | terminal pytest")   -- horizontal split with pytest command

vim.cmd("startinsert")               -- enter insert mode to interact

end, { desc = "Terminal pytest" })

This mapping:

Splits the window.
Opens a terminal running pytest.
Switches to insert mode so you can watch output or type extra arguments.

You still have your test file visible in the top window.

Python REPL and IPython inside Neovim

For data exploration, you often want a REPL tied to the current project environment. The simplest version uses a terminal running Python or IPython.

-- Start a Python REPL in a vertical split

vim.keymap.set("n", "<leader>rp", function()

vim.cmd("vsplit | terminal python")   -- use 'ipython' if available

vim.cmd("startinsert")

end, { desc = "Python REPL" })

This gives a REPL window beside your code. You copy lines from a script into the REPL or send them with a plugin that supports text sending, such as iron.nvim. The important point is that the REPL runs inside the same virtual environment used by the project, so imports and dependencies match your tests.

For more structure you can add iron.nvim or similar tools later to send visual selections or current lines directly into the REPL. The basic terminal mapping still provides a reliable starting point.

With pytest integrated through neotest, terminals available for raw commands, and a REPL ready for exploration, Neovim becomes a tight loop for backend and data work. You edit code, run focused tests, inspect failures, and experiment with logic interactively without leaving the editor, which keeps your attention on the behavior of the system instead of the plumbing around it.

3.5 Example backend debugging and refactoring session

This section walks through a realistic debugging and refactoring session in a Python backend project using Neovim. You start from a failing API behavior, use Neovim features to track the problem, add a focused test, fix the bug, then refactor the code to a cleaner design without losing safety.

The example assumes a FastAPI service, pytest tests, and the Pyright and pytest integrations from earlier sections.

Scenario and starting point

You receive a report that a user details endpoint returns a 500 error instead of a 404 when the user does not exist. The route is /users/{user_id}. You know the bug involves error handling around the database lookup.

You open the route module with Telescope or your file explorer.

app/api/users.py:

from fastapi import APIRouter

from fastapi import HTTPException

from app.repositories.users import get_user_by_id

router = APIRouter()

@router.get("/users/{user_id}")

def get_user(user_id: int):

user = get_user_by_id(user_id)

if not user:

raise HTTPException(status_code=404, detail="User not found")

return user

At first glance the code looks correct. The 404 branch exists. The bug likely lives inside get_user_by_id. You place the cursor on get_user_by_id and press gd to jump to its definition using LSP.

Inspecting the repository function

app/repositories/users.py:

from app.db import SessionLocal

from app.models import User

def get_user_by_id(user_id: int) -> User | None:

db = SessionLocal()                  # open a database session

try:

user = db.query(User).filter(User.id == user_id).one()

return user                      # return the user if found

finally:

db.close()                       # always close the session

Here you see the real issue. The function calls .one() which raises an exception when no row matches. The function signature suggests it returns User | None, but there is no None path. When no user is found, the exception escapes, FastAPI converts it to a 500, and the route never reaches the if not user branch.

Pyright may already underline the annotation and show a warning on hover. You press K over get_user_by_id in the route file to see the type and notice the mismatch between the type and implementation.

Writing a focused test

Before changing code you add a test that captures the wrong behavior. You open the tests file for users. If it does not exist, you create it.

tests/test_users_api.py:

from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)

def test_get_missing_user_returns_404():

response = client.get("/users/999999")  # assume this ID does not exist

assert response.status_code == 404      # expect 404, not 500

You place the cursor inside this test and run <leader>tn to execute the nearest test with neotest. The test fails with a 500 status code and a stack trace pointing to .one(). You use <leader>to to open the detailed output and confirm the NoResultFound exception from SQLAlchemy.

Now the bug is captured. You can refactor with confidence.

Fixing the repository behavior

You return to get_user_by_id and adjust it to return None when the user does not exist.

from sqlalchemy.orm.exc import NoResultFound

from app.db import SessionLocal

from app.models import User

def get_user_by_id(user_id: int) -> User | None:

db = SessionLocal()

try:

try:

user = (

db.query(User)

.filter(User.id == user_id)

.one()

)

return user                         # user found

except NoResultFound:

return None                         # no user, return None

finally:

db.close()

You save the file. Black formats it on save if you have formatting on write enabled. You run <leader>tn again in the test file. The test now passes because get_user_by_id returns None and the route raises the proper 404.

Refactoring to cleaner patterns

The nested try blocks look cluttered. You want a clearer function without changing behavior. This is a good moment for a small refactor.

You introduce a helper that hides the exception handling.

from sqlalchemy.orm.exc import NoResultFound

from app.db import SessionLocal

from app.models import User

def _get_user_safe(db, user_id: int) -> User | None:

try:

return (

db.query(User)

.filter(User.id == user_id)

.one()

)

except NoResultFound:

return None

def get_user_by_id(user_id: int) -> User | None:

db = SessionLocal()

try:

return _get_user_safe(db, user_id)

finally:

db.close()

Explanation line by line:

_get_user_safe accepts a database session and a user id, then wraps the .one() call in a try block.
On success it returns a User instance.
On NoResultFound it returns None which matches the type annotation.
get_user_by_id now focuses on session management. It opens a session, calls _get_user_safe, and closes the session in the finally block.

You use <leader>tf on the test file or <leader>ta on the full suite to verify nothing else broke. LSP still recognizes references. You press gr on get_user_by_id to confirm all call sites remain valid.

Debugging with logs and a REPL check

You might still want to inspect edge cases manually. You add a temporary log and test it in a REPL.

import logging

logger = logging.getLogger(__name__)

def _get_user_safe(db, user_id: int) -> User | None:

try:

user = (

db.query(User)

.filter(User.id == user_id)

.one()

)

logger.debug("Found user %s", user.id)

return user

except NoResultFound:

logger.debug("No user for id %s", user_id)

return None

You open a vertical split terminal with <leader>rp to start a Python or IPython REPL inside the same virtual environment. From there, you import get_user_by_id and call it with some ids to see log lines and values. The combination of automated tests, log statements, and REPL checks gives you both broad and detailed coverage.

At this point you have taken a real bug from report to test, through diagnosis, into a targeted fix, then on to a cleaner design, using Neovim features at each step. The same pattern applies to more complex backend modules, and as your confidence with the tools grows, the debugging and refactoring loop becomes a natural part of everyday development.

Chapter 4: Go and Rust Systems Programming Workspace

4.1 Goals, compilers, and language servers (gopls, rust-analyzer)

Go and Rust target network services, command line tools, and low level infrastructure. A Neovim setup for these languages should feel fast and precise. The editor should understand module layout, support refactors, and surface compiler style feedback early. In this section you define goals for a systems workspace, prepare Go and Rust toolchains, and configure language servers gopls and rust-analyzer through Neovim.

Goals for a Go and Rust workspace

The workspace aims at three outcomes.

Fast feedback that matches compiler behavior.
Accurate navigation across modules and packages.
Safe refactors backed by language servers instead of manual edits.

That means the toolchain on your system must be clean, and Neovim must talk to the official language servers in a predictable way.

Go toolchain and gopls

For Go you install the main compiler and tools from the official distribution. On most systems the go binary sets up a module aware environment.

A quick check in the shell:

go version          # prints Go version and build info

go env GOPATH       # shows workspace path for tools and caches

Language features in Neovim rely on gopls, the Go language server. Recent Go releases install it as a module.

go install golang.org/x/tools/gopls@latest

This instruction places gopls inside GOPATH/bin or GOBIN. That path should appear in your shell PATH so Neovim can launch the server.

gopls provides go to definition, references, rename, and diagnostics that match the real compiler. It also offers module aware completion that respects imports and build tags.

Rust toolchain and rust-analyzer

Rust uses rustup as the main installer and tool manager. It provides the cargo build tool and handles toolchain updates.

Basic checks:

rustup show           # shows active toolchain

rustc --version       # shows compiler version

cargo --version       # shows build tool version

You install rust-analyzer either through rustup or your package manager. With rustup the component command looks like this:

rustup component add rust-analyzer

Some environments use a standalone rust-analyzer binary instead. Either way, the goal is the same. The binary must be on your PATH so Neovim fires it without extra steps.

rust-analyzer provides rich structural information. It tracks modules, traits, generics, and lifetimes. It supports inline type hints, symbol navigation, and code actions for imports and refactors.

Neovim plugin base for language servers

You use nvim-lspconfig and mason.nvim to manage servers for both languages from Lua.

Example plugin entries:

return {

{

"neovim/nvim-lspconfig",        -- core LSP client

},

{

"williamboman/mason.nvim",      -- server installer

build = ":MasonUpdate",

},

{

"williamboman/mason-lspconfig.nvim",  -- bridge between mason and lspconfig

},

}

This group gives Neovim a way to install, configure, and launch servers in a consistent way.

Shared LSP setup helper

You define shared capabilities and keymaps to reuse across gopls and rust-analyzer.

-- Load core LSP and cmp integration

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")

-- Capabilities for completion plugins

local capabilities = cmp_lsp.default_capabilities()

-- Function that runs when any server attaches

local function on_attach(client, bufnr)

-- Local helper for buffer specific keymaps

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

-- Go to definition of symbol under cursor

map("n", "gd", vim.lsp.buf.definition)

-- List references for symbol

map("n", "gr", vim.lsp.buf.references)

-- Show hover documentation or type info

map("n", "K", vim.lsp.buf.hover)

-- Rename symbol across project

map("n", "<leader>rn", vim.lsp.buf.rename)

-- Show available code actions

map("n", "<leader>ca", vim.lsp.buf.code_action)

end

This helper:

Loads core LSP and completion capabilities.
Defines on_attach, which sets buffer local mappings for navigation and refactors.
Keeps behavior consistent between Go and Rust buffers.

Configuring gopls

mason takes care of server installation, then lspconfig connects Neovim to gopls.

-- Ensure gopls and rust-analyzer are available

require("mason").setup({})

require("mason-lspconfig").setup({

ensure_installed = { "gopls", "rust_analyzer" },

})

-- Configure gopls

lspconfig.gopls.setup({

capabilities = capabilities,     -- enable advanced completion

on_attach = on_attach,           -- apply shared keymaps

settings = {

gopls = {

analyses = {

unusedparams = true,       -- flag unused function parameters

shadow = true,             -- detect variable shadowing

},

staticcheck = true,          -- enable extra static checks

},

},

})

Here ensure_installed instructs mason to install gopls.
gopls.setup passes the shared capabilities and on_attach and enables extra analyses. unusedparams and shadow help catch issues in handlers and workers. staticcheck activates a strong set of checks that align with production expectations.

With this configuration, Go files receive instant diagnostics and module aware refactors.

Configuring rust-analyzer

Rust receives a similar setup with language specific settings.

lspconfig.rust_analyzer.setup({

capabilities = capabilities,

on_attach = on_attach,

settings = {

["rust-analyzer"] = {

cargo = {

allFeatures = true,     -- include feature flagged code

},

checkOnSave = {

command = "clippy",     -- run clippy checks on save

},

},

},

})

This block:

Attaches the same keymaps and completion capabilities used for Go.
Tells rust-analyzer to include all Cargo features so navigation covers the full build graph.
Uses clippy for checkOnSave, which extends standard compiler warnings with additional guidance.

The result is an editing environment where Rust code gets immediate feedback aligned with real builds.

Small Go and Rust examples in this workspace

With both servers running, simple examples show how the environment behaves.

Go file:

package main

import "fmt"

func main() {

fmt.Println("hello systems")

}

Rust file:

fn main() {

println!("hello systems");

}

In each file, gd on Println or println! jumps into the standard library definitions offered by the language servers. K shows signatures and hints. Rename and code actions support small refactors without manual search and replace.

With compilers installed cleanly, gopls and rust-analyzer configured through Neovim, and shared navigation and refactor keys in place, this workspace supports Go and Rust systems work with the same smooth loop as your higher level environments.

4.2 Treesitter and LSP setup for Go, Rust, and related filetypes

A systems workspace for Go and Rust benefits from two layers of language awareness. Treesitter provides fast, structure aware highlighting and indentation. LSP adds navigation, diagnostics, and refactors. Together they help you read complex control flow, follow data through services, and keep module layout correct. This section configures Treesitter and LSP for Go, Rust, and the related files that appear in real projects, such as gomod, Cargo.toml, Dockerfiles, and CI configs.

Treesitter configuration for Go, Rust, and support files

Start by enabling Treesitter parsers for your core languages and common support formats.

Example lua/plugins/modules/treesitter.go_rust.lua:

-- Load Treesitter configuration API

local ts = require("nvim-treesitter.configs")

ts.setup({

-- Install parsers for Go, Rust, and related formats

ensure_installed = {

"go",           -- .go source files

"gomod",        -- go.mod module files

"gowork",       -- go.work workspace files

"rust",         -- .rs Rust sources

"toml",         -- Cargo.toml and other configs

"json",         -- tool configs and API payloads

"yaml",         -- CI, deployment, infra definitions

"dockerfile",   -- Dockerfile for images

"bash",         -- shell scripts for tooling

},

highlight = {

enable = true,   -- turn on parser based highlighting

},

indent = {

enable = true,   -- improved indentation where supported

},

})

Explanation:

require("nvim-treesitter.configs") loads the setup entry point.
ensure_installed lists languages that matter in Go and Rust workspaces.
go and rust cover your main source files.
gomod and gowork give proper highlighting and structure in module and workspace files.
toml and json support Cargo.toml, go tool configs, and various metadata.
yaml, dockerfile, and bash support deployment and CI scripts that often sit next to services.
highlight.enable switches syntax highlighting to the parser engine, which stays stable in deeply nested code.
indent.enable lets Treesitter help indentation in supported languages, including Go and Rust.

Load this module from your plugin bootstrap so all buffers benefit from the configuration.

Shared LSP helper recap

You reuse the shared LSP helper from the previous subchapter. It defines capabilities for completion and shared keymaps.

Example lua/core/lsp_common.lua:

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")

-- Capabilities advertised to language servers

local capabilities = cmp_lsp.default_capabilities()

-- Shared on_attach for navigation and refactors

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

map("n", "gd", vim.lsp.buf.definition)      -- jump to definition

map("n", "gr", vim.lsp.buf.references)      -- list references

map("n", "K", vim.lsp.buf.hover)           -- show docs or types

map("n", "<leader>rn", vim.lsp.buf.rename) -- rename symbol

map("n", "<leader>ca", vim.lsp.buf.code_action) -- code actions

end

return {

lspconfig = lspconfig,

capabilities = capabilities,

on_attach = on_attach,

}

This module holds glue code. Other language specific modules import it and avoid duplication.

Go specific LSP setup with gopls

For Go you configure gopls to understand modules, run static checks, and highlight common mistakes.

Example lua/plugins/modules/lsp_go.lua:

local common = require("core.lsp_common")

local lspconfig = common.lspconfig

lspconfig.gopls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

settings = {

gopls = {

gofumpt = true,          -- use gofumpt style if installed

usePlaceholders = true,  -- add placeholders in completion snippets

staticcheck = true,      -- enable extra static analysis

analyses = {

unusedparams = true,   -- report unused function parameters

shadow = true,         -- detect shadowed variables

nilness = true,        -- flag possible nil dereferences

},

},

},

})

Line by line:

core.lsp_common exports lspconfig, capabilities, and on_attach.
gopls.setup registers the Go language server with Neovim.
gofumpt switches formatting style to stricter gofumpt rules when that tool exists in your environment.
usePlaceholders inserts placeholders in completion snippets, for example parameter names inside function calls. This speeds up filling out calls in handlers.
staticcheck extends gopls with staticcheck diagnostics, which catch subtle issues in production style code.
analyses.unusedparams and analyses.shadow highlight mistakes such as unused handler arguments or accidental variable shadowing.
nilness warns about possible nil dereferences, important in long running services.

With this configuration Go buffers receive targeted feedback each time you write or save.

Rust specific LSP setup with rust-analyzer

Rust benefits from rust-analyzer settings that understand Cargo features, clippy checks, and inlay hints.

Example lua/plugins/modules/lsp_rust.lua:

local common = require("core.lsp_common")

local lspconfig = common.lspconfig

lspconfig.rust_analyzer.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

settings = {

["rust-analyzer"] = {

cargo = {

allFeatures = true,      -- include all Cargo features in analysis

},

checkOnSave = {

command = "clippy",      -- run cargo clippy when saving

},

inlayHints = {

typeHints = {

enable = true,         -- show inferred types inline

},

parameterHints = {

enable = true,         -- show parameter names at call sites

},

},

},

},

})

Explanation:

rust_analyzer.setup registers the Rust server.
cargo.allFeatures ensures analysis covers feature flagged code, which is common in systems crates.
checkOnSave.command set to clippy triggers cargo clippy each time you save a Rust file, giving strong feedback that matches CI.
inlayHints.typeHints.enable shows inferred types near bindings, helpful with complex generics.
inlayHints.parameterHints.enable shows parameter names at call sites, which makes code with multiple similar arguments easier to read.

Related filetypes and server coverage

Some support files also benefit from LSP:

gomod and gowork receive gopls support automatically, since gopls understands Go modules and workspaces.
Cargo.toml receives support from the taplo server if you choose to enable it:

lspconfig.taplo.setup({})

This adds validation and completion for Cargo manifests and other TOML configs.

JSON and YAML filetypes can reuse the jsonls and yamlls setups described for Python projects. That keeps CI, deployment, and infra files validated in the same way across languages.

Example editing flow with Treesitter and LSP

With Treesitter and LSP configured, a typical session looks like this. You open a Go handler file. Treesitter highlights types, functions, and control flow. gopls attaches and underlines unused parameters or shadowed variables. You press gd on a repository call to jump into the implementation. In a Rust crate you see inlay hints that show inferred types next to variables. A clippy warning appears after you save, pointing at a suspicious unwrap. Small fixes and refactors follow from code actions and renames, and all changes stay within Neovim, supported by the same Treesitter and LSP stack across Go, Rust, and the files that surround them.

4.3 Build and test integration with tasks, quickfix, and diagnostics

Go and Rust projects rely on tight loops around build and test commands. You run go test, go build, cargo test, or cargo check many times per day. Neovim can treat these commands as first class tasks and route their output into the quickfix list so you jump straight to errors. Language server diagnostics then provide live feedback between task runs.

This section connects build and test commands, the quickfix list, and LSP diagnostics into one workflow.

Goals for build and test integration

You want three outcomes.

Run common build and test commands from Neovim with a single key press.

See compiler or test errors in the quickfix list and jump to them quickly.

Use LSP diagnostics for instant feedback while typing, then confirm with full builds.

To keep things simple, you start with Neovim’s built in quickfix support and Lua wrappers.

Quickfix basics

The quickfix list holds locations produced by commands such as :make, :grep, or custom jobs. Each entry stores a file, line, column, and message. You open it with :copen and move between entries with :cnext and :cprev.

The key to integration is to run project commands in a way that populates the quickfix list automatically. For this you use vim.fn.jobstart and vim.fn.setqflist.

Helper to run shell tasks into quickfix

Create a small Lua module that runs a shell command, captures output, and loads it into quickfix.

Example lua/core/tasks.lua:

local M = {}

-- Run a shell command and send its output to the quickfix list

function M.run_in_quickfix(cmd)

-- Start a job using the shell

vim.fn.jobstart(cmd, {

stdout_buffered = true,        -- collect stdout lines before callback

stderr_buffered = true,        -- collect stderr lines before callback

on_stdout = function(_, data)

if not data then

return

end

-- Convert output lines into quickfix items

local items = {}

for _, line in ipairs(data) do

if line ~= "" then

table.insert(items, { text = line })

end

end

vim.fn.setqflist({}, " ", { title = table.concat(cmd, " "), items = items })

vim.cmd("copen")             -- open quickfix window

end,

on_stderr = function(_, data)

if not data then

return

end

local items = {}

for _, line in ipairs(data) do

if line ~= "" then

table.insert(items, { text = line })

end

end

vim.fn.setqflist({}, " ", { title = table.concat(cmd, " "), items = items })

vim.cmd("copen")

end,

})

end

return M

Explanation:

jobstart runs a command asynchronously. Here cmd is a list like { "go", "test", "./..." }.
stdout_buffered and stderr_buffered collect lines before calling callbacks.
on_stdout and on_stderr receive the output. Each line becomes a quickfix item with its text set to the line.
setqflist replaces the quickfix list with new items and gives it a title based on the command.
copen opens the quickfix window so you can inspect output.

This simple version does not parse file, line, and column yet. For Go and Rust, the error format is standard and can be parsed later. Even raw messages are useful when combined with search and LSP.

Go build and test tasks

Now define Go specific wrappers that call this helper.

local tasks = require("core.tasks")

local map = vim.keymap.set

-- Run all Go tests in the current module or repo

map("n", "<leader>gb", function()

tasks.run_in_quickfix({ "go", "test", "./..." })

end, { desc = "Go test ./..." })

-- Run go build for the current module or repo

map("n", "<leader>gB", function()

tasks.run_in_quickfix({ "go", "build", "./..." })

end, { desc = "Go build ./..." })

You press <leader>gb to run tests across the module. <leader>gB runs a full build. Output appears in the quickfix list, and you can move through failures with :cnext and :cprev.

Rust build and test tasks

For Rust you follow the same pattern with cargo.

local tasks = require("core.tasks")

local map = vim.keymap.set

-- Run cargo test

map("n", "<leader>rt", function()

tasks.run_in_quickfix({ "cargo", "test" })

end, { desc = "Cargo test" })

-- Run cargo check for fast feedback

map("n", "<leader>rc", function()

tasks.run_in_quickfix({ "cargo", "check" })

end, { desc = "Cargo check" })

cargo check gives a fast compile without building binaries. You can run it often while editing. cargo test runs tests and collects failures.

Improving quickfix items with errorformat

To jump directly to locations you need file and line information in quickfix items. Neovim uses errorformat to parse compiler output. For Go and Rust, the built in makeprg and errorformat rules are often enough.

For Go you can set:

vim.opt.makeprg = "go test ./..."

vim.opt.errorformat = "%f:%l:%c: %m"

Then use Neovim’s :make to run tests and populate quickfix:

vim.keymap.set("n", "<leader>gm", function()

vim.cmd("make")           -- uses makeprg and errorformat

vim.cmd("copen")

end, { desc = "Make go test" })

This approach lets Neovim parse file names, line numbers, and messages. The quickfix entries now jump directly to error lines.

For Rust many setups rely on cargo and its default integration. Plugins such as rustaceanvim or crates.nvim add more support, but a basic errorformat for cargo can look like this:

vim.opt.errorformat = "%f:%l:%c: %m"

You can then adjust makeprg to cargo test when working in Rust.

Diagnostics and build output together

LSP diagnostics already highlight many issues while you type. Build and test commands catch the rest, including integration errors and missing dependencies.

Typical loop in Go:

You write code. gopls marks unused variables and type mismatches through vim.diagnostic. You fix these and then press <leader>gb to run go test ./.... Any runtime failures or unhandled cases appear in the quickfix list with full stack traces. You jump into failing files and lines, make changes, and repeat.

Typical loop in Rust:

You write a new service function. rust-analyzer shows type hints and clippy warnings on save. When the function compiles cleanly, you run <leader>rc for cargo check. For deeper coverage you run <leader>rt to execute tests. Failing tests or compile errors show in quickfix and send you back to relevant locations.

When tasks, quickfix, and diagnostics work together like this, Neovim becomes the place where you both write and verify systems code, keeping the build and test loop close to your editing flow.

4.4 Git workflows tuned for frequent small commits and code review

Frequent small commits keep Go and Rust systems code easier to review and debug. Neovim supports this style with inline Git signs, hunk level staging, and focused diffs. The goal is to stay inside the editor while preparing clean commits and reviewing changes before pushing them.

This section wires Git plugins into your workspace and walks through a full edit–stage–commit–review loop.

Goals for the Git workflow

You aim for three concrete behaviors.

Small, focused commits grouped by concern.
Quick staging and unstaging at the hunk level.
Easy review of local changes before and after commits.

Neovim reaches these goals through three pieces.

Gutter signs to show added, changed, and removed lines.
Commands to stage, reset, and preview hunks.
Diff views and status views for commit preparation.

Gitsigns for inline change tracking

Gitsigns marks changes in the sign column next to line numbers. It reads the Git index and working tree, then shows where the buffer differs from HEAD.

Example configuration in lua/core/git.lua:

local gitsigns = require("gitsigns")

gitsigns.setup({

signs = {

add = { text = "+" },          -- lines added

change = { text = "~" },       -- lines modified

delete = { text = "_" },       -- lines deleted

},

current_line_blame = false,      -- disable inline blame by default

})

Explanation:

require("gitsigns") loads the plugin.
signs defines symbols in the gutter for each type of change.
current_line_blame controls inline blame, which you enable later when needed.

Next, add key mappings for hunk operations.

local map = vim.keymap.set

-- Go to next Git hunk

map("n", "]h", gitsigns.next_hunk, { desc = "Next hunk" })

-- Go to previous Git hunk

map("n", "[h", gitsigns.prev_hunk, { desc = "Previous hunk" })

-- Stage current hunk

map("n", "<leader>hs", gitsigns.stage_hunk, { desc = "Stage hunk" })

-- Reset current hunk to HEAD

map("n", "<leader>hr", gitsigns.reset_hunk, { desc = "Reset hunk" })

-- Preview current hunk in a floating window

map("n", "<leader>hp", gitsigns.preview_hunk, { desc = "Preview hunk" })

These mappings support a small commit style. You move between hunks with [h and ]h, inspect a change with <leader>hp, and stage only the hunks that belong in the next commit with <leader>hs.

Fugitive for status and commits

Gitsigns handles inline changes. For status, staging, and commit messages, a Git integration such as vim-fugitive fits well.

Example plugin entry:

{ "tpope/vim-fugitive" }

Basic usage inside Neovim:

:G opens a status buffer.
This buffer lists staged and unstaged files. You use standard motions and commands:

●        Press - on a file to toggle stage/unstage.

●        Press cc to write a commit message and create a commit.

●        Press dv on a file to open a vertical diff.

These commands keep the entire commit cycle in Neovim. You stage files or partial hunks, inspect diffs, and write commit messages without leaving the editor.

To jump into status quickly, add a mapping.

vim.keymap.set("n", "<leader>gs", vim.cmd.G, { desc = "Git status" })

Pressing <leader>gs opens the fugitive status view in the current tab.

Diffview for review oriented workflows

For code review preparation, a dedicated diff interface helps. Diffview presents commit and index differences in side by side layouts.

Example plugin entry:

{ "sindrets/diffview.nvim" }

Example configuration and mappings:

local diffview = require("diffview")

diffview.setup({})

vim.keymap.set("n", "<leader>dv", function()

diffview.open()                      -- open diff against HEAD

end, { desc = "Open diffview" })

vim.keymap.set("n", "<leader>dc", function()

diffview.close()                     -- close diffview

end, { desc = "Close diffview" })

diffview.open() shows changed files and splits for side by side comparisons. You step through files, review each hunk, and double check that a commit covers one logical change set.

Example small commit flow

A typical small commit loop for a Rust service looks like this.

You edit a handler and a helper module. Gitsigns shows + and ~ in the gutter. You move through each hunk with [h and ]h, preview the changes with <leader>hp, and decide which hunks belong in the current commit. Unrelated refactors wait for a later commit.

You stage only the target hunks with <leader>hs. Then you press <leader>gs to open the fugitive status buffer. You confirm that only the desired files and hunks appear staged. If a file includes unrelated changes, you return to the buffer and reset that hunk with <leader>hr.

Once the staged set looks clean, you press cc in the status buffer and write a concise commit message that describes the focused change. After committing, you press <leader>dv to open Diffview and review all changes in that commit side by side. This double check step improves code review quality later, because you catch noisy or accidental changes before pushing.

Blame and history for review

When preparing or responding to code review, you often need to know who last touched a line and why. Gitsigns offers lightweight blame support.

vim.keymap.set("n", "<leader>hb", gitsigns.toggle_current_line_blame, {

desc = "Toggle line blame",

})

Pressing <leader>hb toggles a virtual text overlay that shows author, time, and commit hash for the current line. You use this to understand context during a refactor or when reviewing an older function.

For deeper history, fugitive supports commands such as :Gblame which opens a full blame view for the current file with per line commit hashes.

With gitsigns, fugitive, and diffview tied into your Neovim environment, frequent small commits and careful code review become natural habits. The editor surfaces changes and history inline, and Git commands stay close to your code, which supports a steady flow of clean, reviewable changes in both Go and Rust systems projects.

4.5 Example implementation and test loop for a CLI or service

A systems programming workspace becomes useful when it supports a complete loop from writing code to running tests without leaving Neovim. This section walks through that loop using a small Go or Rust CLI tool as the example. The same steps apply to a long running service with handlers, modules, and background tasks.

The goal is simple. You write a function, run fast tests, inspect failures, apply fixes, and repeat. Neovim’s Treesitter, LSP, and quickfix integration support this flow.

Project layout and starting point

Assume a Go CLI that reads a configuration file and prints a processed value. You open the workspace using your fuzzy finder, then jump to the CLI entry point with gd or Telescope.

cmd/tool/main.go:

package main

import (

"fmt"

"tool/internal/config"

)

func main() {

cfg, err := config.Load("config.json") // load config file

if err != nil {

fmt.Println("error:", err)

return

}

fmt.Println(cfg.Message) // print message from config

}

This code calls a loader, prints a message, and exits. The loader lives in internal/config. You jump there with gd.

internal/config/load.go:

package config

import (

"encoding/json"

"os"

)

type Config struct {

Message string `json:"message"` // message loaded from JSON

}

func Load(path string) (Config, error) {

f, err := os.Open(path)          // open JSON file

if err != nil {

return Config{}, err         // return empty config and error

}

defer f.Close()

var cfg Config

if err := json.NewDecoder(f).Decode(&cfg); err != nil {

return Config{}, err         // return empty config and decode error

}

return cfg, nil

}

You have the basics, but you want to add validation and tests.

Adding a test for the loader

You jump to the test folder and create a test. With Go you use the standard testing package.

internal/config/load_test.go:

package config

import (

"os"

"testing"

)

func TestLoadReturnsMessage(t *testing.T) {

// create a temporary config file

f, err := os.CreateTemp("", "cfg")

if err != nil {

t.Fatal(err)

}

defer os.Remove(f.Name())

// write minimal JSON

f.WriteString(`{"message":"hello"}`)

f.Close()

cfg, err := Load(f.Name()) // call loader

if err != nil {

t.Fatalf("unexpected error: %v", err)

}

if cfg.Message != "hello" {

t.Fatalf("expected hello, got %s", cfg.Message)

}

}

You place the cursor anywhere inside the test and run your test mapping. Earlier sections created one such as:

<leader>gb    // go test ./...

You run it now. The quickfix list opens and shows:

--- FAIL: TestLoadReturnsMessage (0.00s)

...

If the test passes on the first run, you are ready to extend behavior. If it fails, you jump to the failure with :cnext.

Introducing validation and confirming with tests

You decide the loader should reject empty messages. Start with a new test.

internal/config/load_test.go:

func TestLoadRejectsEmptyMessage(t *testing.T) {

f, _ := os.CreateTemp("", "cfg")

defer os.Remove(f.Name())

// write JSON with empty message

f.WriteString(`{"message":""}`)

f.Close()

_, err := Load(f.Name())

if err == nil {

t.Fatalf("expected error for empty message")

}

}

You press <leader>gb again. The quickfix list shows failure because Load still accepts empty messages. This confirms the missing behavior. You jump back to the loader with gd.

Modify Load:

func Load(path string) (Config, error) {

f, err := os.Open(path)

if err != nil {

return Config{}, err

}

defer f.Close()

var cfg Config

if err := json.NewDecoder(f).Decode(&cfg); err != nil {

return Config{}, err

}

// simple validation rule

if cfg.Message == "" {

return Config{}, fmt.Errorf("message cannot be empty")

}

return cfg, nil

}

Now save. If you have format on save, gofumpt or goimports will apply formatting. Run <leader>gb. Both tests pass and the quickfix window closes automatically or stays open depending on your settings.

Running the CLI inside Neovim

You check the full CLI by running the tool through an integrated task. Earlier sections defined a helper such as:

tasks.run_in_quickfix({ "go", "run", "./cmd/tool", "config.json" })

Bind it:

vim.keymap.set("n", "<leader>gr", function()

tasks.run_in_quickfix({ "go", "run", "./cmd/tool", "config.json" })

end, { desc = "Run CLI" })

Pressing <leader>gr shows the program output in quickfix. If the config file is wrong, the error appears there. You jump from messages to files and fix issues quickly.

Rust variant of the same loop

The same loop applies to Rust with cargo test and cargo run.

A minimal Rust loader:

use std::fs;

#[derive(serde::Deserialize)]

pub struct Config {

pub message: String,

}

pub fn load(path: &str) -> anyhow::Result<Config> {

let content = fs::read_to_string(path)?;         // read file

let cfg: Config = serde_json::from_str(&content)?; // parse JSON

if cfg.message.is_empty() {

anyhow::bail!("message cannot be empty");    // validation

}

Ok(cfg)

}

A test:

#[test]

fn test_load_returns_message() {

let cfg = r#"{"message":"hello"}"#;

let file = tempfile::NamedTempFile::new().unwrap();

std::fs::write(file.path(), cfg).unwrap();

let out = load(file.path().to_str().unwrap()).unwrap();

assert_eq!(out.message, "hello");

}

You run <leader>rt to execute cargo test. If failures appear, they populate the quickfix list. With gd you jump into the failing function and adjust code until the loop stabilizes.

Closing the loop

With Treesitter highlighting structure, LSP jumping between modules, tests routed into quickfix, and commands to run the full CLI, Neovim supports a fast build–test–refine cycle. You stay close to the code and confirm each change with focused tests while maintaining steady forward progress on your CLI or service.

Chapter 5: Full Stack Monorepo Setup

5.1 Monorepo challenges, goals, and multi language plugin set

A full stack monorepo combines frontend apps, backend services, shared libraries, scripts, and infra code in one repository. A single workspace holds JavaScript, TypeScript, Python, Go, Rust, Dockerfiles, YAML, and configuration formats. Neovim must handle this variety without slowing down or losing clarity. This section explains the main challenges of monorepos, defines practical goals for your setup, and builds a plugin set that supports multiple languages while staying predictable.

Core challenges of monorepos

A monorepo introduces three consistent problems.

Large directory trees make navigation harder. You switch between frontend code, backend handlers, data pipelines, and tooling scripts many times per day. A flat navigation strategy breaks down when the tree grows.

Multiple languages and tools require separate compilers, language servers, formatters, and linters. Neovim must know which tools apply to each folder without manual switching.

Different subprojects use different configs for formatting, imports, dependencies, and builds. The workspace must load the correct settings for each context and avoid cross contamination.

A good Neovim setup reduces these friction points by detecting project roots, loading the right language servers, and giving you fast navigation across the entire tree.

Goals for a monorepo workspace

Define clear goals before adding plugins or configuration.

You want reliable root detection. Neovim should switch to the correct root for each buffer. A frontend file should detect the nearest package.json, not a backend go.mod at the top of the repo. A Rust crate should detect the nearest Cargo.toml. A Python project should detect its pyproject.toml or virtual environment.

You want one navigation method across languages. Searching files, jumping to symbols, and switching between modules should use the same keys and workflow regardless of language.

You want consistent completion, formatting, and diagnostics. The plugin stack should handle JavaScript, TypeScript, Python, Go, Rust, and config formats with the same interface while letting each language server handle its own rules.

You want the workspace to stay fast. A monorepo can have thousands of files. Neovim must avoid loading unnecessary LSPs and Treesitter parsers while still responding quickly to edits.

Plugin selection for multi language work

The plugin set includes language servers, Treesitter parsers, formatters, linters, and navigation tools. Each plugin must support many languages without forcing extra configuration per subproject.

Start with core language support.

return {

-- LSP support for all languages

{ "neovim/nvim-lspconfig" },

{ "williamboman/mason.nvim", build = ":MasonUpdate" },

{ "williamboman/mason-lspconfig.nvim" },

-- Completion

{ "hrsh7th/nvim-cmp" },

{ "hrsh7th/cmp-nvim-lsp" },

-- Treesitter for structure aware editing

{ "nvim-treesitter/nvim-treesitter", build = ":TSUpdate" },

-- Formatting and linting across languages

{ "nvimtools/none-ls.nvim" },

-- Project root detection

{ "ahmedkhalf/project.nvim" },

-- Fuzzy finder

{ "nvim-telescope/telescope.nvim" },

}

Explanation:

nvim-lspconfig and mason manage language servers for each subproject.
nvim-cmp handles completion across languages with one interface.
nvim-treesitter parses JavaScript, TypeScript, Python, Rust, Go, JSON, YAML, Dockerfiles, and other formats.
none-ls acts as a single entry point for formatters and linters, making multi language formatting predictable.
project.nvim detects project roots automatically. This keeps LSPs in the correct scope, especially in monorepos with nested roots.
telescope supports fast searching, file switching, and symbol lookup across large trees.

Root detection across languages

Monorepos contain several nested root markers: package.json, tsconfig.json, go.mod, Cargo.toml, pyproject.toml, and .git. You configure the detection logic to pick the nearest root for each file.

Example setup:

require("project_nvim").setup({

detection_methods = { "pattern" },

patterns = {

"package.json",

"tsconfig.json",

"go.mod",

"Cargo.toml",

"pyproject.toml",

".git",

},

})

This tells the plugin to search upward from each buffer until it finds the nearest matching marker. If you open a TypeScript file in a frontend app, Neovim picks the package.json in that folder and uses it as the working directory. If you open a Rust file in a backend crate, it picks that crate’s Cargo.toml.

Correct root selection matters because language servers load configuration files from the project root. If the wrong root is selected, formatting, imports, and diagnostics will not match the code style of that subproject.

Multi language Treesitter configuration

Each subproject uses different languages, but Treesitter can support all of them.

require("nvim-treesitter.configs").setup({

ensure_installed = {

"javascript",

"typescript",

"tsx",

"go",

"rust",

"python",

"json",

"yaml",

"toml",

"bash",

"dockerfile",

},

highlight = { enable = true },

indent = { enable = true },

})

Treesitter then provides accurate syntax and structure for all languages inside the monorepo without needing multiple parsers or modes.

Language server assignment per subproject

Each language server activates only when its root markers appear.

Example:

require("lspconfig").tsserver.setup({

root_dir = require("lspconfig.util").root_pattern("package.json"),

})

require("lspconfig").gopls.setup({

root_dir = require("lspconfig.util").root_pattern("go.mod"),

})

require("lspconfig").rust_analyzer.setup({

root_dir = require("lspconfig.util").root_pattern("Cargo.toml"),

})

require("lspconfig").pyright.setup({

root_dir = require("lspconfig.util").root_pattern("pyproject.toml"),

})

Each server attaches only to buffers inside its language domain. This avoids the common monorepo mistake where a JavaScript server tries to index Go files or a Python server attaches in a Rust crate.

Pulling the workspace together

With strong root detection, a unified plugin set, and language aware activation, your monorepo workflow stays clear. You move between frontend components, backend code, shared libraries, and infra files using consistent navigation and completion. Each part of the repo loads only the tools it needs, and Neovim responds quickly even when the repository grows large.

5.2 Treesitter and LSP coverage plan across backend, frontend, and infra

A full stack monorepo mixes frontend apps, backend services, and infrastructure code. Each area uses different languages and tools. Your Neovim setup should understand all of them without becoming fragile or slow. Treesitter gives structural parsing and consistent highlighting. LSP servers add navigation, diagnostics, and refactors for each language.

This section defines a coverage plan. You decide which parsers and language servers to enable, how they attach to files, and how they stay out of each other’s way.

Goals for coverage

You want three clear outcomes.

One Treesitter setup that covers all major languages in the repo.
Language servers that attach only where they belong.
Consistent navigation and diagnostics across backend, frontend, and infra.

This plan reduces manual switching and keeps feedback focused on the file you are editing.

Treesitter configuration for all layers

Treesitter works best when you define one list of parsers for the whole monorepo. That list should include all main languages plus common config formats.

Example Treesitter setup:

local ts = require("nvim-treesitter.configs")

ts.setup({

ensure_installed = {

-- Frontend

"javascript",       -- .js

"typescript",       -- .ts

"tsx",              -- .tsx React TypeScript

"css",              -- CSS modules and styles

"html",             -- HTML templates

-- Backend

"python",           -- Python APIs, jobs

"go",               -- Go services and CLIs

"rust",             -- Rust services and tools

"lua",              -- Neovim config and tools

-- Infra and config

"json",             -- package.json, tool configs, payloads

"yaml",             -- CI, Kubernetes, pipeline configs

"toml",             -- Cargo.toml, pyproject.toml

"bash",             -- shell scripts for automation

"dockerfile",       -- Docker images

},

highlight = {

enable = true,      -- parser based highlighting

},

indent = {

enable = true,      -- Treesitter driven indentation where supported

},

})

Line by line:

ensure_installed lists all parsers Neovim should keep available.
The frontend set covers React and styles.
The backend set covers Python, Go, Rust, and Lua.
The infra set covers JSON, YAML, TOML, shell, and Dockerfiles.
highlight.enable switches syntax to Treesitter for all these filetypes.
indent.enable improves indentation for supported languages, such as Python, Go, and Rust.

With this setup, you avoid separate parser lists per layer. Every buffer that matches these filetypes gets consistent structure aware highlighting.

Base LSP helper

Reuse a shared helper for LSP capabilities and keymaps. This keeps behavior the same in all languages.

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")

local capabilities = cmp_lsp.default_capabilities()

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr })

end

map("n", "gd", vim.lsp.buf.definition)          -- go to definition

map("n", "gr", vim.lsp.buf.references)          -- list references

map("n", "K", vim.lsp.buf.hover)               -- show docs and types

map("n", "<leader>rn", vim.lsp.buf.rename)     -- rename symbol

map("n", "<leader>ca", vim.lsp.buf.code_action) -- code actions

end

return {

lspconfig = lspconfig,

capabilities = capabilities,

on_attach = on_attach,

}

Every language server you configure receives these capabilities and the same core mappings.

Frontend LSP coverage

Frontend work centers on TypeScript, JavaScript, JSX, TSX, and CSS. You attach tsserver for code and a CSS server for styles.

local common = require("core.lsp_common")

local lspconfig = common.lspconfig

local util = require("lspconfig.util")

-- TypeScript and JavaScript

lspconfig.tsserver.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = util.root_pattern("package.json", "tsconfig.json"),

})

-- CSS and related formats

lspconfig.cssls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = util.root_pattern("package.json"),

})

root_dir patterns make tsserver attach only in frontend scopes with a package.json or tsconfig.json. cssls attaches where CSS is part of a Node based app. This keeps frontend servers out of backend and infra folders.

Backend LSP coverage

Backend code splits by language. You want each server to claim its own roots only.

Python with Pyright:

lspconfig.pyright.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = util.root_pattern("pyproject.toml", "setup.cfg"),

})

Go with gopls:

lspconfig.gopls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = util.root_pattern("go.mod"),

settings = {

gopls = {

staticcheck = true,

},

},

})

Rust with rust-analyzer:

lspconfig.rust_analyzer.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = util.root_pattern("Cargo.toml"),

settings = {

["rust-analyzer"] = {

cargo = { allFeatures = true },

},

},

})

Each server uses root patterns that match its build files. A Python service uses pyproject.toml. A Go module uses go.mod. A Rust crate uses Cargo.toml. This prevents cross attachment and keeps diagnostics correct inside each stack.

Infra LSP coverage

Infra files deserve proper validation because mistakes here break deployments.

YAML:

lspconfig.yamlls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

settings = {

yaml = {

validate = true,

completion = true,

},

},

})

JSON:

lspconfig.jsonls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

settings = {

json = {

validate = { enable = true },

},

},

})

TOML:

lspconfig.taplo.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

})

These servers cover CI pipelines, Kubernetes manifests, Cargo manifests, pyproject files, and tool configs. They also respect the same navigation and refactor keys you use in code buffers.

With this Treesitter and LSP coverage plan, your monorepo workspace stays coherent. Frontend, backend, and infra files all receive language aware parsing, navigation, and diagnostics, while each server stays inside its own area. This lets you move across the stack with the same habits and tools, even as the repository grows in size and complexity.

5.3 Fuzzy find, live grep, and symbol search optimized for large trees

Large monorepos slow you down if you scroll or browse by hand. You need fast ways to jump to files, search text across services, and find symbols inside a single app or crate. Neovim with Telescope and ripgrep gives this, as long as you tune it for big trees, ignore noise, and respect project roots.

Monorepo navigation goals

For large trees you want three things.

File search that respects the project root and ignores build output.

Live grep that searches code, not vendor directories or logs.

Symbol search that works within the current project or module, not the whole repo.

Telescope sits at the center of this plan. LSP and Treesitter supply symbol lists. Ripgrep performs fast text search.

Base Telescope setup for large repos

Start with a focused Telescope setup module.

Example lua/core/telescope.lua:

-- Load Telescope core and helpers

local telescope = require("telescope")

local builtin = require("telescope.builtin")

telescope.setup({

defaults = {

layout_strategy = "horizontal",        -- simple horizontal layout

layout_config = {

prompt_position = "top",             -- prompt at top of window

preview_width = 0.6,                 -- preview takes 60 percent

},

sorting_strategy = "ascending",        -- best matches at top

dynamic_preview_title = true,          -- show file name in preview title

-- Ignore noisy paths in large repos

file_ignore_patterns = {

"node_modules",

".git",

"dist",

"build",

"target",

"__pycache__",

".venv",

},

},

})

Explanation, block by block.

require("telescope") loads the plugin.
require("telescope.builtin") loads built in pickers.
layout_strategy and layout_config keep the UI predictable and easy to read.
prompt_position at top helps scanning results.
preview_width set to 0.6 gives enough room to inspect code.
sorting_strategy set to ascending places the closest match first.
file_ignore_patterns removes directories that produce noise in monorepos, such as node_modules, build outputs, and caches.

Root aware file search

In a monorepo you often want search scoped to the current project root, not the entire repository. You reuse the root helper from earlier chapters.

Example lua/core/root.lua:

local M = {}

local markers = {

"package.json",

"go.mod",

"Cargo.toml",

"pyproject.toml",

".git",

}

local function find_root(startpath)

local path = vim.fn.fnamemodify(startpath, ":p")

while path and path ~= "/" do

for _, marker in ipairs(markers) do

local full = path .. "/" .. marker

if vim.loop.fs_stat(full) then

return path

end

end

path = vim.fn.fnamemodify(path, ":h")

end

return vim.loop.cwd()

end

function M.get()

local buf = vim.api.nvim_buf_get_name(0)

if buf == "" then

return vim.loop.cwd()

end

return find_root(buf)

end

return M

This helper searches upward from the current file for known markers. It returns the nearest match. If nothing matches, it falls back to the current working directory. This behavior respects nested packages, services, and crates inside one repo.

Now wire root aware pickers into Telescope.

local root = require("core.root")

local map = vim.keymap.set

-- File search inside the current project root

local function project_files()

builtin.find_files({

cwd = root.get(),        -- restrict search to project root

hidden = true,           -- include dotfiles, still filtered by ignores

})

end

map("n", "<leader>ff", project_files, { desc = "Find files in project" })

Pressing <leader>ff now lists files inside the active project only, with dotfiles included and noisy directories filtered.

Fast live grep with ripgrep

Live grep lets you search code and configuration across a project. Telescope uses ripgrep by default if it exists on your PATH. You keep the same root helper to avoid scanning the entire monorepo.

local function project_live_grep()

builtin.live_grep({

cwd = root.get(),        -- run ripgrep from project root

})

end

map("n", "<leader>fg", project_live_grep, { desc = "Search text in project" })

You press <leader>fg, type part of a function name, error string, or configuration key, and ripgrep returns matches from the current project. This is useful when you need to trace an API handler through frontend calls, backend routes, and infra files.

If you want an even faster grep in huge trees, you switch to the ripgrep based file search and use git status.

map("n", "<leader>fG", builtin.grep_string, { desc = "Grep word under cursor" })

This shortcut searches for the word under the cursor, which speeds up investigation of function and type names.

Symbol search with LSP and Treesitter

Symbol search makes you jump between definitions without knowing file paths. Telescope integrates with LSP and Treesitter for this.

For workspace symbols through LSP:

local function workspace_symbols()

builtin.lsp_workspace_symbols({

query = "",              -- empty query shows prompt first

})

end

map("n", "<leader>fs", workspace_symbols, { desc = "Search workspace symbols" })

When you call this in a TypeScript project, tsserver returns functions, types, and classes. In a Go module, gopls returns functions and methods. In a Rust crate, rust-analyzer returns symbols across the workspace. You narrow results by typing parts of a name.

For buffer level symbols through Treesitter and LSP:

map("n", "<leader>fb", builtin.lsp_document_symbols, {

desc = "Search symbols in current file",

})

This lists functions, methods, structs, interfaces, and React components in the current buffer. You use it to jump between definitions inside a long file without scanning.

Example daily navigation loop

A typical session in a full stack monorepo uses these tools together. You open Neovim inside the repo. Root detection picks the nearest project for each buffer. In a React app, you press <leader>ff to open a component by name and <leader>fg to follow a string across frontend and backend. In a Go service, you use <leader>fs to list handlers and repositories, then use gd to reach definitions. For infra work, you live grep for a deployment name inside YAML files while file_ignore_patterns skip generated manifests. The same keys work everywhere, and Telescope routes you through large trees without manual browsing, which keeps your focus on code instead of directory structure.

5.4 Per project configuration, root detection, and local overrides

A full stack monorepo contains many subprojects. Each subproject has its own build rules, formatter settings, dependency files, and language servers. Neovim must understand which configuration applies to the buffer you are editing. This requires strong root detection, a clear system for local overrides, and a predictable way to load project specific settings without manual switching.

This section gives you a structured plan for per project configuration. You build reusable root detection logic, attach language tools based on root markers, and support optional overrides inside each project without interfering with the rest of the monorepo.

Why per project configuration matters

Monorepos often store frontend, backend, infra, and tooling code in parallel. Each area follows different conventions.

TypeScript projects use tsconfig.json and package.json.
Go modules use go.mod.
Rust crates use Cargo.toml.
Python environments use pyproject.toml or .venv.
Infrastructure uses YAML, JSON, and Dockerfiles with their own schemas.

If Neovim selects the wrong root, the wrong language server starts. That leads to broken imports, slow indexing, incorrect formatting rules, and confusing diagnostics. A clean setup should detect the nearest valid project root for each file and load only the tools for that root.

Root detection with markers

Root detection starts with a list of markers. These markers identify boundaries between projects. You build a searchable list and walk upward from the current buffer until you find a directory that contains one of them.

Example lua/core/root.lua:

local M = {}

-- Markers for different subprojects in a monorepo

local markers = {

"package.json",     -- Node frontend or tooling

"tsconfig.json",    -- TypeScript project root

"go.mod",           -- Go module root

"Cargo.toml",       -- Rust crate root

"pyproject.toml",   -- Python project root

".venv",            -- Python local environment

".git",             -- fallback root when nothing else matches

}

-- Search upward for the nearest project root

local function search_up(start)

local path = vim.fn.fnamemodify(start, ":p")

while path and path ~= "/" do

for _, marker in ipairs(markers) do

local full = path .. "/" .. marker

if vim.loop.fs_stat(full) then

return path       -- found valid root

end

end

path = vim.fn.fnamemodify(path, ":h")  -- step one level up

end

return vim.loop.cwd()    -- fallback to current working directory

end

-- Return root for the active buffer

function M.get()

local buf = vim.api.nvim_buf_get_name(0)

if buf == "" then

return vim.loop.cwd()

end

return search_up(buf)

end

return M

Explanation:

markers defines project boundaries for all languages in the monorepo.
search_up checks the current buffer’s directory and moves upward.
When a marker is found, the directory becomes the project root.
If none match, Neovim uses the current working directory.
M.get exposes a clean interface for other modules.

This module becomes the centerpiece for project aware behavior.

Using root detection in language servers

Each language server should attach only when its own root file appears. This avoids conflicts in monorepos where multiple language ecosystems sit side by side.

Example for TypeScript:

local root = require("core.root")

local common = require("core.lsp_common")

require("lspconfig").tsserver.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = function(fname)

return root.get(fname)   -- use custom root detection

end,

})

The root_dir function receives the path of the current buffer. It forwards this path to the root detection helper. This ensures tsserver attaches only when package.json or tsconfig.json exist nearby.

Example for Go:

require("lspconfig").gopls.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = require("lspconfig.util").root_pattern("go.mod"),

})

Because Go modules follow strict rules, using root_pattern is enough. root.get is still available for commands that need the project directory.

Example for Rust:

require("lspconfig").rust_analyzer.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = require("lspconfig.util").root_pattern("Cargo.toml"),

})

Rust crates are also predictable. Using root_pattern keeps the setup simple.

Example for Python:

require("lspconfig").pyright.setup({

capabilities = common.capabilities,

on_attach = common.on_attach,

root_dir = require("lspconfig.util").root_pattern("pyproject.toml", ".venv"),

})

Python projects vary, but pyproject.toml and .venv give strong signals.

These patterns prevent the most common monorepo mistake: language servers attaching in the wrong folder.

Local overrides for project specific behavior

Some projects need their own editor behavior. You might have frontend apps that require a different formatter config or a backend service that needs special test commands. Instead of editing global configuration, you load local overrides when a project root contains a specific file.

Example mechanism:

local root = require("core.root")

local function load_local_config()

local rd = root.get()

local candidate = rd .. "/.nvim.lua"

if vim.loop.fs_stat(candidate) then

dofile(candidate)    -- execute the project's override script

end

end

vim.api.nvim_create_autocmd("BufEnter", {

callback = load_local_config,

})

Explanation:

load_local_config finds the project root and checks for .nvim.lua.
If the file exists, it loads it as a Lua script.
You wrap it in a BufEnter autocommand so overrides apply when buffers open.

Inside a project specific .nvim.lua you might define overrides.

Example .nvim.lua inside a frontend app:

-- Use prettier via none-ls for this project

local null_ls = require("null-ls")

null_ls.register(null_ls.builtins.formatting.prettier.with({

extra_args = { "--single-quote" },

}))

Example .nvim.lua inside a backend service:

-- Custom test command

vim.keymap.set("n", "<leader>tt", function()

vim.cmd("!pytest")   -- run pytest from project root

end)

These overrides change behavior only for that folder. Nothing leaks into other subprojects.

Root detection in Telescope and other tools

Search tools should also use correct roots. Earlier sections created root aware file and grep commands. Using the same root.get helper keeps all tools aligned.

Example:

local root = require("core.root")

local builtin = require("telescope.builtin")

vim.keymap.set("n", "<leader>pf", function()

builtin.find_files({ cwd = root.get() })

end)

This produces consistent search behavior across the monorepo.

With strong root detection, selective LSP attachment, and optional local overrides, your monorepo workspace becomes predictable. Each project loads only the tools it needs, navigation stays context aware, and overrides remain isolated. This lets you move across diverse codebases without friction and keeps the editor aligned with the structure of the repository.

5.5 Example full stack change across backend, frontend, and infrastructure

This section walks through one end to end change in a monorepo. You touch the backend API, the frontend UI, and the infrastructure configuration, all from the same Neovim workspace. The goal is to show how your navigation, Treesitter, LSP, and root aware tools support a complete feature without friction.

The example adds a new field priority to a task entity. The backend exposes it through an API, the frontend lets users edit it, and the deployment pipeline passes a feature flag.

Starting from the backend API

You begin in the backend service. From any buffer you use your fuzzy finder to open the task route file inside the backend package.

Example services/tasks-api/src/routes/tasks.ts:

import { Router } from "express"

import { getTasks } from "../services/tasks"

const router = Router()

router.get("/tasks", async (_req, res) => {

const tasks = await getTasks()

res.json(tasks)                 // send tasks as JSON

})

export default router

You jump to the service implementation with gd on getTasks.

services/tasks-api/src/services/tasks.ts:

import { db } from "../db"

export type Task = {

id: string                    // unique identifier

title: string                 // task title

completed: boolean            // completion status

}

export async function getTasks(): Promise<Task[]> {

const rows = await db.query("select id, title, completed from tasks")

return rows.rows              // return rows from database client

}

You decide to add a numeric priority field. You update the type and the query.

export type Task = {

id: string

title: string

completed: boolean

priority: number              // new priority field

}

export async function getTasks(): Promise<Task[]> {

const rows = await db.query(

"select id, title, completed, priority from tasks"

)

return rows.rows

}

LSP highlights the new field wherever the Task type appears. If any code still assumes the old shape, diagnostics help you find and fix those places.

Adjusting backend tests

You open the backend test file with your fuzzy finder.

services/tasks-api/test/tasks.test.ts:

import request from "supertest"

import app from "../src/app"

test("returns tasks list", async () => {

const res = await request(app).get("/tasks")

expect(res.status).toBe(200)

expect(Array.isArray(res.body)).toBe(true)

expect(res.body[0]).toHaveProperty("title")

})

You extend the expectation to cover priority.

test("returns tasks list with priority", async () => {

const res = await request(app).get("/tasks")

expect(res.status).toBe(200)

expect(Array.isArray(res.body)).toBe(true)

const task = res.body[0]

expect(task).toHaveProperty("priority")

expect(typeof task.priority).toBe("number")

})

From inside this test you run your project test command, for example a mapping that runs npm test for the backend root. Output flows into the quickfix list so you jump to any failure lines inside Neovim.

Updating the frontend UI

Next you switch to the frontend app. Root detection places you in the correct apps/web project when you open a React component.

Example apps/web/src/components/TaskList.tsx:

import React from "react"

import { Task } from "../types"

type Props = {

tasks: Task[]                 // tasks passed from parent

}

export function TaskList({ tasks }: Props) {

return (

<ul>

{tasks.map(task => (

<li key={task.id}>

<span>{task.title}</span>

</li>

))}

</ul>

)

}

You update the shared type first so LSP guides the rest of the edits.

apps/web/src/types.ts:

export type Task = {

id: string

title: string

completed: boolean

priority: number              // align with backend Task

}

Back in TaskList, LSP completion now offers priority on task. You extend the UI.

export function TaskList({ tasks }: Props) {

return (

<ul>

{tasks.map(task => (

<li key={task.id}>

<span>{task.title}</span>

<span>Priority: {task.priority}</span>

</li>

))}

</ul>

)

}

Treesitter highlights JSX structure clearly, and your formatter keeps style consistent. You run the frontend tests or dev server command from Neovim using a task mapping that calls npm test or npm run dev in the apps/web root.

Aligning the API client

If the frontend fetches tasks from the backend, you update the client code too.

apps/web/src/api/tasks.ts:

import { Task } from "../types"

export async function fetchTasks(): Promise<Task[]> {

const res = await fetch("/api/tasks")

if (!res.ok) {

throw new Error("failed to load tasks")

}

const data = await res.json()

return data as Task[]          // rely on backend shape

}

Because the Task type now includes priority, any consumer that ignores it remains valid. If you want to check for missing fields you can add runtime guards. LSP ensures all imports still resolve and shows any incorrect property names.

Infrastructure and configuration changes

You now teach the deployment layer about this field. For example, you might add a feature flag or environment variable that controls default priority.

infra/k8s/tasks-api-deployment.yaml:

apiVersion: apps/v1

kind: Deployment

metadata:

name: tasks-api

spec:

template:

spec:

containers:

- name: tasks-api

image: tasks-api:latest

env:

- name: DEFAULT_PRIORITY

value: "3"     # default priority for new tasks

YAML LSP validates the structure and highlights mistakes such as bad indentation. If you wire environment variables into the backend config loader, Treesitter and LSP help you trace the value from YAML to TypeScript.

You might also update a CI workflow to include a migration step that adds the priority column.

.github/workflows/tasks-api.yml:

- name: Run database migrations

run: npm run migrate --workspace services/tasks-api

This ensures the database schema includes the new column before the updated service runs.

Connecting the whole loop

During this full stack change you stay inside Neovim. You jump between backend, frontend, and infra files with fuzzy find and live grep. Treesitter keeps each language easy to read. LSPs for TypeScript and YAML catch type and structure errors. Root detection ensures build and test commands run in the correct project. This tight loop lets you implement and verify cross cutting features quickly while keeping all layers of the stack aligned.

Chapter 6: Neovim for Writing, Notes, and Documentation

6.1 Writing goals and differences from coding oriented setups

A Neovim setup for writing and notes has different goals from a coding setup. When you write prose, you care about flow, readability, and low friction. You do not need diagnostics, LSP popups, or error signs around every word. Instead you want soft wrapping, clean fonts, comfortable line width, and simple tools for spelling, folding, and navigation by sentences and paragraphs.

This section explains what changes when you use Neovim for writing and how to adjust your configuration so it feels like a calm writing room, not a code editor.

Writing goals versus coding goals

Coding setups optimize for structure and correctness. Plugins highlight syntax, run linters, show diagnostics, and provide refactors. The screen often shows multiple splits, a statusline full of information, and signs in the gutter for Git and errors.

Writing setups optimize for concentration and clarity. You want:

A single focused buffer with minimal visual noise.
Soft wrapped text at a readable width.
Spelling support for prose, not error messages for code.
Simple movement by line, sentence, and paragraph.

Tools that are helpful for code, such as virtual text diagnostics and inline hints, distract when you write long paragraphs. Your writing profile should turn them off and enable options tuned for text.

Basic options for a writing buffer

You can define a function that applies a set of options whenever you open a writing file such as Markdown.

Example Lua module for writing mode:

local M = {}

function M.enable()

-- Wrap lines at window edge instead of cutting words

vim.opt_local.wrap = true

-- Indent wrapped lines to align with start of paragraph

vim.opt_local.breakindent = true

-- Make movements consider wrapped lines as visual lines

vim.opt_local.linebreak = true

-- Turn on spell checking for the buffer

vim.opt_local.spell = true

-- Use English as the spell language

vim.opt_local.spelllang = "en"

-- Hide list characters such as tabs and trailing spaces

vim.opt_local.list = false

-- Disable relative numbers to reduce visual noise

vim.opt_local.relativenumber = false

-- Keep absolute line numbers or turn them off if you prefer

vim.opt_local.number = false

end

return M

Line by line:

wrap makes long lines continue visually instead of scrolling horizontally. This matters because prose files often use long sentences that exceed 80 characters.
breakindent aligns wrapped parts of a paragraph under the first line, so the paragraph looks like one block.
linebreak moves wraps only at word boundaries, which avoids breaking words in the middle.
spell and spelllang turn on the built in spell checker so mistyped words are highlighted.
list controls display of special characters. Turning it off removes dots or arrows that are useful for code but noisy for text.
relativenumber and number remove the numeric column, which you almost never need for prose.

You can attach this function to specific filetypes.

vim.api.nvim_create_autocmd("FileType", {

pattern = { "markdown", "text" },

callback = function()

require("core.writing").enable()

end,

})

Now Markdown and plain text buffers automatically become writing friendly.

Statusline and UI simplifications

In a coding setup the statusline often shows mode, filetype, diagnostics counts, Git branch, and cursor position. For writing you need much less. A simplified statusline lowers visual clutter.

Example minimal lualine configuration for writing:

local lualine = require("lualine")

lualine.setup({

options = {

theme = "auto",

section_separators = "",

component_separators = "",

},

sections = {

lualine_a = { "mode" },          -- current mode

lualine_b = { "filename" },      -- current file name

lualine_c = {},                  -- no extra info

lualine_x = { "progress" },      -- percent through file

lualine_y = { "location" },      -- line and column

lualine_z = {},

},

})

This configuration keeps only the essential pieces. You still know where you are in the document and which file is open, but you do not see diagnostics or Git counts. For writing chapters or long notes, this is usually enough.

Disabling coding heavy plugins for writing

Some plugins are useful for code but distracting for prose. You can disable them on specific filetypes. For example, you might want to hide inline diagnostics and Treesitter context lines in Markdown.

Example autocommand to reduce noise:

vim.api.nvim_create_autocmd("FileType", {

pattern = "markdown",

callback = function()

-- Disable virtual text diagnostics in writing buffers

vim.diagnostic.config({

virtual_text = false,

}, vim.api.nvim_get_current_buf())

-- Optional, disable Treesitter context plugin if you use it

if package.loaded["treesitter-context"] then

require("treesitter-context").disable()

end

end,

})

This keeps the writing experience free of underlines and side annotations that belong to code.

Text objects and navigation for prose

When writing, you often move by sentence or paragraph instead of by function or block. Vim already has basic paragraph motions such as curly braces for paragraph jumps. You can also add mappings that select entire paragraphs for editing.

Example simple mappings:

-- Select current paragraph in visual mode

vim.keymap.set("n", "vap", "vap", { desc = "Select a paragraph" })

-- Delete current paragraph and enter insert mode

vim.keymap.set("n", "dap", "dapO", { desc = "Replace paragraph" })

These reuse built in text objects. You select or replace paragraphs quickly, which helps when reorganizing sections in a draft.

For headings in Markdown, Treesitter based plugins or simple search commands also help, but the key point is to favor motions that treat text as sentences and paragraphs, not as code blocks.

Neovim becomes a capable writing tool when you adjust it toward comfort and focus. By switching from error heavy coding defaults to soft wrapping, clean statuslines, and spelling, you turn the same editor into a quiet place for prose, notes, and documentation while keeping familiar motions and commands.

6.2 Lightweight plugins for Markdown, notes, and documentation

Neovim works well for writing when you add a small set of focused plugins instead of a large note taking framework. The goal is to keep startup fast and the interface quiet while still giving you structure, preview, and navigation for Markdown and documentation.

This section introduces lightweight plugins that improve Markdown editing, note workflows, and documentation work without turning Neovim into a separate application.

Writing friendly plugin goals

For writing and notes you want three kinds of support.

Plain text comfort such as better wrapping, folding, and heading motions.
Preview and formatting helpers that show how Markdown will render.
Note navigation using dates, links, and fuzzy search rather than deep databases.

Plugins in this section build on the writing options from the previous subchapter. They do not require external services or complex setup.

Core plugin set for Markdown and notes

You add a minimal group of plugins focused on Markdown structure, preview, tables, and note navigation.

Example plugin list in Lua:

return {

-- Markdown syntax and folding improvements

{ "preservim/vim-markdown" },

-- Live Markdown preview in browser

{ "iamcco/markdown-preview.nvim", build = "cd app && npm install" },

-- Table editing helpers

{ "dhruvasagar/vim-table-mode" },

-- Simple focus mode for writing

{ "folke/zen-mode.nvim" },

-- Note navigation on top of Telescope

{ "renerocksai/telekasten.nvim" },

}

Each entry adds a clear capability. vim-markdown improves structure. markdown-preview.nvim gives you a rendered view. vim-table-mode handles Markdown tables. zen-mode provides a focused writing window. telekasten builds a light note system on top of plain Markdown files.

Markdown comfort helpers

vim-markdown improves heading handling, folding, and motion inside Markdown files. It treats sections as first class units.

Example minimal configuration:

vim.g.vim_markdown_folding_disabled = 0     -- enable folding by heading

vim.g.vim_markdown_conceal = 0              -- keep raw Markdown visible

vim.g.vim_markdown_new_list_item_indent = 2 -- control list indentation

Line by line:

vim_markdown_folding_disabled set to 0 turns on folding based on heading levels. This lets you fold sections while editing chapters.
vim_markdown_conceal set to 0 keeps Markdown syntax explicit so you see the real characters the file stores.
vim_markdown_new_list_item_indent controls how list items line up when you press Enter in a list.

You can add simple key mappings for folding.

vim.api.nvim_create_autocmd("FileType", {

pattern = "markdown",

callback = function()

vim.keymap.set("n", "zc", "zc", { buffer = true, desc = "Close fold" })

vim.keymap.set("n", "zo", "zo", { buffer = true, desc = "Open fold" })

end,

})

This keeps fold motions local to Markdown buffers and encourages you to structure long documents into sections.

Live Markdown preview

markdown-preview.nvim runs a small local server and opens your document in a browser. This is useful when you want to check headings, lists, and code blocks before publishing.

Example configuration:

vim.g.mkdp_auto_start = 0        -- do not start preview automatically

vim.g.mkdp_auto_close = 1        -- close preview when buffer closes

vim.g.mkdp_refresh_slow = 0      -- update as you type

vim.g.mkdp_filetypes = { "markdown" }

vim.keymap.set("n", "<leader>mp", ":MarkdownPreviewToggle<CR>", {

desc = "Toggle Markdown preview",

})

Explanation:

mkdp_auto_start set to 0 keeps preview off until you ask for it. This keeps writing mode light.
mkdp_auto_close closes the preview window when you exit the buffer so you do not collect stale previews.
mkdp_filetypes limits preview to Markdown.
The mapping under <leader>mp toggles preview for the current file.

You write with soft wrap and folding in Neovim, then toggle preview when you want to see the rendered result.

Table editing support

Documentation often includes simple tables. Writing Markdown tables by hand is slow. vim-table-mode helps by aligning columns and managing separators.

Example setup:

vim.g.table_mode_corner = "|"   -- use pipe at corners

vim.api.nvim_create_autocmd("FileType", {

pattern = "markdown",

callback = function()

vim.keymap.set("n", "<leader>tm", ":TableModeToggle<CR>", {

buffer = true,

desc = "Toggle table mode",

})

end,

})

You press <leader>tm inside a Markdown buffer to turn table mode on or off. While it is on, hitting Tab and Enter inside a table navigates between cells and keeps separators aligned. This reduces friction when editing reference style docs or feature comparison notes.

Focus mode for writing

zen-mode.nvim gives you a focused window by resizing the current buffer and hiding side elements. This suits sessions where you draft a longer section or document.

Example configuration:

local zen = require("zen-mode")

zen.setup({

window = {

width = 80,             -- width of the text area in columns

options = {

number = false,       -- hide line numbers

relativenumber = false,

},

},

plugins = {

gitsigns = { enabled = false },      -- hide Git signs

tmux = { enabled = false },          -- do not change tmux

},

})

vim.keymap.set("n", "<leader>zz", function()

zen.toggle()

end, { desc = "Toggle zen mode" })

This code sets a fixed text column width, hides line numbers, and disables some extra plugin visuals. You switch in and out with <leader>zz, so you return to normal editing when needed.

Lightweight note navigation

telekasten.nvim builds a note and link workflow on top of plain Markdown files. It uses Telescope for search and does not lock you into a proprietary format.

Example minimal setup:

local home = vim.fn.expand("~/notes")

require("telekasten").setup({

home = home,                        -- root directory for notes

take_over_my_home = false,          -- keep config explicit

auto_set_filetype = false,          -- rely on markdown filetype

})

vim.keymap.set("n", "<leader>nn", "<cmd>Telekasten new_note<CR>", {

desc = "New note",

})

vim.keymap.set("n", "<leader>nf", "<cmd>Telekasten find_notes<CR>", {

desc = "Find note",

})

Here home points at your notes directory. telekasten.new_note creates a new Markdown file with a title and date. telekasten.find_notes opens a Telescope picker for all notes. This keeps everything as standard Markdown files that you can search with normal tools and version with Git.

With this small plugin set, Neovim supports Markdown and documentation work without losing its lightweight feel. You gain structure, preview, table support, focus mode, and note navigation while still working in plain text with simple commands and predictable behavior.

6.3 UI tuning for prose, including themes, statusline, and line settings

A Neovim setup for writing should feel calm and uncluttered. When you write prose, you spend more time reading sentences than scanning symbols. Strong contrast, sharp gutters, and constant status updates help when you write code, but they can distract you when you work on chapters, docs, or notes. UI tuning for prose focuses on three areas: theme, statusline, and line settings.

The goal is a layout that fades into the background so you think about ideas, not the editor.

THEME CHOICES FOR WRITING

For prose you want a theme with gentle contrast, predictable heading colors, and no aggressive accent colors. You also want true color support so fonts and highlights look clean.

A basic theme setup in Lua might look like this:

-- Enable true color support in the terminal

vim.opt.termguicolors = true

-- Choose a softer theme suitable for long reading sessions

vim.cmd("colorscheme tokyonight-night")

This code does two simple things.

termguicolors tells Neovim to use full RGB color support. Without it, some themes look washed out or inconsistent.
colorscheme applies a theme. Here it uses a darker variant that avoids harsh whites and neon tones, which helps during longer writing sessions.

You do not need theme switching logic just for writing, but you can define a helper that applies extra tweaks when editing text files.

local M = {}

function M.apply_writing_highlights()

-- Dim the colorcolumn if it is enabled

vim.cmd("hi ColorColumn guibg=#1f2335")

-- Make comments slightly softer, useful when mixing prose and code blocks

vim.cmd("hi Comment gui=italic guifg=#5c6370")

end

return M

Later you call this function from a filetype autocmd for markdown or text. This keeps code colors sharp while softening prose highlights.

STATUSLINE FOR PROSE

A coding statusline often shows mode, Git branch, diagnostics, file encoding, and more. For prose, most of this information is unnecessary. You need only a simple sense of progress in the file and your position in the text.

You can configure lualine to provide a minimal statusline that works for both code and writing, but feels especially natural for prose.

local lualine = require("lualine")

lualine.setup({

options = {

theme = "auto",

section_separators = "",

component_separators = "",

globalstatus = true,          -- one statusline for the entire window

},

sections = {

lualine_a = { "mode" },       -- shows NORMAL, INSERT, etc.

lualine_b = { "filename" },   -- current file name

lualine_c = {},               -- no extra info in the center

lualine_x = {},               -- leave this section empty for writing

lualine_y = { "progress" },   -- percentage through the file

lualine_z = { "location" },   -- line and column

},

})

Explanation, section by section.

options removes separators so the line looks flat and quiet. globalstatus uses one statusline across the entire window, which works well when you keep a single writing pane.
lualine_a shows the current mode so you avoid confusion between insert and normal mode.
lualine_b shows the filename.
lualine_y and lualine_z show progress and location, which are useful when working in longer documents.
lualine_c and lualine_x are empty, so no diagnostics or Git counts appear while you write.

This statusline reduces visual noise without removing orientation entirely.

LINE SETTINGS FOR PROSE

Line related options have the biggest impact on writing comfort. Code often uses hard line length limits and no wrap. Prose benefits from soft wrapping and a visually centered text column.

A writing mode helper can apply these options for Markdown and text buffers.

local M = {}

function M.enable_writing_mode()

-- Wrap long lines at word boundaries

vim.opt_local.wrap = true

vim.opt_local.linebreak = true

-- Indent wrapped lines to align with the start of the paragraph

vim.opt_local.breakindent = true

-- Hide line numbers for a cleaner margin

vim.opt_local.number = false

vim.opt_local.relativenumber = false

-- Remove the colorcolumn guide

vim.opt_local.colorcolumn = ""

-- Hide the cursorline so only the text stands out

vim.opt_local.cursorline = false

-- Keep a minimal signcolumn to avoid shifts when signs appear

vim.opt_local.signcolumn = "no"

-- Enable spell checking for prose

vim.opt_local.spell = true

vim.opt_local.spelllang = "en"

-- Use larger conceallevel only if you like hiding formatting markers

vim.opt_local.conceallevel = 0

-- Do not show list characters (tabs, trailing spaces) while writing

vim.opt_local.list = false

end

return M

This function adjusts the buffer to behave more like a writing pane.

wrap and linebreak ensure that long sentences wrap at word boundaries rather than breaking words or forcing horizontal scrolling.
breakindent makes wrapped lines align nicely under the first line of the paragraph, which keeps blocks readable.
number and relativenumber off produce a clean left margin. Line numbers rarely matter in prose.
colorcolumn cleared removes visual width limits that are useful for code but distracting in text.
cursorline off keeps the current line from glowing. For some people this improves focus on the paragraph as a whole.
signcolumn set to no removes the gutter reserved for diagnostics and Git signs. This stabilizes layout and reduces visual noise.
spell and spelllang enable the spell checker so you catch typos early.
list off hides symbols for whitespace, which are useful for code alignment but unnecessary in prose.

You can apply writing mode automatically for prose filetypes.

vim.api.nvim_create_autocmd("FileType", {

pattern = { "markdown", "text" },

callback = function()

require("core.writing_ui").enable_writing_mode()

end,

})

This autocommand listens for Markdown and text buffers and then enables the writing UI. Your coding buffers keep their usual layout, while writing buffers immediately switch to a softer, more comfortable style.

When themes, statusline, and line settings are tuned this way, Neovim feels like a dedicated writing space whenever you open prose files. You keep the same motions and commands you use for code, but the interface fades back so the words on the screen take center stage.

6.4 Folding, outlines, templates, and snippets for documents and notes

Documents and notes grow messy if you see everything at once. A good Neovim writing setup treats sections as units, gives you a quick outline, and lets you drop in reusable templates. Folding hides detail, outlines help you jump around, and snippets and templates speed up structure so you think about content instead of formatting.

This section shows how to use these tools in a way that stays light and predictable.

Folding documents by headings

For Markdown and long notes, folding by heading level is simple and effective. You want folds to follow the structure of the document, not manual markers.

Set folding for Markdown and text buffers:

vim.api.nvim_create_autocmd("FileType", {

pattern = { "markdown", "text" },

callback = function()

-- Use syntax based folding (works well with Markdown plugins or Treesitter)

vim.opt_local.foldmethod = "expr"

vim.opt_local.foldexpr = "nvim_treesitter#foldexpr()"

-- Start with all folds open so you see the full document

vim.opt_local.foldlevel = 99

end,

})

Explanation:

foldmethod set to expr tells Neovim to ask an expression for fold levels.
foldexpr tied to Treesitter lets headings and lists define fold boundaries.
foldlevel set high keeps everything open by default so you control folding manually.

You use:

zc to close a fold under the cursor.
zo to open it.
zM to close all folds.
zR to open all folds.

This lets you collapse sections you are not editing and keep focus on the current part of the document.

Quick outlines with Telescope symbols

For long documents you also want a quick outline view. Treesitter and LSP can expose headings and sections as symbols, and Telescope shows them in a list.

A simple outline for Markdown using LSP document symbols:

local builtin = require("telescope.builtin")

vim.keymap.set("n", "<leader>do", function()

builtin.lsp_document_symbols({

symbols = { "String", "Namespace" },  -- broad match that works for headings

})

end, { desc = "Document outline" })

If you prefer Treesitter based navigation, you can use the generic symbols picker:

vim.keymap.set("n", "<leader>ds", builtin.treesitter, {

desc = "Treesitter document symbols",

})

In both cases you press the mapping, see a list of headings and sections, type a few characters, and hit Enter to jump. This is useful when you split a chapter into many subsections and you need to move between them quickly.

Templates for recurring document types

Many documents share a common skeleton. For example, meeting notes, design docs, or book chapters. Instead of copying old files, you define templates in Lua and insert them with a command.

Create a small module for templates:

local M = {}

local templates = {

meeting = {

"# Meeting notes",

"",

"Date: ",

"Attendees: ",

"",

"## Agenda",

"",

"- ",

"",

"## Decisions",

"",

"- ",

"",

"## Actions",

"",

"- ",

},

chapter = {

"# Chapter title",

"",

"## Introduction",

"",

"## Main section",

"",

"## Examples",

"",

"## Closing",

"",

},

}

local function insert_lines(lines)

-- Insert lines at top of buffer

vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)

end

function M.insert(name)

local tpl = templates[name]

if not tpl then

vim.notify("Unknown template: " .. name, vim.log.levels.WARN)

return

end

insert_lines(tpl)

end

return M

Then expose user commands:

vim.api.nvim_create_user_command("TemplateMeeting", function()

require("core.templates").insert("meeting")

end, {})

vim.api.nvim_create_user_command("TemplateChapter", function()

require("core.templates").insert("chapter")

end, {})

You open a new Markdown file, run :TemplateChapter, and the base structure appears. You then fill in headings and content instead of setting up sections by hand.

Snippets for small patterns in prose

Templates handle whole documents. Snippets handle smaller recurring patterns like callout blocks, definition lists, or checklists. LuaSnip works for both code and prose.

Define a few Markdown snippets:

local ls = require("luasnip")

local s = ls.snippet

local t = ls.text_node

local i = ls.insert_node

ls.add_snippets("markdown", {

-- Callout block

s("note", {

t("> Note: "), i(1, "text here"),

}),

-- Task list item

s("todo", {

t("- [ ] "), i(1, "task description"),

}),

-- Section header with placeholder

s("sec", {

t("## "), i(1, "Section title"),

t({ "", "" }), i(2, "Start writing here"),

}),

})

Explanation:

The snippet file targets the markdown filetype.
The “note” snippet expands to a Markdown blockquote with a placeholder.
The “todo” snippet inserts a checklist item.
The “sec” snippet inserts a level two heading and a placeholder paragraph.

To use them, you type the trigger text, such as sec, and press your snippet expansion key (for example Tab, depending on your LuaSnip mapping). You then move through the placeholders with the snippet jump keys.

Snippets save time when you repeat the same structures many times, such as notes with standard headings or documents with predictable sections.

Putting folding, outlines, templates, and snippets together

In a typical writing session you combine all of these tools. You start a new chapter and insert a base template with a command. As you draft, you use snippets to add headings, notes, and task lists quickly. Folding lets you collapse completed sections while you focus on the current part. The outline mapping gives you a quick view of the structure, so you jump between introduction, examples, and closing without losing your place. With these features in place, Neovim supports you through the full life cycle of a document, from blank page to well structured text, while still staying light and under your control.

6.5 Example writing and revision workflow for a multi file document

Large documents work best when each chapter or section lives in its own file. Neovim handles this structure well when you treat the document directory as a small project. You combine consistent file navigation, writing-friendly settings, templates for new chapters, and simple revision techniques that let you move through the entire document without losing context.

This section presents a complete workflow using Markdown files arranged as chapters inside a folder. The same structure works for long reports, design documents, or course material.

Project layout and navigation across files

Start with a clean directory structure.

docs
book
01-title.md
02-introduction.md
03-core-topics.md
04-appendix.md

You open the folder as your working root.

cd docs/book
nvim

Inside Neovim you rely on Telescope to switch between chapters.

local builtin = require("telescope.builtin")

vim.keymap.set("n", "<leader>ff", function()

builtin.find_files({

cwd = vim.fn.getcwd(),        -- only search inside current document folder

hidden = false,               -- ignore dotfiles

})

end, { desc = "Find chapter" })

This mapping lists only the chapter files, which keeps navigation predictable in large writing projects.

Applying writing mode to all chapters

A multi file document stays easier to manage when every chapter opens with the same writing options. You use your writing mode helper to set wrap, linebreak, spelling, and a quiet margin.

vim.api.nvim_create_autocmd("FileType", {

pattern = "markdown",

callback = function()

require("core.writing_ui").enable_writing_mode()   -- apply writing settings

end,

})

This ensures every chapter looks and behaves like the same writing environment. You avoid visual jumps when switching files.

Creating new chapters with a reusable template

When you add a new chapter you avoid blank page syndrome by inserting a base template with standard sections. You define a helper command.

vim.api.nvim_create_user_command("NewChapter", function(opts)

local title = opts.args ~= "" and opts.args or "Chapter title"

-- Base structure for a new chapter

local lines = {

"# " .. title,

"",

"## Overview",

"",

"## Main ideas",

"",

"## Examples",

"",

"## Closing",

"",

}

-- Insert template at top of the file

vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)

end, { nargs = "?" })

To use it, you create a new file and run:

:NewChapter Writing workflow

This gives you consistent sections across all chapters, which helps during later editing and proofreading.

Outlines and folding for fast structure editing

As the document grows, the ability to jump between sections becomes important. Telescope’s Treesitter picker acts as a quick outline.

vim.keymap.set("n", "<leader>do", function()

require("telescope.builtin").treesitter()       -- list headings

end, { desc = "Document outline" })

Pressing the mapping displays all headings in the current chapter. You type part of a section name and jump instantly.

You also enable folding with Treesitter so each heading becomes a fold boundary.

vim.api.nvim_create_autocmd("FileType", {

pattern = "markdown",

callback = function()

vim.opt_local.foldmethod = "expr"                 -- compute folds with expression

vim.opt_local.foldexpr = "nvim_treesitter#foldexpr()"

vim.opt_local.foldlevel = 99                      -- start unfolded

end,

})

During drafting you keep everything open. During revision you collapse completed sections and keep only the current part expanded.

Tracking revision state across chapters

Multi file documents often move through draft, review, and polished states. A simple system uses inline markers at the top of each file.

Examples:

Status: DRAFT
Status: REVIEW
Status: FINAL

You create a Telescope search for draft chapters.

vim.keymap.set("n", "<leader>sd", function()

require("telescope.builtin").live_grep({

cwd = vim.fn.getcwd(),             -- search all chapters

default_text = "Status: DRAFT",

})

end, { desc = "Find draft chapters" })

This displays all files that still need work. You open each one and revise until the marker changes.

Performing a focused revision pass

A revision session follows a steady loop.

You start with the draft search. Telescope lists all chapters still marked as DRAFT.
You open the first file and enable zen mode if you prefer a centered layout.
You fold sections that are complete and expand only the section you are improving.
You use document symbols to jump between subsections that need work.

Inside the chapter you add simple TODO markers.

TODO: rewrite introduction
TODO: verify example

Before updating the status, you run a grep inside the file to ensure no TODO markers remain.

vim.keymap.set("n", "<leader>st", function()

require("telescope.builtin").grep_string({

search = "TODO",

})

end, { desc = "Find TODO markers" })

After addressing every TODO, you change the chapter’s marker to REVIEW and move to the next draft file.

Using Git to track multi file revisions

You commit your changes in small, chapter-focused commits. A simple pattern:

Stage only the chapter you revised.
Open diff view inside Neovim to confirm the changes.
Write a short message identifying the chapter and purpose.

Example:

Edit Chapter 3 examples and transitions

Small commits keep revision history clear and make it easy to navigate earlier stages of your document.

With this workflow, Neovim becomes a stable environment for multi file writing. You navigate like a project, structure chapters consistently, revise with folding and outlines, and track progress with a light status system, all while keeping the editor simple and focused on the text.

Chapter 7: Tmux and Neovim Integrated Terminal Workflow

7.1 Role separation between tmux and Neovim, and base layout

Tmux and Neovim both manage windows and splits, but they work best when they do different jobs. If you let both tools manage layout in the same way, you get key conflicts and confusing nesting. If you give each tool a clear role, the workflow stays simple and predictable.

Think of tmux as the session and process manager. It owns terminals, long running commands, and high level window layout. Neovim is the editing space inside one of those panes. It owns buffers, code windows, and integrated terminals that are close to your current file.

This section defines that separation and shows a base layout that fits development, testing, and documentation work.

What tmux should handle

Tmux is a terminal multiplexer. It should handle tasks that live at the shell layer.

Good candidates for tmux:

One pane or window per project.
Long running servers such as npm run dev, uvicorn, go run, or cargo watch.
System level tools such as htop, docker commands, or monitoring.
A dedicated shell for quick experiments that do not belong inside Neovim.

You want tmux to survive editor restarts. If Neovim crashes or you close it, tmux panes stay alive. This is useful when a server or watcher has been running for a long time.

A simple .tmux.conf that supports this role:

# Use Ctrl a as tmux prefix

set-option -g prefix C-a

unbind C-b

bind C-a send-prefix

# Use vi style keys in copy mode

setw -g mode-keys vi

# Split panes with | and -

bind | split-window -h      # vertical split

bind - split-window -v      # horizontal split

# Navigation between panes

bind h select-pane -L

bind j select-pane -D

bind k select-pane -U

bind l select-pane -R

Line by line:

The prefix changes to Ctrl a to avoid conflicts with common Neovim mappings.
Splits use simple keys, which makes window layout easy to remember.
Pane navigation with h, j, k, l mirrors Neovim motions, so your hands use the same pattern in both layers.

Tmux stays responsible for these panes and windows. Inside each pane, Neovim runs as a regular process.

What Neovim should handle

Neovim should manage everything inside its own terminal: buffers, splits, and tight loops around code, tests, and logs.

Good candidates for Neovim:

Editing source files.
Running short lived commands tied to the current buffer or project.
Embedded REPLs for Python, Node, or Go.
Floating terminals for tools that relate to the open file.

The key idea is distance. Commands that care about your current file or project belong in Neovim. Commands that care about the whole machine or session belong in tmux.

A light Neovim terminal helper:

local M = {}

-- Open a horizontal terminal for short lived project commands

function M.term_bottom(cmd)

cmd = cmd or vim.o.shell                -- use default shell if no command

vim.cmd("botright split")               -- open split at bottom

vim.cmd("resize 12")                    -- fixed height

vim.cmd("terminal " .. cmd)             -- start terminal with command

vim.cmd("startinsert")                  -- enter insert mode

end

return M

You then map a key:

vim.keymap.set("n", "<leader>tt", function()

require("core.term").term_bottom()      -- open shell at bottom

end, { desc = "Project terminal bottom" })

This terminal is tied to the current project. You might run pytest, npm test, or a one off script here, then close it when finished. If the terminal needs to live longer than Neovim, you move that command into a dedicated tmux pane instead.

Base layout combining tmux and Neovim

A simple base layout uses:

One tmux window per project.
Inside that window, two tmux panes.
Left pane runs Neovim as the main editor.
Right pane runs a long lived server or watcher.

You create it by hand:

In tmux:

Create a new window for a project.

In the left pane, run nvim.

Split right with prefix + | and start a server, for example npm run dev or cargo watch.

This layout works well for web or API work. The right pane shows server logs and reloads. The left pane runs Neovim with multiple splits and tabs that you manage from inside the editor.

Inside Neovim you can still open short lived terminals at the bottom using the helper above. These do not replace the tmux server pane. They handle things like quick linters, one off migrations, or data inspection.

Reducing conflicts between splits

Because both tmux and Neovim use h, j, k, l for movement, you need clear rules about where you are.

Simple approach:

Use tmux prefix for cross pane navigation.
Use Neovim keys for splits inside the editor.

Example Neovim mappings:

-- Move between Neovim splits

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")

In this setup:

Inside Neovim you use Ctrl h, j, k, l to move between splits.
In tmux you use prefix then h, j, k, l to move between panes.

There is no overlap because tmux only sees keys after its prefix, and Neovim sees plain Ctrl key combinations.

With this separation in place tmux keeps your long running processes and global layout stable, and Neovim stays focused on editing and project level commands. The two tools complement each other instead of competing, and your terminal environment feels like one coherent workspace instead of two overlapping window managers.

7.2 Integrated terminals, toggle keys, and split strategies

Integrated terminals inside Neovim keep short lived commands and feedback close to the file you are editing. They complement tmux, not replace it. Tmux keeps long running processes and global layout. Neovim terminals handle project focused commands such as tests, linters, formatters, and REPLs.

This section shows how to build a small terminal helper, map toggle keys, and choose split strategies that stay predictable across languages and projects.

TERMINAL USE CASES INSIDE NEOVIM

Use Neovim terminals for commands that depend on the current buffer or project and that you want to see next to the relevant file.

Examples include running pytest in the current Python project, running npm test in the current frontend app, running go test or cargo test, starting a Python or Node REPL tied to the project environment, and executing one off commands such as migrations or data inspection.

These commands do not need to survive editor restarts. They should open fast, run near your code, and close when you are done.

A SIMPLE TERMINAL HELPER MODULE

You start with a small Lua module that opens a bottom split terminal. This split is good for test output and short logs.

Example lua/core/term.lua:

local M = {}

-- Internal function to open a terminal split at the bottom

local function open_bottom_terminal(cmd)

-- Use the user shell if no command is provided

cmd = cmd or vim.o.shell

-- Open a horizontal split at the bottom

vim.cmd("botright split")

-- Set a fixed height for the terminal

vim.cmd("resize 12")

-- Start a terminal running the command

vim.cmd("terminal " .. cmd)

-- Enter insert mode so keystrokes go to the terminal

vim.cmd("startinsert")

end

-- Public helper to open or reuse a bottom terminal

function M.project_terminal(cmd)

open_bottom_terminal(cmd)

end

return M

Explanation line by line.

The module exports a public function project_terminal. This function calls open_bottom_terminal, which handles split creation. cmd defaults to the user shell, which means you get a normal interactive shell if you do not specify a command. botright split opens a split at the bottom of the current window so you know where the terminal will appear. resize 12 fixes its height, which keeps the layout stable. terminal starts the command. startinsert switches to terminal input mode immediately, so you do not need extra keystrokes.

TOGGLE KEYS FOR COMMON TERMINAL ACTIONS

You now create key mappings that call this helper with or without specific commands.

Example mappings:

local term = require("core.term")

-- Open a plain project shell at the bottom

vim.keymap.set("n", "<leader>tt", function()

term.project_terminal()           -- use default shell

end, { desc = "Project shell" })

-- Run tests using a project specific command

vim.keymap.set("n", "<leader>tr", function()

term.project_terminal("pytest")   -- replace with npm test, go test, etc.

end, { desc = "Run tests" })

The first mapping opens a shell in a bottom split. You use it for ad hoc commands. The second mapping runs pytest directly. In a JavaScript project you would change the command to npm test. In a Go or Rust project you would use go test or cargo test. The idea is that the mapping name stays the same, while the command adapts per project through local overrides.

Basic terminal buffers are not toggle aware by default. If you want true toggling, you can track a window id and close or restore it.

local term_win = nil

local function toggle_bottom_terminal(cmd)

if term_win and vim.api.nvim_win_is_valid(term_win) then

-- Close existing terminal window

vim.api.nvim_win_close(term_win, true)

term_win = nil

return

end

-- Create a new terminal window and remember its id

vim.cmd("botright split")

vim.cmd("resize 12")

vim.cmd("terminal " .. (cmd or vim.o.shell))

vim.cmd("startinsert")

term_win = vim.api.nvim_get_current_win()

end

function M.toggle_project_terminal(cmd)

toggle_bottom_terminal(cmd)

end

Now you can map a real toggle:

vim.keymap.set("n", "<leader>tt", function()

term.toggle_project_terminal()

end, { desc = "Toggle project terminal" })

Pressing the mapping opens the terminal if it is not visible, and closes it if it is.

SPLIT STRATEGIES FOR DIFFERENT TASKS

Not all commands fit well in a bottom split. Some outputs benefit from a vertical split on the right, especially long log lines or REPLs.

Add a right side helper:

function M.term_right(cmd)

cmd = cmd or vim.o.shell

-- Open a vertical split on the right

vim.cmd("vert rightbelow split")

-- Set width to one third of the screen

vim.cmd("vertical resize 80")

vim.cmd("terminal " .. cmd)

vim.cmd("startinsert")

end

Example mappings:

-- Right side REPL, good for long commands or interactive work

vim.keymap.set("n", "<leader>pr", function()

require("core.term").term_right("python")   -- or node, ipython, etc.

end, { desc = "Python REPL right" })

Use the bottom split for commands you run and scroll occasionally, such as tests and linters. Use the right split for interactive sessions where you type often and need to see more history.

NAVIGATION BETWEEN EDITOR AND TERMINAL

You navigate between Neovim windows with standard split keys. A consistent pattern uses Ctrl h, j, k, l.

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")

Inside the terminal, you often want a quick way to return to normal mode. A simple mapping:

vim.keymap.set("t", "<Esc>", [[<C-\><C-n>]], { desc = "Exit terminal mode" })

This makes the escape key return to normal mode when you are inside a terminal buffer.

With a clear set of helpers, toggle keys, and split strategies, integrated terminals in Neovim become reliable tools for project tasks. Short lived commands run close to your code, REPLs sit in predictable splits, and navigation between editing and output stays simple, which keeps your focus on the development loop rather than on window management.

7.3 Navigation mappings between Neovim windows and tmux panes

When you work with both tmux and Neovim in the same terminal, smooth navigation becomes essential. Each tool manages splits differently. Tmux controls terminal panes. Neovim controls editor windows. If you try to move between them with the same keys, the two layers fight for input. A clear division avoids confusion and builds a predictable movement system.

This section defines a simple rule set for navigation and provides tested mappings for both tools.

Why navigation rules matter

Tmux panes sit outside Neovim. Neovim windows sit inside one tmux pane. If both tools use identical shortcuts for movement, you cannot tell which layer will react. You solve this by assigning different key patterns to each layer.

Use this pattern:

Neovim splits use Control combined with h, j, k, l.
Tmux panes use the tmux prefix followed by h, j, k, l.

This keeps movement consistent without overlap. Your hands use the same letter keys for direction, but the modifier key tells the system whether you want to move inside Neovim or across tmux panes.

Configuring Neovim window navigation

Neovim already supports moving between splits with <C-w>h, <C-w>j, <C-w>k, and <C-w>l. These commands are long to type. You create shorter mappings based on Ctrl movement keys.

-- Move left inside Neovim

vim.keymap.set("n", "<C-h>", "<C-w>h", {

desc = "Move to left split",

})

-- Move down inside Neovim

vim.keymap.set("n", "<C-j>", "<C-w>j", {

desc = "Move to below split",

})

-- Move up inside Neovim

vim.keymap.set("n", "<C-k>", "<C-w>k", {

desc = "Move to above split",

})

-- Move right inside Neovim

vim.keymap.set("n", "<C-l>", "<C-w>l", {

desc = "Move to right split",

})

Explanation:

Each mapping triggers <C-w> followed by a direction key.
Using <C-h> through <C-l> gives you predictable movements.
These keys control only Neovim windows. They never affect tmux panes.

With these mappings you can split and navigate inside the editor without touching tmux controls.

Configuring tmux pane navigation

Tmux navigation uses a prefix. You choose Ctrl a or Ctrl b depending on personal preference. The goal is to avoid collisions with Neovim. Using Ctrl a as prefix works well because Neovim rarely depends on it.

Example .tmux.conf navigation section:

# Set tmux prefix to Ctrl a

set-option -g prefix C-a

unbind C-b

bind C-a send-prefix

# Pane navigation with h, j, k, l after prefix

bind h select-pane -L     # move to left pane

bind j select-pane -D     # move to pane below

bind k select-pane -U     # move to pane above

bind l select-pane -R     # move to right pane

Explanation:

The prefix ensures that tmux only listens to navigation commands after pressing Ctrl a.
The movement keys match Neovim directions.
This alignment builds muscle memory across the two layers.

With this setup, prefix + h moves across terminal panes. Ctrl h moves inside Neovim.

Bridging Neovim and tmux with smart navigation

Sometimes you want Neovim to send navigation commands to tmux when no Neovim split exists in that direction. A helper tool such as vim-tmux-navigator handles this automatically. You can also write your own minimal logic, but a plugin keeps the setup clean.

Installation example with a plugin manager:

return {

{ "christoomey/vim-tmux-navigator" },

}

This plugin overrides your navigation keys so that:

If a Neovim split exists in the requested direction, it moves inside Neovim.
If no Neovim split exists, it hands the movement to tmux automatically.

This improves flow when you work with large layouts that mix tmux panes and Neovim windows.

Interaction with terminal mode inside Neovim

When you work inside a Neovim terminal buffer, you need a clean way to return to normal mode before moving to another window or pane. Without this step, movement keys send characters to the terminal instead of controlling splits.

Add a terminal mode escape mapping:

vim.keymap.set("t", "<Esc>", [[<C-\><C-n>]], {

desc = "Exit terminal mode",

})

Now pressing Escape puts you back in normal mode. Once in normal mode, your Ctrl h, j, k, l navigation works again.

For convenience, you can add movement mappings that work from terminal mode by including a normal mode escape before sending navigation commands.

vim.keymap.set("t", "<C-h>", [[<C-\><C-n><C-w>h]], {

desc = "Move left from terminal",

})

This technique ensures that terminal buffers behave like normal windows in your navigation flow.

A stable layout for mixed navigation

When using both layers, aim for a simple base layout.

One tmux window per project.
Left pane: Neovim with splits for code.
Right pane: logs, servers, watchers, build commands.

You move inside Neovim with Ctrl h, j, k, l.
You move across panes with prefix then h, j, k, l.

This separation keeps mental load low. You always know which layer you are controlling.

With clear mappings, terminal mode escapes, and consistent directional keys, moving between Neovim windows and tmux panes becomes second nature. The editor and multiplexer stay in sync without collisions, which keeps the development workflow stable and comfortable across long sessions.

7.4 Running tests, builds, and logs in embedded and floating terminals

Integrated terminals inside Neovim keep tests, builds, and logs close to your code. You still use tmux for long living services, but Neovim terminals handle the fast feedback loop that belongs to the current buffer or project. Embedded splits work well for continuous output. Floating terminals work well for quick commands that you want to see, then hide.

This section builds a small terminal helper module and shows how to wire it into common workflows.

Embedded terminals for tests and builds

Embedded terminals live in normal splits. They are good for:

●        Running test suites for the current project

●        Running one off builds such as go build or npm run build

●        Watching short logs while editing the related file

Start with a helper module.

local M = {}

-- Open a terminal in a bottom split and run a command

function M.bottom(cmd)

cmd = cmd or vim.o.shell                  -- use user shell if no command

vim.cmd("botright split")                 -- open split at bottom

vim.cmd("resize 12")                      -- fixed height for readability

vim.cmd("terminal " .. cmd)               -- start terminal with command

vim.cmd("startinsert")                    -- enter terminal input mode

end

-- Open a terminal in a right split and run a command

function M.right(cmd)

cmd = cmd or vim.o.shell

vim.cmd("vert rightbelow split")          -- open vertical split on right

vim.cmd("vertical resize 80")             -- set width in columns

vim.cmd("terminal " .. cmd)

vim.cmd("startinsert")

end

return M

Explanation:

The module exports two functions, bottom and right.
Each function chooses a default command from the current shell when none is provided.
botright split and vert rightbelow split open predictable splits at the bottom and right.
resize and vertical resize pin the size so your layout stays stable.
terminal runs the command and startinsert puts you into terminal mode so keystrokes go straight to the process.

You then add mappings.

local term = require("core.term")

-- Bottom terminal for tests or quick builds

vim.keymap.set("n", "<leader>tb", function()

term.bottom("pytest")                     -- adjust per language or project

end, { desc = "Tests bottom" })

-- Right terminal for watching logs

vim.keymap.set("n", "<leader>lr", function()

term.right("npm run dev")                 -- example log or dev server

end, { desc = "Logs right" })

In a JavaScript project, you switch pytest to npm test. In a Go project, you use go test. The concept stays the same. Tests run in a bottom split where you glance at results, while a right split can show continuous logs.

Floating terminals for quick commands

Floating terminals appear as overlays. They are useful for:

●        Running a short command such as git status or cargo check

●        Running a one shot REPL to test a function

●        Inspecting a log snippet without changing your splits

You create a helper using Neovim’s floating window API.

local M = {}

local float_win

local float_buf

function M.float(cmd)

cmd = cmd or vim.o.shell

if float_win and vim.api.nvim_win_is_valid(float_win) then

-- Hide existing float if it is open

vim.api.nvim_win_close(float_win, true)

float_win = nil

return

end

-- Create a scratch buffer for the terminal

float_buf = vim.api.nvim_create_buf(false, true)

-- Compute centered window size

local cols = vim.o.columns

local lines = vim.o.lines

local width = math.floor(cols * 0.8)

local height = math.floor(lines * 0.6)

local row = math.floor((lines - height) / 2)

local col = math.floor((cols - width) / 2)

float_win = vim.api.nvim_open_win(float_buf, true, {

relative = "editor",

row = row,

col = col,

width = width,

height = height,

style = "minimal",

border = "single",

})

-- Start terminal in the floating buffer

vim.fn.termopen(cmd)

vim.cmd("startinsert")

end

return M

Explanation:

float_win and float_buf store the current floating terminal state.
If a floating window already exists and is valid, calling float again closes it. This gives a toggle effect.
create_buf makes a scratch buffer that will host the terminal.
columns and lines reflect the current Neovim size. You compute a width and height fraction for the float.
open_win shows the buffer centered with a minimal border.
termopen runs the command inside that buffer and startinsert enters terminal mode.

Mapping example:

local fterm = require("core.float_term")

vim.keymap.set("n", "<leader>tf", function()

fterm.float("git status")                 -- any quick command

end, { desc = "Floating git status" })

You can reuse the same mapping with no argument to open a general floating shell.

vim.keymap.set("n", "<leader>ts", function()

fterm.float()                             -- floating shell

end, { desc = "Floating shell" })

Exiting and moving around terminals

Inside terminal mode you often want to return to normal mode quickly.

vim.keymap.set("t", "<Esc>", [[<C-\><C-n>]], {

desc = "Exit terminal mode",

})

Once back in normal mode, you use your standard split navigation keys.

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")

This keeps movement consistent regardless of whether a window holds code, a split terminal, or a floating terminal.

With embedded splits handling continuous tests and logs, and floating terminals handling quick commands and checks, Neovim becomes a flexible shell for your dev loop. You stay close to the code, keep feedback in view, and hide output when you want to focus again, without leaving the editor or losing your place.

7.5 Example day working with services, logs, and code together

This section walks through a full day of development with tmux and Neovim working together. You will see how services, logs, and code stay visible at the same time without turning the terminal into a mess. The goal is a stable layout that survives all day while you move between debugging, implementing features, and running tests.

Assume a typical stack with three parts:

A backend API.
A frontend web app.
A background worker that processes jobs.

Your tools:

Tmux manages long running processes and high level layout.
Neovim manages code, short commands, and tight feedback loops.

Starting the day in tmux

You begin with one tmux session per project.

In a shell:

tmux new -s app

Inside tmux, you create a base layout with three panes.

Left pane for Neovim.
Top right pane for the main API server.
Bottom right pane for worker or log tail.

You create this layout:

Start with a single pane.
Split vertically for a right pane:

prefix + |

Then split the right pane horizontally:

prefix + -

Now start your services.

Top right pane:

npm run dev:api
or go run ./cmd/api
or uvicorn app.main:app

Bottom right pane:

npm run worker
or a Python or Go worker process
or tail -f logs/app.log

These commands live all day. If Neovim restarts, the services keep running because tmux owns the processes.

Opening Neovim and setting up splits

In the left pane, you start Neovim:

nvim

You open the main backend file, such as a route or handler. Inside Neovim you create a simple editing layout.

Horizontal split to pair code with tests:

:split

You now have:

Top left window for code.
Bottom left window for tests or related code.

You keep this layout stable and rely on fuzzy finding to change files inside each window. Your navigation inside Neovim uses:

Ctrl h, j, k, l

Your navigation between tmux panes uses:

prefix + h, j, k, l

This separation keeps movement simple.

Running backend tests inside Neovim

You wire a project terminal for tests using the helper from earlier chapters.

Example mapping:

local term = require("core.term")

vim.keymap.set("n", "<leader>tr", function()

term.bottom("pytest")          -- or go test ./..., npm test, cargo test

end, { desc = "Run tests in bottom terminal" })

When you are editing a backend test file in the bottom left window, you press:

<leader>tr

Neovim opens a bottom split terminal and runs the command. You see failures next to your code without leaving the editor. When the test run finishes, you can close the split or keep it for another run.

This is different from your long running API server in tmux. The server outputs every request and stays running. The Neovim test terminal is a short command tied to the current work.

Debugging with logs and code side by side

Imagine the frontend sends a request that fails with a 500 response. You want to see the error in logs and edit the handler.

You bring focus to the top right tmux pane with:

prefix + l

You watch the API output. The next failing request shows a stack trace with a function name and line number. You note the handler name.

You move back to the Neovim pane:

prefix + h

Inside Neovim, you use live grep to find the handler:

vim.keymap.set("n", "<leader>fg", function()

require("telescope.builtin").live_grep({

cwd = vim.fn.getcwd(),

})

end, { desc = "Search in project" })

You press <leader>fg, type part of the handler name, and open the file. You fix the bug, save, and immediately see the next request succeed or fail in the tmux logs. This loop keeps logs and code visible without constant context switches.

Using floating terminals for one off checks

Sometimes you need a quick command that should not change your split layout. For example:

git status
curl a single endpoint
running a small script

You use a floating terminal helper.

Example mapping:

local fterm = require("core.float_term")

vim.keymap.set("n", "<leader>tf", function()

fterm.float("git status")

end, { desc = "Floating git status" })

Pressing <leader>tf opens a centered floating terminal with git status. Pressing it again closes the float. This keeps the main Neovim splits and tmux panes intact.

You can also open a floating shell:

vim.keymap.set("n", "<leader>ts", function()

fterm.float()

end, { desc = "Floating shell" })

You run a quick command, press Escape to leave terminal mode, and toggle the float off when done.

Balancing frontend, backend, and worker tasks

During the day you move between layers.

For frontend tasks:

You open frontend files in Neovim and keep the backend pane visible to see API errors.
You run npm test in a bottom Neovim terminal for focused component tests.
You watch the frontend dev server logs in a dedicated tmux pane, maybe in a second tmux window if needed.

For worker tasks:

You keep the worker logs in the bottom right tmux pane.
When a job fails, you grab the job id from logs, then search in Neovim for the handler using live grep.
You fix the worker code, run unit tests in Neovim, then watch the next job succeed in tmux logs.

Throughout the day tmux owns the long running services and logs. Neovim owns files, focused tests, and small commands. Navigation stays consistent, and your layout stays stable while you move through different pieces of the system. This balance keeps your attention on behavior and code rather than on managing windows.

Chapter 8: Authoring and Publishing a Small Neovim Plugin

8.1 Defining the plugin idea, scope, and project layout

Writing a Neovim plugin works best when you start from a small, precise idea. You want a tool that solves one problem well, has a clear user story, and can be implemented in a few Lua modules. Once the idea is sharp, you define scope and project layout before writing code.

This section walks through that process using a concrete example plugin and shows how to organize the files so the plugin is easy to understand and publish.

Choosing a focused plugin idea

A good first plugin should:

Solve a specific annoyance you feel often.
Be useful without requiring heavy dependencies.
Fit in a handful of Lua files.

Example idea:

A “project terminal” plugin that opens a bottom split terminal in the current project root and runs a command. It gives you one command to open a project shell and one command to run a project test command.

User story:

In any project, run a single Neovim command to open a shell at the project root.
Optionally pass a command such as pytest or npm test.
Avoid configuring the same terminal logic in every personal config.

This idea is small, but general enough to be worth publishing.

Defining the plugin scope

Scope describes what the plugin does and what it will not do.

For this example:

The plugin will:

Find the project root using simple markers.
Open a bottom split terminal using that root as the working directory.
Provide a Lua function and a user command to open the terminal.
Provide a small configuration function to set default command and height.

The plugin will not:

Manage multiple terminals at once.
Provide floating terminals or right splits.
Integrate with tmux.
Handle complex per project configuration files.

This scope keeps the first version simple. Future versions can add features, but the base remains small and understandable.

Basic project layout on disk

Neovim Lua plugins follow a standard layout. You can place the plugin in your personal config during development, then move it to its own repository when ready.

Directory structure:

nvim-project-terminal/
lua/
project_terminal/
init.lua
config.lua
plugin/
project_terminal.lua
README.md
LICENSE

Responsibilities:

lua/project_terminal/init.lua holds the main logic and public API.
lua/project_terminal/config.lua stores default settings and a simple setup function.
plugin/project_terminal.lua registers user commands when Neovim loads the plugin.
README.md explains usage and installation.
LICENSE sets the licensing terms.

Neovim automatically loads Lua modules under lua/ and runs files in plugin/ at startup.

Configuration module

Start with the configuration module. It holds defaults and a setup function that users can call from their config.

lua/project_terminal/config.lua:

local M = {}

-- Default configuration

M.options = {

height = 12,                 -- default terminal height

default_cmd = nil,           -- no default command, use shell

root_markers = {             -- files that mark a project root

".git",

"package.json",

"pyproject.toml",

"go.mod",

"Cargo.toml",

},

}

-- Merge user options into defaults

function M.setup(opts)

M.options = vim.tbl_deep_extend("force", M.options, opts or {})

end

return M

Explanation:

options stores default values for height, default command, and root markers.
setup takes an optional table and merges it into options using tbl_deep_extend.
Users call require("project_terminal.config").setup({...}) to adjust behavior.

This module matters because it keeps global state in one place and lets users customize the plugin without editing its code.

Core logic module

The main module implements root detection and terminal creation.

lua/project_terminal/init.lua:

local config = require("project_terminal.config")

local M = {}

-- Find nearest project root by walking up the directory tree

local function find_root(startpath)

local path = vim.fn.fnamemodify(startpath, ":p")

while path and path ~= "/" do

for _, marker in ipairs(config.options.root_markers) do

local candidate = path .. "/" .. marker

if vim.loop.fs_stat(candidate) then

return path                -- found a root directory

end

end

path = vim.fn.fnamemodify(path, ":h")  -- go one level up

end

return vim.loop.cwd()            -- fallback to current working directory

end

-- Open a bottom terminal at the project root and run cmd

function M.open(cmd)

-- Use current buffer or cwd as starting point

local bufname = vim.api.nvim_buf_get_name(0)

local start = bufname ~= "" and bufname or vim.loop.cwd()

local root = find_root(start)

-- Change directory to project root

vim.cmd("lcd " .. vim.fn.fnameescape(root))

-- Open bottom split with configured height

vim.cmd("botright split")

vim.cmd("resize " .. config.options.height)

-- Choose command: explicit cmd, configured default, or shell

local run_cmd = cmd or config.options.default_cmd or vim.o.shell

vim.cmd("terminal " .. run_cmd)

vim.cmd("startinsert")

end

return M

Explanation:

find_root walks up from the current file or directory and looks for any root marker. If it finds one, it returns that directory. If not, it falls back to the current working directory.
open takes an optional command. It uses the current buffer name or current directory as the starting point to find the root. It changes Neovim’s local directory to the root with lcd so the terminal runs from the correct path. It opens a bottom split, resizes it to the configured height, decides which command to run, starts a terminal with that command, and enters insert mode.

This logic matters because it turns your small idea into a reusable function that can be called from Lua or from a user command.

Plugin entrypoint and user command

The plugin entrypoint registers a command so users can run the plugin without writing Lua.

plugin/project_terminal.lua:

-- Register :ProjectTerminal[ cmd] command

vim.api.nvim_create_user_command("ProjectTerminal", function(opts)

-- opts.args holds the rest of the command line after :ProjectTerminal

local arg = opts.args ~= "" and opts.args or nil

require("project_terminal").open(arg)

end, {

nargs = "?",                      -- command argument is optional

complete = "shellcmd",            -- enable shell command completion

})

Explanation:

create_user_command creates :ProjectTerminal.
nargs = "?" means the command accepts zero or one argument.
If the user runs :ProjectTerminal pytest, opts.args becomes "pytest".
The handler calls project_terminal.open with the argument or nil.
complete = "shellcmd" gives completion for shell commands in the command line.

From a user’s perspective:

:ProjectTerminal opens a shell in a project root.
:ProjectTerminal pytest opens a bottom split and runs pytest in the project root.

With a clear idea, tight scope, and a clean layout like this, you have the foundation for a small Neovim plugin that is easy to understand, document, and share.

8.2 Public API design with setup, options, commands, and keymaps

A small Neovim plugin becomes useful when its public API is simple and predictable. The API is what users see: the setup function, configuration options, Lua functions they can call, user commands, and suggested keymaps. If this surface is clean, the internal code can evolve without breaking everyone’s config.

This section uses the project terminal plugin from the previous subchapter and turns it into a well defined public API.

Design goals for the public API

Before writing code, decide how users should think about the plugin.

You want:

A single entry point, usually a setup function.
A small set of options with clear defaults.
One or two main Lua functions that users or other plugins can call.
One user facing command that covers most use cases.
Optional keymaps that users can copy into their config.

Anything more will slow adoption and make maintenance harder.

Exposing a setup function and options

The main module should export a setup function that accepts a table of options. Internally it passes those options to the config module. This gives users one place to configure the plugin.

lua/project_terminal/init.lua:

local config = require("project_terminal.config")

local M = {}

-- Public setup function to configure the plugin

function M.setup(opts)

-- Delegate to config module

config.setup(opts)

end

-- Open project terminal as before

function M.open(cmd)

-- implementation from previous section

end

return M

Explanation:

setup is the main entry point. Users call require("project_terminal").setup({...}) once in their Neovim configuration.
The function forwards opts to the config module’s setup, which merges them into defaults.
open remains the core behavior. It stays part of the public API because users might want to call it directly from Lua or custom keymaps.

This structure matters because it separates configuration (setup) from action (open). Other internal helpers remain private.

Documenting configuration options

The options table in the config module should be small and self explanatory. You keep it visible and easy to inspect.

lua/project_terminal/config.lua:

local M = {}

M.options = {

height = 12,                  -- height of bottom terminal split

default_cmd = nil,            -- default command, or use shell if nil

root_markers = {              -- project root markers

".git",

"package.json",

"pyproject.toml",

"go.mod",

"Cargo.toml",

},

}

function M.setup(opts)

M.options = vim.tbl_deep_extend("force", M.options, opts or {})

end

return M

Users see these keys in the README and understand what they change. Keeping options in a single table with comments reduces confusion and makes the API stable.

User commands as the main interface

Many users will interact with the plugin from the command line instead of Lua. You give them a single command that maps cleanly to core behavior.

plugin/project_terminal.lua:

-- :ProjectTerminal[ cmd]

vim.api.nvim_create_user_command("ProjectTerminal", function(opts)

local arg = opts.args ~= "" and opts.args or nil

require("project_terminal").open(arg)

end, {

nargs = "?",

complete = "shellcmd",

})

Explanation:

The command name matches the plugin name, which makes it easy to remember and search for.
nargs = "?" allows an optional command argument. If provided, it overrides default_cmd and the shell.
complete = "shellcmd" lets Neovim’s command line complete commands such as pytest, npm, or custom binaries.

This command is the primary public interface. From a user perspective:

:ProjectTerminal opens a project shell.
:ProjectTerminal pytest opens a terminal running pytest at the project root.

Lua API for keymaps and other plugins

While commands help interactive usage, Lua functions enable tighter integration and custom keymaps. You expose only what you want users to rely on.

Public API surface:

require("project_terminal").setup(opts)
require("project_terminal").open(cmd)

You do not expose internal helpers such as find_root. Those stay private so you can change them without breaking users.

Example configuration in a user’s Neovim config:

require("project_terminal").setup({

height = 10,

default_cmd = "pytest",

})

This sets custom defaults. Then users can define their own keymaps.

vim.keymap.set("n", "<leader>pt", function()

require("project_terminal").open()   -- uses configured default_cmd

end, { desc = "Project terminal" })

vim.keymap.set("n", "<leader>ps", function()

require("project_terminal").open("npm test")  -- override per call

end, { desc = "Project terminal npm test" })

Explanation:

The first keymap uses the configured default command, which might be pytest set via setup.
The second keymap overrides the command explicitly for a specific workflow.
This pattern lets people mix global defaults with local overrides in a predictable way.

Designing sensible defaults

A good API works without any setup call at all. That means your default configuration must be usable.

In this example:

height set to 12 gives a reasonable split size on most terminals.
default_cmd set to nil means “use the shell”, which is always available.
root_markers include common project types so root detection works for many languages.

A user can install the plugin and run :ProjectTerminal immediately. If they never call setup, the behavior is still useful.

Putting the public API together

With a clear setup function, a small options table, one core Lua function, and one user command, your plugin’s public API stays easy to learn and hard to misuse. Keymaps remain user owned but are simple to define, and internals stay hidden behind a stable surface. This structure makes the plugin fit naturally into different Neovim configurations while still being straightforward to maintain and extend over time.

8.3 Implementing behavior with buffers, windows, and scratch views

A Neovim plugin becomes practical when it works with buffers and windows in a predictable way. Lua gives you direct access to these building blocks through the Neovim API. Most useful behavior can be expressed as “create or reuse a buffer, show it in a window, and fill it with the right content.” Scratch views are special buffers that display information without touching real files.

This section explains how to think about buffers, windows, and scratch views, then applies these ideas to a small but realistic feature.

Buffers, windows, and their relationship

A buffer holds text. A window displays a buffer. One buffer can appear in many windows. A window always shows exactly one buffer. This separation matters because your plugin often needs to manage hidden data (buffers) and visible layout (windows) separately.

Common patterns:

Reuse an existing buffer when you want to preserve state, such as search results.
Create a new scratch buffer when you want a temporary view that should not be written to disk.
Open windows in standard positions (bottom split, right split, or floating) so users learn where your plugin appears.

You use the Lua API to work with these objects.

Creating and managing scratch buffers

Scratch buffers are ideal for plugin views such as logs, quick panels, or status summaries. You mark them as unlisted and unmodifiable so they do not interfere with normal file editing.

Example helper to create a scratch buffer:

local function create_scratch_buffer()

-- Create a new, unlisted, scratch buffer

local buf = vim.api.nvim_create_buf(false, true)

-- false: buffer is not listed in :ls

-- true: buffer is scratch, not associated with a file

-- Optional: set buffer options the view needs

vim.bo[buf].buftype = "nofile"         -- no file on disk

vim.bo[buf].swapfile = false          -- no swapfile

vim.bo[buf].bufhidden = "wipe"        -- wipe when hidden

return buf

end

Explanation:

nvim_create_buf creates a buffer that is not linked to a file. Passing false, true means “not listed” and “scratch.”
buftype set to nofile tells Neovim this buffer is not associated with a path on disk.
swapfile false prevents swap files for this buffer.
bufhidden set to wipe removes the buffer when it is hidden, so it does not accumulate in the buffer list.

You then fill the buffer with content:

local function set_lines(buf, lines)

-- Replace all existing lines in the buffer

vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)

end

This function writes a Lua table of strings into the buffer.

Opening windows for plugin views

Once you have a buffer, you decide how to display it. You can use a split or a floating window. Splits suit tools that feel like normal documents. Floating windows suit transient views.

Example, bottom split view:

local function open_bottom_window(buf, height)

-- Open horizontal split at bottom

vim.cmd("botright split")

vim.cmd("resize " .. height)

-- Set the buffer for the new window

local win = vim.api.nvim_get_current_win()

vim.api.nvim_win_set_buf(win, buf)

return win

end

Explanation:

botright split opens a new window at the bottom.
resize sets its height.
nvim_get_current_win gets the window id.
nvim_win_set_buf attaches your scratch buffer to that window.

For a floating view:

local function open_float_window(buf)

local cols = vim.o.columns

local lines = vim.o.lines

local width = math.floor(cols * 0.7)

local height = math.floor(lines * 0.6)

local row = math.floor((lines - height) / 2)

local col = math.floor((cols - width) / 2)

local win = vim.api.nvim_open_win(buf, true, {

relative = "editor",

row = row,

col = col,

width = width,

height = height,

style = "minimal",

border = "single",

})

return win

end

Here the window is centered with a border. style minimal hides window decorations such as statuslines. You pass the scratch buffer id and make the window current.

Putting it together for a simple plugin view

Extend the project terminal plugin with an information view that shows the detected project root and the command that will be executed. This view does not edit files. It only informs the user.

Example module:

local config = require("project_terminal.config")

local M = {}

local info_buf

local info_win

local function find_root(startpath)

-- Root detection logic from earlier sections

-- Returns a directory path string

end

local function build_info_lines(root, cmd)

return {

"# Project terminal info",

"",

"Root: " .. root,

"Command: " .. cmd,

"",

"Press q to close this window.",

}

end

local function ensure_info_buffer()

if info_buf and vim.api.nvim_buf_is_valid(info_buf) then

return info_buf

end

info_buf = vim.api.nvim_create_buf(false, true)

vim.bo[info_buf].buftype = "nofile"

vim.bo[info_buf].swapfile = false

vim.bo[info_buf].bufhidden = "wipe"

vim.bo[info_buf].filetype = "markdown"      -- enable simple formatting

return info_buf

end

function M.show_info()

-- Determine root and command as the plugin would use them

local bufname = vim.api.nvim_buf_get_name(0)

local start = bufname ~= "" and bufname or vim.loop.cwd()

local root = find_root(start)

local cmd = config.options.default_cmd or vim.o.shell

local lines = build_info_lines(root, cmd)

local buf = ensure_info_buffer()

vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)

-- Open or reuse floating window

if info_win and vim.api.nvim_win_is_valid(info_win) then

vim.api.nvim_win_set_buf(info_win, buf)

vim.api.nvim_set_current_win(info_win)

else

info_win = open_float_window(buf)

end

-- Map q to close the info window

vim.keymap.set("n", "q", function()

if info_win and vim.api.nvim_win_is_valid(info_win) then

vim.api.nvim_win_close(info_win, true)

info_win = nil

end

end, { buffer = buf })

end

return M

Explanation:

ensure_info_buffer creates or reuses a scratch buffer for the info view. It sets buftype, swapfile, and bufhidden so the buffer behaves like a temporary view. filetype markdown gives basic highlighting.
show_info computes the same root and command that the terminal will use. It builds descriptive lines and writes them into the buffer.
If an info window already exists, the code reuses it and brings it to the front. Otherwise it calls open_float_window to create a new centered view.
Inside the buffer, q closes the window. The mapping is buffer local, so it does not affect other buffers.

This pattern shows how to build a readable plugin surface: scratch buffer for content, a window helper for layout, and a single public function that ties them together.

By building behavior around buffers, windows, and scratch views in this way, you structure your plugin as a set of small, testable pieces. It becomes easier to adjust layout, reuse views, and extend functionality without forcing users to change how they interact with your plugin.

8.4 Autocommands, events, docs, and README for user facing behavior

A Neovim plugin feels complete when it reacts at the right moments and explains itself clearly. Autocommands and events define when your plugin acts. Documentation, including a README and help file, explains how to use it. Both parts are user facing, so they must be predictable and simple.

This section shows how to add autocommands and events to the project terminal plugin, then how to write practical docs and a README that match the behavior.

Autocommands and when to use them

Autocommands let your plugin react to editor events such as opening a buffer, changing directories, or leaving Neovim. You must be careful with them. Too many autocommands make the plugin feel intrusive.

Good uses for autocommands:

Lazy initialization when the first relevant buffer appears.
Cleaning up temporary windows or buffers when a session ends.
Recalculating internal state when the working directory changes.

For the project terminal plugin, a simple use case is caching the last used command per project so the plugin can offer smart defaults.

You define an autocommand group to avoid duplicates.

Example:

local group = vim.api.nvim_create_augroup("ProjectTerminal", {

clear = true,        -- remove any old autocommands in this group

})

This group holds all autocommands for the plugin, which keeps them organized.

Tracking last command with events

Suppose you want the plugin to remember the last command used per session. You can track this when a terminal created by the plugin exits.

You can use TermClose as the event.

Example:

vim.api.nvim_create_autocmd("TermClose", {

group = group,

callback = function(args)

local buf = args.buf

-- Use buffer variables to check if this terminal belongs to the plugin

local is_project_term = vim.b[buf].project_terminal

if not is_project_term then

return

end

-- Get the command that was run from buffer variable

local cmd = vim.b[buf].project_terminal_cmd

if not cmd or cmd == "" then

return

end

-- Store the command in a global module table

require("project_terminal.state").set_last_cmd(cmd)

end,

})

Explanation:

The TermClose autocommand triggers when any terminal buffer exits.
The callback receives args with a buffer id.
The plugin sets buffer local variables when it creates a terminal. The autocommand checks those variables to confirm that the terminal belongs to the plugin.
If it does, the code reads the command and stores it in a state module.

This pattern avoids touching terminals that are not created by the plugin.

Marking terminal buffers as plugin owned

To make the TermClose logic work, you set buffer variables when opening the terminal.

Inside project_terminal.init.open:

-- After creating the terminal

local term_buf = vim.api.nvim_get_current_buf()

-- Mark this buffer as created by project_terminal

vim.b[term_buf].project_terminal = true

vim.b[term_buf].project_terminal_cmd = run_cmd

Explanation:

vim.b[buf] writes buffer local variables.
project_terminal is a flag that the autocommand can check.
project_terminal_cmd stores the command string for later use.

This keeps the link between autocommands and plugin behavior explicit.

Optional events for directory changes

If your plugin depends on project roots, you might want to update cached state when the working directory changes. You can listen to DirChanged.

Example:

vim.api.nvim_create_autocmd("DirChanged", {

group = group,

callback = function()

require("project_terminal.state").clear_for_dir(vim.loop.cwd())

end,

})

This is optional. If you use it, document it clearly so users understand when the plugin recalculates state.

Writing a focused README

The README is the first thing users see on Git hosting. It should answer four questions.

What does the plugin do
How do I install it
How do I configure it
How do I use it day to day

You can structure it with simple Markdown headings.

Minimal README outline:

Name and one sentence description.
Install section with examples for one or two plugin managers.
Setup section with a Lua snippet showing require("project_terminal").setup.
Usage section with the main command and example keymaps.
Optional section for advanced behavior, such as how TermClose tracking works.

Example usage snippet:

require("project_terminal").setup({

height = 10,

default_cmd = "pytest",

})

vim.keymap.set("n", "<leader>pt", function()

require("project_terminal").open()

end, { desc = "Project terminal" })

vim.keymap.set("n", "<leader>pp", function()

require("project_terminal").open("npm test")

end, { desc = "Project npm test" })

You should explain the snippet line by line in the README text so users can adapt it.

Neovim help file for in editor docs

In addition to the README, a proper Neovim plugin provides a help file under doc/.

Example file name:

doc/project_terminal.txt

At the top, add a help tag:

project_terminal.txt:

*project_terminal.txt*  Project terminal plugin

Inside, include sections similar to the README:

Overview with a short description.
Installation instructions.
Configuration reference listing options with explanations.
Command reference describing :ProjectTerminal.
Lua API reference describing setup and open.

Example option description:

height                        *project_terminal-height*

Number. Default: 12

Height of the bottom split when opening a project terminal.

Example command description:

:ProjectTerminal [cmd]        *:ProjectTerminal*

Open a bottom terminal in the detected project root.

If [cmd] is given, it is executed in the terminal.

If [cmd] is omitted, the plugin uses `default_cmd` or the shell.

You then generate help tags using :helptags doc from inside Neovim, so users can run :h project_terminal to read this file.

Connecting behavior and docs

Autocommands and events make your plugin feel alive. Documentation and README tell users what to expect. When both parts match, users understand why a terminal closes, why a command is remembered, and when project roots are recalculated. That alignment turns a small plugin into a dependable tool that fits smoothly into daily editing without surprise behavior.

8.5 Tagging versions, hosting on Git, and iterating based on feedback

Once a plugin works on your machine, the next step is to treat it as a real project. That means using Git for history, tags for stable versions, and a public host for users and contributors. Good version habits help users trust the plugin and help you ship improvements without breaking setups.

This section walks through a simple versioning approach, a basic Git hosting workflow, and a practical feedback loop.

Semantic versions and why they matter

Neovim plugin managers often support pinning a plugin to a tag. Users rely on this to avoid surprise changes. A clear version scheme sets expectations.

A practical rule set:

Use semantic versions in the form MAJOR.MINOR.PATCH.

Start at v0.1.0 for an initial public release.

Increment PATCH for bug fixes that do not change behavior.

Increment MINOR for new features that keep existing APIs working.

Increment MAJOR for breaking changes, such as renaming functions or changing config keys.

Example version sequence for the project terminal plugin:

v0.1.0 initial release with setup, open, and :ProjectTerminal.
v0.1.1 fix root detection bug in complex directories.
v0.2.0 add floating info view without breaking the API.
v1.0.0 first stable API, ready for wider use.

This pattern helps users decide when to upgrade and gives you a language for release notes.

Tagging versions with Git

Assume the plugin lives in a Git repository and main is stable.

First commit everything:

git status              # check for uncommitted changes

git add .

git commit -m "Initial plugin version"

Then create an annotated tag for the first release:

git tag -a v0.1.0 -m "v0.1.0 initial release"

Explanation:

git tag -a creates an annotated tag, which stores a message and metadata.
The tag name v0.1.0 follows semantic versioning.
The message gives a short summary.

Push code and tags to the remote:

git push origin main

git push origin v0.1.0

Some hosts treat tags as release candidates. Plugin managers will be able to pin to this tag.

Hosting the plugin on a Git platform

A public Git host such as GitHub or GitLab gives users a stable URL for the plugin. The essential steps are the same across platforms.

Create a new repository, for example project-terminal.nvim.

Add a short description, such as “Project-aware terminal split for Neovim.”

Set the license, for example MIT.

Push the local repository as shown above.

In the README, show a minimal installation example for a Lua plugin manager.

Example using lazy.nvim style syntax:

{

"yourname/project-terminal.nvim",

config = function()

require("project_terminal").setup({

height = 12,

})

end,

}

This snippet matters because it gives users a copy-paste starting point. Combined with tags, plugin managers are able to lock the plugin to a version.

Changelog and release notes

A simple changelog helps users see what changed between versions. You do not need a complex format. A plain Markdown file with sections per version is enough.

Example CHANGELOG.md structure:

# Changelog

## v0.2.0

- Add floating info view with project root and command.

## v0.1.1

- Fix root detection when working in nested git repositories.

## v0.1.0

- Initial release with :ProjectTerminal command and setup API.

Each section lists changes as short lines. When preparing a new version:

Update the changelog.

Commit the changes.

Tag the release with git tag -a.

Push code and tags.

On hosts that support releases, you can copy the changelog entry into the release description.

Responding to feedback and issues

Once the plugin is public, users will open issues and propose changes. Good feedback habits keep the project healthy.

Simple practices:

Create labels such as bug, enhancement, question.
Add a short issue template that asks for Neovim version, plugin version, and minimal config.
Respond with clear steps: request reproduction, suggest a workaround, and record decisions in the issue thread.

When a bug report leads to a fix:

Add a test or reproduction snippet in a local scratch file.

Fix the code.

Mention the issue number in the commit message, for example “Fix root detection in nested repos (#12).”

Add the change to CHANGELOG.md.

Bump PATCH or MINOR version depending on the scope.

Tag and push the new version.

This pattern ties each user visible change to an issue and a version tag.

Iterating without breaking users

As ideas grow, you will find new features for the plugin. The key is to extend the public API without surprising existing configurations.

Good rules:

Avoid renaming setup options without a deprecation period.
If you need to change an option, support both the old and new name for one MINOR release, and print a warning.
Keep new functions optional, not required, for basic usage.
If you must break an API, plan a MAJOR version bump and document the change clearly in both README and changelog.

Example deprecation pattern in Lua:

if opts.height_lines and not opts.height then

vim.notify("[project-terminal] `height_lines` is deprecated, use `height` instead")

opts.height = opts.height_lines

end

This keeps older configs working while guiding users forward.

By tagging versions, hosting the code on a public Git platform, and iterating in response to feedback with a stable API, you move from a personal script to a small, reliable Neovim plugin that others are able to install, debug, and trust over time.

Chapter 9: Team Ready Neovim Configuration Template

9.1 Goals, constraints, and core module set for a shared config

A team ready Neovim configuration acts as a template for multiple developers. It gives a stable base while leaving room for personal tweaks. The aim is not a perfect setup for one person. The aim is a predictable setup that works for many people across projects.

This section defines goals and constraints for a shared config and then outlines a core module set that keeps the structure clear and maintainable.

Goals for a team shared configuration

A shared setup should hit a few concrete goals.

First, it should support the main languages and tools in the team stack. That usually means LSP support, Treesitter, completion, formatting, and basic test workflows.

Second, it should be stable. Updates to plugins or Neovim should not break the entire team’s editor on a random morning. The config should handle plugin pinning and version changes in a controlled way.

Third, it should be understandable. New team members should open the config and see a simple structure. Each module should have a clear purpose, such as “LSP,” “UI,” or “Git.”

Fourth, it should allow personal overrides without forking the main repo. Each developer should extend or tweak behavior in a private file without touching team defaults.

Constraints for shared configs

A team config faces real world constraints.

Developers run Neovim on different platforms and terminals. The config must avoid assumptions that only hold on one system, such as hard coded paths.

The plugin set must stay lean enough for fast startup. Heavy plugin stacks slow down weaker machines and increase maintenance effort.

Keymaps need to avoid conflicts with common tools such as tmux, shells, and OS shortcuts. Strange or highly opinionated mappings limit adoption.

Finally, the configuration should degrade gracefully. If a plugin fails to load, basic editing should still work.

Directory layout for a shared config

A clear project structure helps teams reason about the configuration.

Example layout inside a Git repository:

team-nvim
init.lua
lua/
team/
core/
options.lua
keymaps.lua
autocmds.lua
plugins/
init.lua
lsp.lua
treesitter.lua
completion.lua
git.lua
ui.lua
user/
local.lua

Roles:

init.lua wires everything together.
lua/team/core holds core editor behavior that does not depend on plugins.
lua/team/plugins defines the plugin list and plugin specific setups.
lua/team/user/local.lua is a personal override file that stays ignored in Git.

This layout gives a single entry point and a place for each concern.

Core module set and responsibilities

The shared config revolves around a small set of core modules.

Core options

Options set editor behavior that should match across the team.

lua/team/core/options.lua:

local o = vim.opt

-- General editing behavior

o.number = true                  -- show absolute line numbers

o.relativenumber = true          -- relative numbers for motions

o.expandtab = true               -- use spaces instead of tabs

o.shiftwidth = 2                 -- indentation width

o.tabstop = 2                    -- tab width

o.termguicolors = true           -- enable true color

-- Search behavior

o.ignorecase = true              -- search ignores case

o.smartcase = true               -- use case when pattern has capitals

o.incsearch = true               -- show matches as you type

Explanation:

The module sets defaults for line numbers, tabs, indentation, and colors. These settings shape how code looks and how motions behave. Search options define how project wide searches feel across languages.

Keymaps

Keymaps define shared muscle memory.

lua/team/core/keymaps.lua:

local map = vim.keymap.set

-- Leader key for team mappings

vim.g.mapleader = " "

-- Save and quit

map("n", "<leader>w", "<cmd>write<CR>", { desc = "Save file" })

map("n", "<leader>q", "<cmd>quit<CR>", { desc = "Quit window" })

-- Split navigation

map("n", "<C-h>", "<C-w>h", { desc = "Left window" })

map("n", "<C-j>", "<C-w>j", { desc = "Down window" })

map("n", "<C-k>", "<C-w>k", { desc = "Up window" })

map("n", "<C-l>", "<C-w>l", { desc = "Right window" })

Explanation:

A space leader keeps mappings consistent across the team. Shared save and quit mappings avoid exotic patterns. Split navigation with Ctrl h, j, k, l matches tmux layouts described earlier, which reduces confusion.

Plugin bootstrap

A single module declares the plugin list and calls plugin setups.

lua/team/plugins/init.lua:

return {

-- Plugin manager itself goes here, depending on choice

-- LSP and tools

{ "neovim/nvim-lspconfig" },

{ "williamboman/mason.nvim" },

{ "williamboman/mason-lspconfig.nvim" },

-- Treesitter

{ "nvim-treesitter/nvim-treesitter", build = ":TSUpdate" },

-- Completion

{ "hrsh7th/nvim-cmp" },

{ "hrsh7th/cmp-nvim-lsp" },

-- Git integration

{ "lewis6991/gitsigns.nvim" },

-- UI

{ "nvim-lualine/lualine.nvim" },

{ "nvim-tree/nvim-web-devicons" },

}

Explanation:

This list focuses on essentials. LSP modules handle language servers. Treesitter handles parsing. Completion improves insert mode. Gitsigns handles inline Git info. Lualine and devicons handle UI polish. Additional plugin modules, such as lsp.lua or treesitter.lua, load later to apply detailed configuration.

User overrides

The user local module holds personal tweaks that stay outside version control.

lua/team/user/local.lua:

-- This file is optional. Users place personal overrides here.

-- Example personal mapping

-- vim.keymap.set("n", "<leader>e", "<cmd>Explore<CR>", { desc = "File explorer" })

init.lua loads this module in a protected call.

pcall(require, "team.user.local")

If the file does not exist, nothing breaks. Each team member tailors their own behavior without changing shared modules.

Wiring modules in init.lua

The main init.lua ties the structure together.

require("team.core.options")

require("team.core.keymaps")

-- Plugin manager bootstrap not shown here

require("team.plugins")          -- load plugin specs and setups

-- Optional user overrides

pcall(require, "team.user.local")

This file describes the config in one glance. Options, keymaps, plugins, and user overrides appear in a simple, stable order.

With these goals, constraints, and core modules in place, the shared configuration acts as a dependable template for your team. Each developer receives a clear structure, a minimal yet complete plugin set, and room for personal extensions, while the project remains easy to reason about and maintain over time.

9.2 Structure for per user and per project overrides

A team Neovim config must serve two different needs. The team wants a shared, stable base. Each developer wants local tweaks that do not affect others. Projects also need their own behavior, such as different test commands or formatter rules. You solve this by defining a clear override structure that layers configuration in a predictable order.

This section shows how to design those layers and how to wire them into the shared config without adding complexity.

Goals for overrides

The override system should satisfy three requirements.

It must never break the shared base if a local file is missing or invalid.
It must keep team modules clean. No personal changes inside team tracked files.
It must give each project a way to declare its own behavior without touching the global config.

You achieve this by adding two override levels.

Per user overrides, stored under lua/team/user.
Per project overrides, stored in the project tree and loaded when you open files in that project.

Per user overrides with a local module

You already have a shared layout under lua/team. You add a user directory inside that namespace.

team-nvim
lua/
team/
core/
plugins/
user/
local.lua (ignored in Git)

In version control, you include the user directory but not the file.

In .gitignore:

lua/team/user/local.lua

This tells Git to ignore each developer’s local.lua file. Everyone can create it, but it never shows up in commits.

The main init.lua loads this file in a protected way.

require("team.core.options")

require("team.core.keymaps")

require("team.plugins")

-- Optional per user overrides

pcall(require, "team.user.local")

Explanation:

pcall wraps require, catching any error.
If local.lua does not exist, require fails, but pcall prevents a crash.
If local.lua has a typo, it fails without breaking the shared config.

Inside local.lua, developers define personal mappings or small tweaks.

Example user local.lua:

-- Example: personal Telescope mapping

vim.keymap.set("n", "<leader>e", "<cmd>Telescope find_files<CR>", {

desc = "Find file (personal)",

})

-- Example: personal theme

vim.cmd("colorscheme tokyonight-night")

This matters because each developer can adjust UI or keymaps without changing team modules. The team can still reason about the shared behavior by looking only at core and plugin modules.

Per project overrides with project files

Some behavior should attach to a project rather than a person. Common examples:

Different test commands per repo.
Special formatting rules for a legacy codebase.
Extra LSP or linting tools only used in one service.

You keep these overrides inside the project repository, not inside the team Neovim config. The Neovim config only defines a convention and a loader.

Define a simple project override file name, for example .nvim.lua at the project root.

In the team config, create a root helper, or reuse one from earlier chapters.

Example lua/team/core/root.lua:

local M = {}

local markers = {

".git",

"package.json",

"pyproject.toml",

"go.mod",

"Cargo.toml",

}

local function search_up(start)

local path = vim.fn.fnamemodify(start, ":p")

while path and path ~= "/" do

for _, marker in ipairs(markers) do

local candidate = path .. "/" .. marker

if vim.loop.fs_stat(candidate) then

return path

end

end

path = vim.fn.fnamemodify(path, ":h")

end

return vim.loop.cwd()

end

function M.get()

local buf = vim.api.nvim_buf_get_name(0)

if buf == "" then

return vim.loop.cwd()

end

return search_up(buf)

end

return M

This helper finds the nearest project root based on common markers.

Now you define a loader that looks for a .nvim.lua inside that root.

local root = require("team.core.root")

local function load_project_config()

local rd = root.get()

local project_file = rd .. "/.nvim.lua"

if vim.loop.fs_stat(project_file) then

-- Execute the project specific config file

dofile(project_file)

end

end

vim.api.nvim_create_autocmd("BufEnter", {

group = vim.api.nvim_create_augroup("TeamProjectConfig", { clear = true }),

callback = load_project_config,

})

Explanation:

On BufEnter, Neovim computes the project root for the current buffer.
It builds the path to .nvim.lua.
If the file exists, dofile executes it.
If not, nothing happens.

Because dofile runs Lua inside Neovim, you can write normal Neovim configuration in .nvim.lua.

Example project .nvim.lua for a Python service:

-- Project specific test mapping

vim.keymap.set("n", "<leader>tt", function()

vim.cmd("!pytest")          -- run tests from project root

end, { desc = "Project tests" })

-- Project specific formatting

vim.cmd("let g:black_linelength = 100")

Example project .nvim.lua for a Node backend:

-- Use npm test as the default test command

vim.keymap.set("n", "<leader>tt", function()

vim.cmd("!npm test")

end, { desc = "Project tests" })

These overrides live with the project code. They can be versioned and shared across the team. The team Neovim config does not need to know about every project’s specific commands.

Order of application and safety

The final layering order should be:

Team core options and keymaps.
Team plugin configuration.
Per project .nvim.lua loaded on buffer enter.
Per user local.lua loaded from init.lua.

This order matters.

Team modules set a common base.
Project overrides adjust behavior for that repository.
User overrides apply last so developers can adapt even project conventions if needed.

Because each layer uses pcall or existence checks, missing files do not break startup. The config remains robust, and the structure stays clear.

With this structure in place, the team gains both a stable shared editor and flexible override points for individuals and projects, without cluttering the main configuration or risking accidental changes to team wide behavior.

9.3 Standard keymaps, LSP settings, and Git workflows for the team

A team Neovim config works best when everyone shares the same core keymaps, language tooling, and Git habits. Personal preferences still live in overrides, but the main workflow stays consistent. This section defines a standard set of mappings, LSP behavior, and Git tools that support readable code and steady progress.

Standard keymaps for navigation and actions

Shared keymaps give the team a common language. The goal is a small set of bindings that cover daily actions: moving between windows, searching, working with files, and running project commands.

Example module lua/team/core/keymaps.lua:

local map = vim.keymap.set

-- Leader key for shared mappings

vim.g.mapleader = " "

-- Save and quit

map("n", "<leader>w", "<cmd>write<CR>", { desc = "Save file" })

map("n", "<leader>q", "<cmd>quit<CR>", { desc = "Quit window" })

-- Window navigation

map("n", "<C-h>", "<C-w>h", { desc = "Left window" })

map("n", "<C-j>", "<C-w>j", { desc = "Down window" })

map("n", "<C-k>", "<C-w>k", { desc = "Up window" })

map("n", "<C-l>", "<C-w>l", { desc = "Right window" })

-- Project search (Telescope)

map("n", "<leader>ff", "<cmd>Telescope find_files<CR>", { desc = "Find file" })

map("n", "<leader>fg", "<cmd>Telescope live_grep<CR>", { desc = "Search text" })

-- Toggle file tree (if team uses one)

map("n", "<leader>e", "<cmd>NvimTreeToggle<CR>", { desc = "File explorer" })

Explanation:

The leader is space, which avoids conflicts with tmux and terminal shortcuts.
Save and quit under <leader>w and <leader>q keep writing consistent.
Window movement with Ctrl plus direction matches tmux pane navigation habits.
<leader>ff and <leader>fg establish a standard way to open files and search text.
<leader>e toggles a shared file explorer, if the team uses one.

These keymaps form a baseline that works across languages and projects. Personal mappings live in per user overrides, not here.

Shared LSP settings and on attach behavior

Language servers provide hover, jump to definition, references, rename, and diagnostics. The team should share one set of LSP keymaps and capabilities so any language that uses LSP behaves in the same way.

Example lua/team/plugins/lsp.lua:

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")

-- Shared capabilities for completion

local capabilities = cmp_lsp.default_capabilities()

-- Shared on_attach callback for all servers

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs, desc)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })

end

-- Basic navigation

map("n", "gd", vim.lsp.buf.definition, "Go to definition")

map("n", "gr", vim.lsp.buf.references, "List references")

map("n", "gD", vim.lsp.buf.declaration, "Go to declaration")

-- Documentation and info

map("n", "K", vim.lsp.buf.hover, "Hover docs")

map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")

map("n", "<leader>ca", vim.lsp.buf.code_action, "Code action")

-- Diagnostics

map("n", "[d", vim.diagnostic.goto_prev, "Prev diagnostic")

map("n", "]d", vim.diagnostic.goto_next, "Next diagnostic")

end

Explanation:

capabilities integrates LSP with the completion plugin so completion behavior stays consistent.
on_attach sets buffer local mappings each time a server attaches.
gd, gr, K, <leader>rn, and <leader>ca become shared expectations across languages.
[d and ]d standardize movement through diagnostics.

Server specific configuration reuses this callback.

Examples:

-- TypeScript / JavaScript

lspconfig.tsserver.setup({

capabilities = capabilities,

on_attach = on_attach,

})

-- Python

lspconfig.pyright.setup({

capabilities = capabilities,

on_attach = on_attach,

})

-- Go

lspconfig.gopls.setup({

capabilities = capabilities,

on_attach = on_attach,

})

-- Rust

lspconfig.rust_analyzer.setup({

capabilities = capabilities,

on_attach = on_attach,

})

Any new language server the team adds should follow this pattern.

Diagnostics behavior and formatting

Diagnostics settings should support writing readable code without drowning buffers in noise.

Example shared diagnostics configuration:

vim.diagnostic.config({

virtual_text = {

spacing = 2,         -- distance from text

prefix = "●",        -- simple marker for issues

},

signs = true,          -- show signs in the gutter

underline = true,      -- underline problem text

update_in_insert = false,

})

Explanation:

Virtual text provides inline hints without clutter when tuned carefully.
Signs in the gutter show where issues exist at a glance.
update_in_insert = false avoids flickering while typing.

Formatting should pass through dedicated tools, such as null-ls or language server formatting, with consistent triggers.

Example keymap:

vim.keymap.set("n", "<leader>fm", function()

vim.lsp.buf.format({ async = true })

end, { desc = "Format buffer" })

This gives the team a single way to format code, regardless of language.

Git workflows with consistent tools

Git integration in Neovim should focus on three things: seeing changes inline, staging small hunks, and reviewing commits before push. Gitsigns and a Git plugin such as fugitive or a simple status wrapper support this.

Example lua/team/plugins/git.lua:

local gitsigns = require("gitsigns")

gitsigns.setup({

signs = {

add = { text = "+" },

change = { text = "~" },

delete = { text = "_" },

},

})

local map = vim.keymap.set

-- Navigate hunks

map("n", "]h", gitsigns.next_hunk, { desc = "Next hunk" })

map("n", "[h", gitsigns.prev_hunk, { desc = "Prev hunk" })

-- Stage and reset hunks

map("n", "<leader>hs", gitsigns.stage_hunk, { desc = "Stage hunk" })

map("n", "<leader>hr", gitsigns.reset_hunk, { desc = "Reset hunk" })

-- Preview hunk

map("n", "<leader>hp", gitsigns.preview_hunk, { desc = "Preview hunk" })

Explanation:

Signs show adds, changes, and deletes in the margin.
[h and ]h move between hunks, which fits daily review of local changes.
<leader>hs and <leader>hr encourage small, focused commits by staging or resetting hunks instead of whole files.

For status and commits, a standard mapping to Git status keeps behavior consistent.

Example with fugitive:

vim.keymap.set("n", "<leader>gs", vim.cmd.G, { desc = "Git status" })

Team members then follow the same pattern when preparing commits: use <leader>hs to stage selected hunks, <leader>gs to open status, and write a commit message inside Neovim.

With these shared keymaps, LSP settings, and Git workflows, the team gains a predictable environment. Everyone navigates, formats, and reviews code in the same way, which reduces friction in pairing, code review, and onboarding while still leaving space for personal shortcuts in local override files.

9.4 Version control, code review, and change management for the config

A team Neovim config is part of your tooling, not a private experiment. It should follow the same discipline as application code. You use version control, review changes, and roll out updates carefully. This reduces surprises, keeps editors stable, and makes debugging easier when something breaks.

This section describes a practical workflow for managing the shared config in Git, reviewing changes, and shipping updates to the team.

Repository and branch structure

Treat the team config as its own repository. For example:

team-nvim
init.lua
lua/team/...
lua/team/user/.gitignore

Use a simple branch model.

main holds the stable, deployed configuration.
feature branches hold changes until review.

Example feature branch names:

feat/lsp-updates
fix/treesitter-crash
chore/plugin-bumps

Developers clone the repo and point their Neovim runtime to it, or use it as a plugin in their personal config.

Commit discipline

Changes to the config should be small and targeted. Each commit should have one clear purpose, such as:

Add a plugin and its configuration.
Adjust LSP defaults.
Change a common keymap.
Update pinned plugin versions.

Good commit messages describe intent, not only what changed.

Examples:

Add shared Python LSP config
Change diagnostics to disable update in insert
Pin Treesitter to known good commit

This matters because when something breaks later, you can bisect the repo and understand what changed without reading large, mixed commits.

Code review for configuration changes

You should review Neovim config changes like any other code. Use pull requests or merge requests on your Git hosting platform.

A lightweight process:

Open a PR from a feature branch into main.

Include a short description of what changes and why.

List any user visible changes, such as new keymaps, new plugins, or changed behavior.

Reviewers check:

Is the change aligned with team goals and style
Does it introduce heavy or niche plugins
Does it override existing shared keymaps
Is the change minimal and easy to revert if needed

Example PR description:

Title: Add standard Rust LSP configuration
Body:

●        Add rust-analyzer setup with shared on_attach

●        Enable allFeatures in Cargo settings

●        No keymap changes

Clear descriptions reduce confusion and help future readers understand why the change was introduced.

Managing plugin versions and updates

Plugin versions affect stability. Unpinned plugins can break after an upstream update. Use your plugin manager’s mechanism for version pinning.

For example, with a spec based plugin manager, you can pin plugins by commit or tag.

Example snippet:

{
"nvim-treesitter/nvim-treesitter",
build = ":TSUpdate",
commit = "abc1234", -- known stable commit
}

When you update plugins:

Create a branch such as chore/plugin-updates.

Update versions or dependency definitions.

Test the config on at least two machines or platforms.

Note the changes in a simple CHANGELOG or RELEASES file.

Merge after review when basic workflows work as expected.

This reduces the chance that a new plugin release breaks everyone’s editor.

Simple test procedures for the config

Before merging any change, run a few simple checks.

Start Neovim with the team config and check for errors:

nvim -u /path/to/team-nvim/init.lua

Test basic workflows:

Open a project and confirm that LSP attaches.
Use keymaps such as <leader>ff, <leader>fg, and <leader>e.
Open a Git tracked file and check gitsigns behavior.

You can script a minimal check with a shell script that runs Neovim in headless mode and loads the config.

Example script scripts/check.lua:

-- Run basic Lua checks on config modules

require("team.core.options")

require("team.core.keymaps")

require("team.plugins")

Then run:

nvim --headless -u NONE -c "luafile scripts/check.lua" +q

This confirms that the modules load without syntax errors.

Communicating changes to the team

Even small configuration changes affect everyone. Document changes in a simple CHANGELOG or in the repository README under a “Recent changes” section.

Example CHANGELOG entry:

9.3.1

●        Add Rust analyzer support with shared LSP mappings

●        Change diagnostic virtual text marker to ●

When a merge lands in main, share a short message in the team chat:

Updated team Neovim config: Rust LSP added, diagnostics adjusted. Pull latest main and restart Neovim.

This keeps everyone aware of new features and avoids confusion when behavior shifts.

Handling rollbacks and breakages

Sometimes a change that looks safe causes issues on another system. A stable change management process must support fast rollback.

If a change breaks the config:

Confirm the failing commit using git log or git bisect.

Revert the commit on a new branch.

Open a PR with a message like “Revert Rust LSP change due to startup errors.”

Merge the revert and inform the team.

Because your commits are small and focused, revert operations are clean. You can then revisit the change later with better testing or guards.

With version control, review habits, and a simple update and rollback flow in place, your team Neovim config becomes a reliable shared tool. It changes at a deliberate pace, problems are traceable, and updates feel controlled rather than random, which builds trust in the shared editor environment.

9.5 Example onboarding flow for a new team member using the template

A team Neovim template only helps if a new developer reaches a working setup quickly. Onboarding should feel structured and predictable, not like reverse engineering someone else’s dotfiles. This section walks through an example flow for a new teammate from first clone to first feature task.

Step 1. Prerequisites and environment check

Before touching the config, the new member confirms base tools.

Required pieces:

●        Neovim at or above the version your team supports

●        Git

●        A terminal with true color support

●        A recent Node, Python, Go, or other runtimes, if LSP servers depend on them

A short script inside the repo helps verify this.

Example scripts/check-env.sh:

#!/usr/bin/env sh

nvim --version | head -n 1

git --version

echo "TERM: $TERM"

The script prints Neovim and Git versions plus terminal info. The team README explains expected ranges. This avoids issues later when plugins expect a newer Neovim.

Step 2. Cloning and wiring the shared config

The new member clones the shared config into a stable path.

cd ~/.config

git clone git@github.com:yourorg/team-nvim.git

There are two common wiring approaches.

First approach, use it directly as Neovim config:

mv ~/.config/nvim ~/.config/nvim.backup  # keep any old setup safe

ln -s ~/.config/team-nvim ~/.config/nvim

Neovim then loads team-nvim/init.lua by default.

Second approach, keep personal config and require the team config from there. In ~/.config/nvim/init.lua:

-- Load team template

require("team-nvim.init")

-- Personal extras for this developer

pcall(require, "local.init")

The team repo then acts like a library. This fits developers who already have tools around Neovim.

Step 3. First run and plugin bootstrap

The new member starts Neovim with the template:

nvim

The plugin manager triggers installation of the core plugin set. The README helps here with a short note on what output to expect and how to proceed if network problems appear.

After install finishes, a quick smoke check follows:

●        Open a file with :edit

●        Confirm line numbers and basic options match the team defaults

●        Confirm no errors appear in the message area

If errors appear, the new member copies them into a team channel or issue for quick help.

Step 4. Verifying standard keymaps and LSP behavior

Next, the new member learns and tests core keymaps.

From a project folder:

cd /path/to/repo

nvim

Inside Neovim:

●        Press <leader>ff to open file search

●        Press <leader>fg to search text

●        Use <C-h>, <C-j>, <C-k>, <C-l> to move between splits

For LSP:

●        Open a TypeScript, Python, Go, or Rust file from the team stack

●        Place the cursor on a symbol and press gd for definition

●        Press K for hover info

●        Press <leader>rn to try a rename on a harmless symbol in a scratch branch

These checks confirm that language servers attach correctly and that shared keymaps behave as described in chapter 9.3.

Step 5. Creating per user overrides

At this stage the new member has a working shared setup. Next comes personal behavior that does not belong in the team config.

Inside the team repo, the developer creates the local.lua override file:

lua/team/user/local.lua:

-- Personal color scheme

vim.cmd("colorscheme tokyonight-night")

-- Personal quick mapping for diagnostics list

vim.keymap.set("n", "<leader>xx", vim.diagnostic.setloclist, {

desc = "Diagnostics to loclist (personal)",

})

Git ignores this file through the existing .gitignore entry. The developer gains personal tweaks. The team repo remains clean. This reinforces the separation between shared behavior and personal preference.

Step 6. Seeing a project override in action

The next step is awareness of per project overrides. The new member checks one repository that ships a .nvim.lua at its root.

Example .nvim.lua in a backend service:

-- Project specific test mapping

vim.keymap.set("n", "<leader>tt", function()

vim.cmd("!pytest")

end, { desc = "Project tests" })

The onboarding guide explains that the shared config loads this file on BufEnter using the root detection helper. The new member opens a file inside that project and presses <leader>tt. The command runs from the project root. This teaches the layered model:

●        Team defaults

●        Project overrides

●        User overrides

Step 7. First real feature task using the template

To finish onboarding, the new member completes a small real task through the shared workflow.

Example mini task:

●        Open the main backend repo

●        Use <leader>ff to open a handler file

●        Change a log message or add a small unit test

●        Run tests with the shared test mapping (<leader>tt or <leader>tr depending on the team standard)

●        Stage the change with gitsigns using <leader>hs

●        Commit through Git status, opened with <leader>gs or a documented command

During this flow the new member uses navigation, LSP, tests, and Git integration in one loop. Any friction surfaces early while context is still simple.

A template only reaches its value when new people join and reach a productive loop quickly. With this onboarding flow in place, a new team member installs the shared config, verifies core behavior, layers in personal and project overrides, and then completes real work using the same Neovim habits as the rest of the team.

Chapter 10: Performance Hardened Neovim for Large Codebases

10.1 Goals and symptoms of slow configs in big projects

Large codebases expose every weak point in a Neovim configuration. What feels smooth in a small personal project can become slow when you open thousands of files, attach several language servers, and layer multiple plugins on top. To harden your setup, you need clear goals and a precise way to recognize slow behavior before you start changing options or removing plugins.

This subchapter defines practical performance goals, shows the most common symptoms of slow configs in big projects, and points at the main categories of configuration mistakes that cause problems.

Practical performance goals for large codebases

Your goal is not a benchmark number. Your goal is an editor that feels responsive while you work.

Reasonable targets are:

Neovim should start fast enough that restarting is not painful. On a normal laptop, this usually means around one to two seconds with your full config loaded.

Opening files in a large monorepo should feel immediate. Fuzzy finding, :edit, and jumping to definitions should not pause for a second every time you open a new file.

Typing in insert mode should stay smooth. Pressing keys must not feel delayed because LSP, Treesitter, or diagnostics are doing work on every keystroke.

Scrolling and searching should feel continuous. Moving with j, k, Ctrl d, or using /search and Telescope live_grep should not stutter, even in large files.

Language tools should feel supportive instead of blocking. Hover, references, rename, and diagnostics should respond in a short and predictable time frame.

If your setup meets these targets, it is “fast enough” for most real production codebases.

Typical symptoms of a slow Neovim configuration

Performance issues often follow a pattern. Learning to spot these patterns will help you find the right area to adjust.

Slow startup shows up as a visible delay between running nvim and seeing the first buffer. If startup gets slower as you add plugins or more language servers, eager plugin loading or heavy init logic is likely to blame.

Slow file open and buffer switching appears when fuzzy find, :edit, or jumping to a file from LSP pauses for a noticeable time, especially on first open. If switching back to a previously opened buffer also lags, you may have expensive autocommands on BufEnter.

Typing lag appears when insert mode feels sticky. You press a key and characters appear with a small but persistent delay. This often points to synchronous diagnostics, Treesitter updates on every change, or too many events firing while you type.

Scrolling and search stalls show up as j, k, or Ctrl d feeling uneven, or Telescope live_grep feeling slow when scanning large trees. Heavy virtual text, many signs, or unfiltered search paths often cause this.

Slow LSP features show up as hover documentation appearing late, rename freezing the buffer briefly, or diagnostics arriving all at once with a visible pause. These can be caused by misconfigured servers, overlapping servers for one filetype, or too many high cost LSP features enabled by default.

Common configuration patterns that cause slowdowns

Slow behavior usually comes from a group of small decisions rather than a single dramatic mistake. A few patterns appear repeatedly in large setups.

Eager plugin loading is a common root cause. If every plugin is required and configured at startup with no lazy loading, you pay the full cost even when editing simple files. Plugin managers that support lazy loading only help if you use that feature.

Heavy autocommands are another frequent problem. If you attach expensive logic to wide events such as BufEnter, BufRead, or TextChanged without filtering by filetype or size, Neovim may run large chunks of Lua code for every buffer in a big project.

Example of a problematic pattern:

vim.api.nvim_create_autocmd("BufEnter", {

callback = function()

-- Expensive work done for every buffer, every time

vim.cmd("TSBufEnable highlight")

vim.cmd("LspStart")

end,

})

This autocommand enables Treesitter and starts LSP for every buffer on every entry. In a large repo with many open buffers, this multiplies work and leads to lag.

Over aggressive Treesitter usage can also hurt. Treesitter highlighting and folding are nice, but running them on huge generated files or logs wastes CPU. Enabling Treesitter globally without checks is a common mistake.

Too many active LSP features create load as well. Semantic tokens, inlay hints, code lenses, and similar features all cost processing time. Enabling everything for every server and every project often exceeds what is needed in daily work.

Excess virtual text and signs, for diagnostics and Git plugins, can cause redraw overhead. Each movement or change triggers recalculation of signs and virtual text, which can be noticeable in large files.

Finally, lack of ignore patterns in search and file tools is a silent source of slowdown. If Telescope and ripgrep scan node_modules, build directories, and huge vendored trees, every search operation becomes slower than necessary.

Why defining goals and symptoms comes first

Without clear goals and a vocabulary for symptoms, performance tuning turns into random trial and error. You might disable helpful features or remove useful plugins without addressing the real bottlenecks. By defining what “fast enough” means for you, and learning to recognize startup lag, buffer lag, typing lag, and LSP lag as distinct signals, you create a solid base. The next subchapters then build on this base with concrete strategies that keep Neovim responsive even when the codebase around it grows large and complex.

10.2 Startup profiling with startuptime and plugin profiling tools

Before you optimize a Neovim config, you need proof of where time goes during startup. Guessing leads to wrong changes. Neovim gives you a built in timing tool, :startuptime, and most plugin managers also provide profiling. Together they show which files, plugins, and Lua chunks slow you down.

This subchapter shows how to run startup profiling, read its output, and connect that information to real decisions about your plugin set and configuration order.

Using :startuptime to profile Neovim itself

:startuptime measures how long Neovim needs to start and exit. It writes a timing report to a file. You run it from the shell, not from inside Neovim.

Basic usage:

nvim --startuptime startup.log

This command starts Neovim once, records timing into startup.log, and then opens Neovim. After you quit, you inspect the log.

A single run includes noise from disk and cache. You get a better average by running multiple iterations with the -n flag.

nvim --startuptime startup.log -n 10

Explanation:

--startuptime startup.log tells Neovim to write timing info into startup.log.
-n 10 instructs Neovim to measure 10 startup cycles in a row.

The log file contains a list of timestamps for each event and script. The most important columns are:

time cumulative time since start.
event describes what Neovim is doing.
fname shows which file was loaded.

To inspect the log inside Neovim:

nvim startup.log

You then search for large time jumps. A typical pattern is a large increase around sourcing lines that load Lua or Vimscript files under your config or plugin paths. These lines tell you which scripts are slow.

For example, you might see:

012.345  001.200  001.200  sourcing /home/user/.config/nvim/lua/plugins/init.lua

020.678  008.333  008.333  sourcing /home/user/.local/share/nvim/lazy/telescope.nvim/plugin/telescope.lua

The first number is cumulative time. The second is time spent on this file. The third is self time. Large values show where startup spends most time. In this example, your plugin loader and Telescope setup are significant.

Comparing startup with and without your config

To understand how much your config adds, compare Neovim with no config and with your full setup.

No config:

nvim -u NONE --startuptime startup-none.log -n 10

Full config:

nvim --startuptime startup-full.log -n 10

Then open both logs in Neovim and compare total startup times and the heaviest entries. The difference shows how much cost comes from your configuration.

If Neovim alone starts in 100 ms, but your full config takes 1500 ms, you know the config is the main source of delay. The log lines under startup-full.log show which scripts and plugins matter most.

Profiling plugins with a plugin manager

Most modern plugin managers offer their own profiling features. These tools measure load times per plugin and often present the data in a more structured way than :startuptime.

For example, a manager might allow a profiling flag in its setup.

Example using a generic pattern:

require("plugin_manager").setup({

performance = {

rtp = {

disabled_plugins = {

"gzip",

"zipPlugin",

},

},

profiling = {

enabled = true,           -- turn on plugin load profiling

threshold = 5,            -- show plugins slower than 5 ms

},

},

})

Explanation:

profiling.enabled tells the manager to measure how long each plugin takes to load.
threshold filters out very small entries. Only plugins that take longer than the threshold appear in the report.
Disabling built in plugins that you do not need can also shave off time.

After startup, the manager often provides a command or view to inspect the results. The exact name depends on the manager, but the core idea stays the same. You get per plugin timing.

You then identify the slowest plugins. Heavy fuzzy finders, UI layers, and LSP helpers often show up at the top. With this information, you decide which plugins to lazy load, which to drop, and which to defer until specific events.

Connecting profiling results to configuration changes

Once you know which parts are slow, you plan changes.

If init.lua or a core module is heavy, you:

Separate configuration into smaller modules.
Avoid running expensive logic at top level.
Move nonessential setup into event based callbacks.

If plugin profiling shows a single plugin dominating startup, you:

Lazy load it by filetype, command, or keymap.
Check if you really need it for all workflows.
Replace it with a lighter alternative if possible.

If several plugins together take too long, you:

Review their necessity in the context of large projects.
Disable overlapping functionality.
Pin versions if regressions appear after updates.

Profiling does not tell you what to remove. It tells you exactly where time is spent. Combined with your knowledge of which features you use daily, it lets you make targeted decisions instead of vague “optimization.”

With :startuptime and plugin profiling in place, you move from guessing about performance to working with concrete numbers. That gives you a solid foundation for the tuning strategies that follow in later sections of this chapter, where you reshape your config to stay responsive even as your codebases grow.

10.3 Identifyig andn refactoring slow modules, autocommands, and plugins

A large codebase stresses every part of your Neovim setup. Startup profiling shows where time goes, but you still need to translate those numbers into changes in real files. This subchapter explains how to find slow modules, noisy autocommands, and heavy plugins, then reshape them into lighter, event driven pieces that behave well in big projects.

Identifying heavy modules

Start with the modules that appear near the top of your startup logs. These often include:

init.lua or main entry files
plugin loader modules
large setup files such as lsp.lua, ui.lua, or plugins.lua

Open each slow file and look for work that runs as soon as the module loads. Anything at the top level runs during startup.

Typical patterns that hurt performance:

Direct require chains that pull in many secondary modules
Setup functions called immediately for every language and tool
Loops that build big tables or scan directories at load time

Example of a heavy pattern:

local langs = require("myconfig.langs")

local tools = require("myconfig.tools")

langs.setup_all()        -- configure every language at startup

tools.setup_all()        -- configure every tool at startup

This runs full setup for all languages and tools before any buffer opens. With many servers and tools, this grows slow in large environments.

A better approach splits setup into a cheap base and per language hooks.

local M = {}

function M.setup()

-- Cheap defaults only

require("myconfig.langs").setup_basic()

require("myconfig.tools").setup_basic()

end

return M

Language specific setup then hooks into FileType events, described later. This reduces the work that runs at startup.

Review every slow module with this lens. Anything not required for the first buffer belongs behind a function or event.

Taming slow autocommands

Autocommands often hide performance problems because they run later, not at startup. In large projects, expensive work on BufEnter or BufRead events becomes very visible.

Example of an unsafe pattern:

vim.api.nvim_create_autocmd("BufEnter", {

callback = function()

-- Runs for every buffer

vim.cmd("TSBufEnable highlight")

vim.cmd("LspStart")

require("myconfig.status").refresh()

end,

})

This code triggers on every buffer, regardless of filetype or size. In a monorepo with many open buffers, that pattern produces frequent pauses.

Refactor in three steps:

Limit scope by filetype
Skip very large files
Move heavy work into dedicated functions

Example refactor:

local function is_large(bufnr)

local limit = 200000          -- bytes

local ok, stat = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(bufnr))

if not ok or not stat then

return false

end

return stat.size > limit

end

vim.api.nvim_create_autocmd("FileType", {

pattern = { "lua", "python", "typescript", "go", "rust" },

callback = function(args)

local bufnr = args.buf

if is_large(bufnr) then

return                  -- skip heavy features on large files

end

-- Safe Treesitter start

pcall(vim.treesitter.start, bufnr)

-- LSP attaches via lspconfig, no need for LspStart

require("myconfig.status").on_attach(bufnr)

end,

})

Now the autocommand fires only for relevant filetypes. Large files skip heavy features. Status logic runs through a helper that you can adapt later. This structure reduces work dramatically in large trees.

If you suspect an autocommand, add temporary logging:

vim.api.nvim_create_autocmd("BufEnter", {

callback = function(args)

vim.notify("BufEnter " .. vim.api.nvim_buf_get_name(args.buf))

end,

})

Use this only for debugging. It helps you see which events fire too often.

Managing heavy plugins with lazy loading

Plugin profiling shows which plugins take the most time to load. Heavy tools are not always a problem, as long as they load only when needed.

Eager loading example:

{ "nvim-telescope/telescope.nvim" }

Here Telescope initializes every time Neovim starts, even when you only edit a single file.

Lazy loading example:

{

"nvim-telescope/telescope.nvim",

cmd = { "Telescope" },    -- load when :Telescope runs

keys = {

{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find files" },

{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Search text" },

},

}

Now Telescope loads when the user presses a mapped key or runs the command. Startup gets lighter, while the feature remains one keypress away.

Apply the same pattern to:

Git user interfaces
Debug adapters
Project dashboards
Large UI frameworks

Anything that you do not use in every session belongs behind a command, key, or event.

Breaking up monolithic language setup

Many configs place all LSP and tooling setup in a single large module. That works in small projects but feels slow in big ones.

Instead of:

require("myconfig.lsp").setup_all()

Use per language modules with FileType events.

require("myconfig.lsp").setup_basic()

vim.api.nvim_create_autocmd("FileType", {

pattern = { "typescript", "javascript" },

callback = function(args)

require("myconfig.lsp.ts").on_attach(args.buf)

end,

})

vim.api.nvim_create_autocmd("FileType", {

pattern = "python",

callback = function(args)

require("myconfig.lsp.py").on_attach(args.buf)

end,

})

This design loads language specific code only when you open related files. Large projects that only use a subset of languages on a given day pay only for what they touch.

Measuring after refactors

After each change, repeat startup and plugin profiling. Compare total timing and the heaviest lines. If Neovim starts faster and file opens feel smoother, the refactor helped. If results do not improve, inspect the next slowest module and repeat.

Through this loop of measuring, identifying, and refactoring, your configuration shifts from a single heavy block into a set of small, focused modules that behave well under the load of real, large codebases.

10.4 Aggressive lazy loading, feature toggles, and tuned LSP/completion

Once you know where time goes and which modules are heavy, the next step is to stop loading work you do not need. Large codebases reward configurations that load late, turn off nonessential features, and keep LSP and completion focused. This subchapter shows how to push lazy loading further, how to add feature toggles, and how to tune LSP and completion so they stay fast under load.

Aggressive lazy loading strategy

Lazy loading means a plugin initializes only when something triggers it, such as a command, keymap, or filetype. Aggressive lazy loading means you apply this idea to almost everything that is not core editing.

You treat plugins in three groups.

Always on core, such as Treesitter, LSP base, and your statusline.
Frequently used tools, such as Telescope and Git signs, which load on first use.
Rare tools, such as debug adapters or specialized viewers, which load only when explicitly requested.

Example plugin spec for a search tool:

{

"nvim-telescope/telescope.nvim",

cmd = { "Telescope" },  -- load when :Telescope is run

keys = {

{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find files" },

{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Search text" },

},

}

Explanation:

The plugin manager defers loading until a user runs :Telescope or presses one of the mapped keys.
Until then, Telescope adds nothing to startup time.
In large repos, this keeps the editor responsive when you are only editing a small set of files.

You apply the same pattern to debug plugins, project dashboards, test runners, and heavy UI components. Only core language tools and minimal UI should be always on.

Feature toggles for heavy behavior

Some features are helpful but expensive. Instead of hard enabling them, expose simple toggles so you can disable them in large projects or on slower machines.

You can define a small feature flag module.

local M = {

enable_virtual_text = true,

enable_inlay_hints = false,

enable_semantic_tokens = false,

}

return M

Then use these flags when configuring diagnostics and LSP.

local flags = require("team.flags")

vim.diagnostic.config({

virtual_text = flags.enable_virtual_text and {

spacing = 2,

prefix = "●",

} or false,          -- disable entirely when flag is false

})

For inlay hints:

local flags = require("team.flags")

local function maybe_enable_inlay_hints(bufnr)

if not flags.enable_inlay_hints then

return

end

if vim.lsp.buf.inlay_hint then

vim.lsp.buf.inlay_hint(bufnr, true)

end

end

Explanation:

The flags module centralizes feature toggles.
You can change defaults per machine using per user overrides.
Setting a flag to false disables the feature across all servers and filetypes without editing each plugin setup.

This approach matters in large codebases where inlay hints, semantic tokens, or heavy virtual text can overload visuals and CPU.

Tuned LSP attachment and capabilities

For performance, you treat LSP as a shared service with guardrails, not a set of independent servers each doing everything.

First, you restrict which servers attach to which filetypes. Second, you constrain capabilities so servers do not send more data than you use.

Example shared LSP setup:

local lspconfig = require("lspconfig")

local cmp_lsp = require("cmp_nvim_lsp")

local flags = require("team.flags")

local capabilities = cmp_lsp.default_capabilities()

local function on_attach(client, bufnr)

local map = function(mode, lhs, rhs, desc)

vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })

end

map("n", "gd", vim.lsp.buf.definition, "Go to definition")

map("n", "gr", vim.lsp.buf.references, "List references")

map("n", "K", vim.lsp.buf.hover, "Hover docs")

map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")

if not flags.enable_semantic_tokens and client.server_capabilities.semanticTokensProvider then

client.server_capabilities.semanticTokensProvider = nil

end

end

Explanation:

capabilities integrate with completion but can be trimmed later if needed.
on_attach sets standard keymaps and then disables semantic tokens if the flag is off.
This keeps semantic data from flooding Neovim in large files when not required.

Server setup stays simple but controlled:

lspconfig.tsserver.setup({

capabilities = capabilities,

on_attach = on_attach,

})

lspconfig.pyright.setup({

capabilities = capabilities,

on_attach = on_attach,

})

You avoid multiple servers for the same filetype and keep advanced features behind flags.

Completion performance tuning

Completion must feel instant. Heavy configuration here hurts typing speed directly.

Key ideas:

Use a small set of sources that matter every day.
Limit how much text completion inspects.
Avoid triggering on every single keystroke in large files.

Example nvim-cmp setup:

local cmp = require("cmp")

cmp.setup({

completion = {

keyword_length = 2,        -- start completion after 2 characters

},

performance = {

debounce = 60,             -- wait a bit before recomputing

throttle = 60,             -- limit update frequency

},

sources = {

{ name = "nvim_lsp" },

{ name = "buffer", keyword_length = 3 },

{ name = "path" },

},

})

Explanation:

keyword_length keeps completion from firing on trivial prefixes, which reduces noise and work.
debounce and throttle reduce repeated work when you type quickly.
Limiting sources to LSP, buffer, and path covers most use cases without loading many niche sources.

For large projects, this tuned completion feels responsive while still providing relevant suggestions.

Aggressive lazy loading, feature toggles, and tuned LSP and completion form the backbone of a performance hardened setup. They ensure you load only what you need, keep expensive features under control, and let language tools support your work instead of slowing it down, even when the codebase around you grows to millions of lines.

10.5 Example work session in a huge monorepo with the optimized setup

A large monorepo places steady pressure on Neovim. Thousands of directories, multiple languages, many LSP servers, and a constant need to search, test, and refactor. The tuned configuration from this chapter aims to keep the editor responsive throughout a full work session. This section walks through a realistic day working inside such a repository and shows how the optimized setup behaves.

Starting Neovim in the monorepo

You open a terminal at the repository root.

cd ~/work/company-monorepo
nvim

Startup finishes quickly because aggressive lazy loading limits work to core modules. Only lightweight components run: options, keymaps, and the plugin manager’s registration phase. No heavy plugin initialization happens until you request it.

A minimal init entry keeps startup small.

local core = require("team.core")

core.setup()                       -- basic options and keymaps only

require("team.plugins").setup()    -- register plugin specs, no heavy work

The initial buffer opens without delay. Treesitter, Telescope, LSP helpers, and UI layers stay idle until triggered.

Navigating a deep folder structure

Your first task is in a backend service buried deep in the repository. Instead of walking directories, you press the shared file finder key:

<leader>ff

This is the first time you invoke Telescope. The plugin manager loads it on demand. The plugin spec ensures it never loads during startup.

{

"nvim-telescope/telescope.nvim",

cmd = { "Telescope" },

keys = {

{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find file" },

{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Search text" },

},

}

Ignore patterns for build folders, vendor directories, and huge generated paths keep searching fast even in very large trees.

The file picker reacts immediately. You jump to the service code with no noticeable delay.

Opening files and LSP attachment

Inside the selected file, the FileType event fires. Language specific setup loads only when needed.

vim.api.nvim_create_autocmd("FileType", {

pattern = { "typescript", "go", "rust", "python" },

callback = function(args)

local bufnr = args.buf

if require("team.large").is_large(bufnr) then

return

end

pcall(vim.treesitter.start, bufnr)

end,

})

This avoids enabling Treesitter on giant files. Only supported languages with safe sizes receive advanced parsing.

The LSP server attaches through a shared on_attach callback.

local function on_attach(client, bufnr)

local map = function(lhs, rhs, desc)

vim.keymap.set("n", lhs, rhs, { buffer = bufnr, desc = desc })

end

map("gd", vim.lsp.buf.definition, "Go to definition")

map("gr", vim.lsp.buf.references, "List references")

map("K", vim.lsp.buf.hover, "Hover docs")

local flags = require("team.flags")

if not flags.enable_semantic_tokens then

client.server_capabilities.semanticTokensProvider = nil

end

end

The callback applies buffer local LSP keymaps and disables heavy features when flags are off. Large codebases benefit from this because semantic tokens and related features add overhead.

Navigation remains instant because only one server handles the filetype, and costly operations are filtered.

Running tests with project specific commands

The monorepo uses directory level overrides to define commands such as per service test runners. A project override file lives at the service root.

Example .nvim.lua:

vim.keymap.set("n", "<leader>tt", function()

vim.cmd("!pnpm test")

end, { desc = "Service tests" })

The team config loads this file when you enter the project.

local root = require("team.root")

vim.api.nvim_create_autocmd("BufEnter", {

callback = function()

local path = root.get()

local file = path .. "/.nvim.lua"

if vim.loop.fs_stat(file) then

dofile(file)

end

end,

})

Pressing <leader>tt runs service tests from the correct root. The terminal opens in a split and streams output without blocking editing.

Opening a huge generated file

During debugging, you inspect a generated output or bundled file. These files often exceed megabytes. The optimized setup detects them and skips heavy features.

The size helper:

local M = {}

function M.is_large(bufnr)

local limit = 200000

local ok, stat = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(bufnr))

if not ok or not stat then

return false

end

return stat.size > limit

end

return M

Large files disable Treesitter, semantic tokens, and inlay hints. They open instantly in plain highlighting or no highlighting. Movement stays smooth because nothing triggers expensive parsing.

Working with tests, logs, and code in parallel

You open logs in a horizontal split. You open tests in a vertical split. You edit code in one window while running tests in another. Tmux style navigation works because window movement is mapped consistently:

<Ctrl h>, <Ctrl j>, <Ctrl k>, <Ctrl l>

Heavy plugins remain unloaded unless invoked. For example, you run live_grep:

<leader>fg

At that moment, Telescope loads once and then provides fast interactive search through the monorepo.

Late session adjustments using feature flags

If the monorepo feels dense, you reduce noise by disabling virtual text and hints in your local overrides.

local flags = require("team.flags")

flags.enable_virtual_text = false

flags.enable_inlay_hints = false

This reduces redraw workload and improves responsiveness on slower machines.

Across the entire session, the optimized setup remains stable because heavy tools load only when needed, expensive features stay behind toggles, and large files never trigger costly operations. Each layer of the configuration supports large scale work without slowing down your core editing flow.