nvim-project-config#
Per-project configuration for Neovim with smart detection, flexible loading, and seamless integration.
Introduction#
Every project is different. Your Neovim configuration should adapt to the project you're working on, not fight against it. nvim-project-config automatically detects which project you're in and loads the appropriate configuration—whether that's project-specific settings, keymaps, or tooling preferences.
This library provides a robust, asynchronous pipeline for project detection and configuration loading. It walks your directory tree, identifies project roots using configurable matchers, finds configuration files in your Neovim config directory, and executes them using pluggable handlers for Lua, Vim script, and JSON.
Architecture Overview#
flowchart TD
BufferOpen[User opens buffer] --> Walk[Walk Stage]
Walk --> DetectRoot[Detect Root Stage]
DetectRoot --> DetectName[Detect Name Stage]
DetectName --> FindFiles[Find Files Stage]
FindFiles --> Execute[Execute Stage]
Execute --> Applied[Configuration Applied]
subgraph Pipeline["Pipeline Stages"]
Walk
DetectRoot
DetectName
FindFiles
Execute
end
subgraph Caches
DirCache[(Directory Cache)] -.-> Walk
FileCache[(File Cache)] -.-> FindFiles
FileCache -.-> Execute
end
CTX{{ctx}} --> Pipeline
The system operates through a configurable pipeline with pluggable stages:
- Walk: Traverses directories upward from current file, yielding each as a potential project
- Detect (root): Identifies the project root using matchers (e.g.,
.gitfolder) - Detect (name): Extracts project name (e.g., from
package.json) - Find Files: Locates configuration files in
~/.config/nvim/projects/ - Execute: Runs found configurations through appropriate handlers
Stages are instantiated functions—you can use the same stage type multiple times with different configuration. Stages communicate via channels on a mutable context object.
Installation#
-- lazy.nvim
{
"rektide/nvim-project-config",
dependencies = { "nvim-lua/plenary.nvim" },
config = function()
require("nvim-project-config").setup({})
end
}
Quick Start#
-- Minimal setup - uses all defaults
require("nvim-project-config").setup()
For a project at ~/src/rad-project/, this loads:
~/.config/nvim/projects/rad-project.lua~/.config/nvim/projects/rad-project.vim~/.config/nvim/projects/rad-project.json~/.config/nvim/projects/rad-project/*.lua(and .vim, .json)
Create your first project config:
-- ~/.config/nvim/projects/my-project.lua
vim.opt_local.tabstop = 4
vim.opt_local.shiftwidth = 4
vim.keymap.set('n', '<leader>tb', ':!npm test<CR>', { buffer = true })
Configuration#
Short Form#
require("nvim-project-config").setup({
config_dir = vim.fn.stdpath("config") .. "/projects",
on_load = function(ctx) print("Loaded: " .. ctx.project_root) end,
})
Full Default Configuration#
local detect = require("nvim-project-config.stages.detect")
local walk = require("nvim-project-config.stages.walk")
local find_files = require("nvim-project-config.stages.find_files")
local execute = require("nvim-project-config.stages.execute")
require("nvim-project-config").setup({
-- Where to find project configs
config_dir = function()
return vim.fn.stdpath("config") .. "/projects"
end,
-- Pipeline stages (instantiated, order matters)
pipeline = {
walk({ direction = "up" }),
-- Detect project root
detect({
matcher = { ".git", ".hg", ".svn", "Makefile", "package.json" },
on_match = function(ctx, path)
ctx.project_root = path
ctx.project_name = vim.fn.fnamemodify(path, ":t")
end,
}),
-- Optionally extract project name from package.json
detect({
matcher = "package.json",
on_match = function(ctx, path)
local pkg_path = path .. "/package.json"
local ok, content = pcall(vim.fn.readfile, pkg_path)
if ok then
local data = vim.fn.json_decode(table.concat(content, "\n"))
if data and data.name then
ctx.project_name = data.name
end
end
end,
}),
find_files({
extensions = { ".lua", ".vim", ".json" },
}),
execute({
router = {
[".lua"] = require("nvim-project-config.executors.lua"),
[".vim"] = require("nvim-project-config.executors.vim"),
[".json"] = require("nvim-project-config.executors.json"),
},
}),
},
-- Executor options
executors = {
lua = {
async = false, -- Run Lua files async (default: sync)
},
vim = {
async = false, -- Run Vim files async (default: sync)
},
},
-- Loading behavior
loading = {
on = "startup", -- "startup" | "lazy" | "manual"
start_dir = nil, -- Starting directory (default: vim.fn.getcwd())
watch = {
-- Watch config directory for file changes (vim.loop.fs_event)
-- Reloads when you edit ~/.config/nvim/projects/*.lua
config_dir = false,
-- Watch buffer changes (BufEnter autocmd)
-- When focus moves to buffer in different directory, reload for that context
buffer = false,
-- Watch cwd changes (DirChanged autocmd)
-- Reloads on :cd, :lcd, :tcd
cwd = false,
debounce_ms = 100, -- Debounce time for rapid events
},
},
-- Caching
cache = {
directory = {
enabled = true,
},
file = {
enabled = true,
mtime_check = true,
write_through = true,
},
trust_mtime = true, -- Set false for unreliable filesystems
},
-- Callbacks
on_load = nil, -- function(ctx) after successful load
on_error = nil, -- function(err, ctx, file) on errors; pipeline continues
on_clear = nil, -- function(ctx) when context cleared
})
Callbacks#
on_load(ctx) - Ready Signal#
Called when configuration pipeline completes successfully. Use this callback for reactive setup that responds to config completion.
require("nvim-project-config").setup({
on_load = function(ctx)
-- Project configuration is ready
print("Loaded: " .. ctx.project_name)
-- Apply settings based on loaded config
if ctx.json and ctx.json.formatter then
vim.cmd("compiler " .. ctx.json.formatter)
end
-- Create autocommands for this project
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = "*.ts",
callback = function()
-- Project-specific save hook
end,
})
end,
})
When called:
- After all pipeline stages complete successfully
- After all config files are executed and merged
- Before any watchers start monitoring for changes
Common uses:
- Reactive setup that runs whenever a project loads
- Apply conditional settings based on
ctx.jsonvalues - Create project-specific autocommands
- Log or notify user of loaded project
- Set up project-specific UI elements
When to use on_load vs load_await():
- Use
on_loadfor reactive setup - runs automatically every time a project loads - Use
load_await()for imperative control - wait for load in a specific coroutine
Note: The callback runs in the main thread via vim.schedule(), ensuring it's safe to interact with Neovim APIs.
on_error(err, ctx, file) - Error Handler#
Called when a configuration file fails to execute or parse. The pipeline continues loading other files.
require("nvim-project-config").setup({
on_error = function(err, ctx, file)
vim.notify("Config error: " .. file .. "\n" .. tostring(err), vim.log.levels.WARN)
end,
})
Parameters:
err: Error object or messagectx: Current pipeline contextfile: Path to the file that caused the error (optional)
When called:
- When a config file has syntax errors (Lua/Vim)
- When JSON files fail to parse
- When executors throw errors during execution
Note: Unlike on_load, this callback also runs via vim.schedule() for main-thread safety.
on_clear(ctx) - Cleanup Hook#
Called when the context is cleared (e.g., when switching to a different project). Use this to clean up resources.
require("nvim-project-config").setup({
on_clear = function(ctx)
-- Clear project-specific state
if ctx.project_name == "monorepo" then
vim.api.nvim_del_autocmd("MyProjectAutoGroup")
end
print("Cleared: " .. (ctx.project_name or "unknown"))
end,
})
When called:
- When you call
npc.clear()explicitly - When watchers detect a directory change and reload (indirectly)
- Before a new project loads
Common uses:
- Delete project-specific autocommands
- Clear buffer-local settings
- Close project-specific UI elements
- Reset temporary variables
Callback Execution Order#
For a typical project load:
- Pipeline starts (
walk→detect→find_files→execute) - Config files execute (may trigger
on_errorif files fail) - Pipeline completes successfully
on_loadis called ← This is your ready signal- Watchers start monitoring (if configured)
When switching projects or reloading:
on_clearis called for old project- Pipeline restarts for new project
- Config files execute
on_loadis called for new project
### Flexible Matching
Matchers are a core concept supporting multiple forms:
```lua
-- String: exact match
matcher = ".git"
-- Table: any of these match (OR)
matcher = { ".git", ".hg", "package.json" }
-- Function: custom logic
matcher = function(path)
return vim.fn.isdirectory(path .. "/.git") == 1
end
-- Composed matchers
local m = require("nvim-project-config.matchers")
matcher = m.all(".git", "package.json") -- Both must exist
matcher = m.not_(".hg") -- Must NOT match
matcher = m.any(".git", m.all("src", "package.json"))
The on_match Hook#
The detect stage uses on_match to write arbitrary data to context when a match occurs:
detect({
matcher = "Cargo.toml",
on_match = function(ctx, path)
ctx.project_root = path
-- Read project name from Cargo.toml
local cargo = path .. "/Cargo.toml"
for line in io.lines(cargo) do
local name = line:match('^name%s*=%s*"([^"]+)"')
if name then
ctx.project_name = name
break
end
end
end,
})
Nested Project Names#
For monorepos with sub-packages, use nested names:
~/src/big-repo/packages/frontend/
Loads configuration from:
~/.config/nvim/projects/big-repo.lua~/.config/nvim/projects/big-repo/frontend.lua
The deeper file wins on merge conflicts.
File Structure#
~/.config/nvim/
├── init.lua
├── lua/
│ └── ...
└── projects/ # Your project configs
├── rad-project.lua # Config for "rad-project"
├── rad-project/
│ └── extra.lua # Additional rad-project config
├── my-npm-package.lua # Matched by package.json name
└── monorepo/
└── frontend.lua # Nested: monorepo/frontend
nvim-project-config/ # Plugin structure
├── lua/
│ └── nvim-project-config/
│ ├── init.lua # Entry point, setup(), ctx
│ ├── pipeline.lua # Pipeline orchestration, channels
│ ├── cache/
│ │ ├── directory.lua # Single-directory cache (ls_async)
│ │ └── file.lua # File cache with mtime tracking
│ ├── stages/
│ │ ├── walk.lua # Directory walking
│ │ ├── detect.lua # Generic detect with on_match
│ │ ├── find_files.lua # File discovery
│ │ └── execute.lua # File execution router
│ ├── executors/
│ │ ├── lua.lua # Lua file executor
│ │ ├── vim.lua # Vim script executor
│ │ └── json.lua # JSON merge with write-back
│ ├── matchers.lua # Matcher utilities (any, all, not_)
│ └── watchers.lua # Directory/buffer change watchers
├── test/
│ ├── unit/ # Unit tests with mock channels
│ │ ├── matchers_spec.lua
│ │ ├── cache_spec.lua
│ │ └── pipeline_spec.lua
│ └── fixture/ # Integration test fixtures
│ ├── fake-project/
│ │ ├── .git/
│ │ └── package.json
│ └── projects/
│ └── fake-project.lua
└── doc/
└── nvim-project-config.txt # Vim help
Architecture Deep Dive#
Pipeline Execution Model#
Stages are async functions connected by channels. Each stage reads from an input channel and writes to an output channel:
flowchart LR
subgraph "Stage"
InputCh[Input Channel] --> Work[Async Work]
Work --> HasOutput{Has Output?}
HasOutput -->|Yes| OutputCh[Output Channel]
HasOutput -->|No| Continue[Continue Loop]
OutputCh --> Continue
Continue --> InputCh
end
Channels live on ctx. This enables:
- Cancellation:
clear()closes channels, stages exit naturally - Streaming: Multiple inputs yield multiple outputs
- Async I/O: Stages can await without blocking
Context Object#
Context is the mutable state that flows through the pipeline:
ctx = {
-- User configuration (persists across clear)
config_dir = "/home/user/.config/nvim/projects",
pipeline = { ... },
loading = { ... },
on_load = function(ctx) ... end,
on_clear = function(ctx) ... end,
-- Caches (persist across clear, have own invalidation)
dir_cache = DirectoryCache, -- Single-directory listings
file_cache = FileCache, -- File contents + parsed data
-- Discovered state (cleared on clear())
project_root = "/home/user/src/rad-project",
project_name = "rad-project",
json = { ... },
_last_project_json = "/path/to/last/matched.json",
_files_loaded = { ... },
-- Pipeline infrastructure (recreated on run)
channels = { ... },
}
Access context from anywhere:
local npc = require("nvim-project-config")
print(npc.ctx.project_name)
Caching Strategy#
flowchart TD
Request[Request] --> InCache{In Cache?}
InCache -->|No| Fetch[Fetch from Disk]
InCache -->|Yes| MtimeCheck{mtime Changed?}
MtimeCheck -->|No| Return[Return Cached]
MtimeCheck -->|Yes| Invalidate[Invalidate]
Invalidate --> Fetch
Fetch --> Update[Update Cache]
Update --> Return
Directory Cache (ctx.dir_cache):
- Uses
vim.loop.fs_readdir(single directory, not recursive) - One cache entry per directory path
- Invalidated when directory mtime changes
File Cache (ctx.file_cache):
- Stores: path, raw content, mtime, parsed data (
.jsonfield for JSON files) - Read: on mtime mismatch, reloads content and clears parsed data
- Write behavior:
- Write both raw content AND parsed data together, OR
- Write raw content only → clears any parsed data (must re-parse on next read)
- Write-through: all writes go to disk immediately
-- File cache entry structure
{
path = "/path/to/file.json",
content = '{"key": "value"}', -- Raw file content
mtime = 1706540800, -- Last modified time
json = { key = "value" }, -- Parsed JSON (optional, cleared on raw write)
}
-- Writing both (preferred for JSON executor)
ctx.file_cache:write(path, { content = raw_str, json = parsed_table })
-- Writing raw only (clears .json)
ctx.file_cache:write(path, { content = raw_str })
mtime Fallback: If filesystem doesn't support reliable mtime (some network mounts), set cache.trust_mtime = false to always re-read.
Clear and Cancellation#
clear() stops the pipeline and resets state:
function clear(ctx)
-- Close all channels (stages exit their loops)
for _, ch in pairs(ctx.channels or {}) do
pcall(function() ch.tx:close() end)
end
-- Reset discovered state
ctx.project_root = nil
ctx.project_name = nil
ctx.json = nil
ctx._last_project_json = nil
ctx._files_loaded = nil
ctx.channels = nil
-- Notify listeners
if ctx.on_clear then ctx.on_clear(ctx) end
end
New load() call creates fresh channels and runs the pipeline again.
JSON Executor#
The JSON executor merges multiple files with last file wins:
-- ~/.config/nvim/projects/rad-project.json
{ "indent": 2, "formatter": "prettier" }
-- ~/.config/nvim/projects/rad-project/local.json
{ "formatter": "biome" }
-- Result in ctx.json:
{ "indent": 2, "formatter": "biome" }
Writes go to the last matched project-root-named JSON file.
Direct assignment via metatable:
local npc = require("nvim-project-config")
-- Read
local fmt = npc.ctx.json.formatter
-- Write (triggers file write via __newindex metatable)
npc.ctx.json.formatter = "dprint"
-- Nested writes work recursively
npc.ctx.json.lsp = npc.ctx.json.lsp or {}
npc.ctx.json.lsp.enabled = true
The ctx.json table uses a recursive metatable that intercepts writes at any depth and persists changes to disk.
API#
Core#
local npc = require("nvim-project-config")
npc.setup(opts) -- Initialize with options
npc.load() -- Manually trigger load (fire-and-forget)
npc.load_await() -- Load and await completion (returns promise-like awaiter)
npc.clear() -- Stop pipeline, clear state, prepare for reload
npc.ctx -- Current context (mutable)
Async/Await Loading#
Use load_await() to wait for configuration to complete:
local npc = require("nvim-project-config")
local async = require("plenary.async")
npc.setup()
-- Load and wait for completion
async.run(function()
local awaiter = npc.load_await()
local ctx = awaiter()
print("Project loaded: " .. ctx.project_name)
print("Config dir: " .. ctx.config_dir)
-- Your config is now ready, do work here
end)
When to use load_await():
- Script initialization where you need to wait for config before proceeding
- Command that depends on project-specific settings
- Testing or debugging to verify load completion
- Any scenario where you need to sequence actions after config load
Comparison:
npc.load()- Fire-and-forget, useon_loadcallback for notificationnpc.load_await()- Returns awaiter, blocks coroutine until complete
Notes:
- The awaiter function must be called inside a coroutine (via
async.run()) - Returns the same context passed to
on_loadcallback - Preserves any existing
on_loadcallback (both will be called) - Can be called with an override context like
npc.load()
JSON Access#
local npc = require("nvim-project-config")
-- Read
local fmt = npc.ctx.json.formatter
local nested = npc.ctx.json.lsp and npc.ctx.json.lsp.enabled
-- Write (triggers file write to last matched JSON)
npc.ctx.json.formatter = "biome"
Cache Control#
local cache = require("nvim-project-config.cache")
cache.directory.invalidate(path)
cache.file.invalidate(path)
cache.clear_all()
Examples#
Per-Project Formatter#
~/.config/nvim/projects/web-app.lua:
vim.opt_local.tabstop = 2
vim.opt_local.shiftwidth = 2
vim.keymap.set('n', '<leader>rd', ':!npm run dev<CR>')
Project Name from Cargo.toml#
require("nvim-project-config").setup({
pipeline = {
walk({ direction = "up" }),
detect({
matcher = "Cargo.toml",
on_match = function(ctx, path)
ctx.project_root = path
for line in io.lines(path .. "/Cargo.toml") do
local name = line:match('^name%s*=%s*"([^"]+)"')
if name then ctx.project_name = name; break end
end
end,
}),
find_files({ extensions = { ".lua" } }),
execute({ router = { [".lua"] = lua_executor } }),
},
})
JSON Settings with Programmatic Access#
~/.config/nvim/projects/notes.json:
{
"word_count_goal": 1000,
"auto_save": true
}
local npc = require("nvim-project-config")
if npc.ctx.json and npc.ctx.json.auto_save then
vim.opt_local.autowrite = true
end
TODO#
- commit tests
License#
MIT