While my neovim configuration works out of the box for most of my projects, I have increasingly encountered situations where I need to adjust certain aspects on a per-project basis.
For example, while doing some work with LLVM, I wanted to make use of the MLIR and TableGen LSP servers. While I could install these locally and add them to my configuration, I found it quite beneficial to use the specific binaries built from the LLVM tree I was working on - that way any changes I made to, for example, an MLIR dialect, would immediately be available in the MLIR LSP after a quick re-compile.
There is nothing stopping me from adding such edge-case checks to my main configuration - but that does not scale nicely and inevitably leaves behind a whole bunch of configuration snippets I don't actively use, but don't want to delete in case I go back to a project in the future. I would much prefer a strategy that allows me to keep such per-project configuration with the actual project.
As with anything related to neovim configuration there are about 50 different ways this can be achieved, and because neovim is usually configured with executable code instead of declarative config files, there are a few gotchas that might not be immediately obvious. Therefore I thought it might be useful to quickly write-up how the setup I landed on works, and my thoughts behind why I built it in this way.
First, a quick overview of some related built-in features and plugins that are related to this subject:
exrc optionThe exrc option is the historical solution to this problem, dating all
the way back to vi:
set exrc
On startup, when this option is set, vim will look for a .vimrc or .exrc
vimscript file in the current directory, while neovim will look for .nvimrc
or .exrc vimscript file or a .nvim.lua lua script file.
If one such file is found, after the system and main user configuration is
loaded, (neo)vim will :source this file, executing whatever code it contains.
Without any additional security mechanism, this option will execute arbitrary code when opening your editor without any prompt or confirmation. NeoVim features some mitigation to make this less risky - see below.
Have a look at :h exrc for more information.
:trust listAs executing arbitrary code when opening your editor in some repository you
just cloned from the internet is not an amazing idea, neovim introduced
vim.secure.read() and the associated trust list in v0.11.5
(released in 2022 - f1922e7).
When attempting to read a file for the first time using vim.secure.read(), neovim will
prompt the user to view the file and explicitly trust it:
:lua vim.secure.read("README.md")
exrc: Found untrusted code. To enable it, choose (v)iew then run `:trust`:
/home/schilkp/repos/schilkp.github.io/README.md
[i]gnore, (v)iew, (d)eny:
Only if the user actually marks the file as trusted, will the content be read.
The location and name of the file, a sha256 hash of its content, and the user
decision will be stored in the trust list, located at $XDG_STATE_HOME/nvim/trust:
d84ce812f0b22bf6fd2502d9deadbeef2229931192efef1abc0002091b40 /home/schilkp/repos/schilkp.github.io/README.md
! /home/schilkp/Downloads/random_project/sketchy_file_i_dont_trust.lua
After it has been trusted, successive secure reads of this file will complete
without a user prompt, unless the file content or location has changed.
In neovim, vim.secure.read() is used for reading the content of any file
discovered and sourced through the exrc option mechanism, making it significantly
less reckless
(294910a).
See :h vim.secure for more information.
Per-project configuration is a common need, and there are also quite a few
plugins available to help you manage it, including
tpope/vim-projectionist,
klen/nvim-config-local, and
direnv/direnv.
With the vim.secure.read() mechanism, the exrc option in neovim is already
fairly close to what I want.
Its one major limitation for my use is that it is sourced after my main
configuration and therefore after all plugins are configured and loaded.
This makes it tricky to do things like adding new plugins, or disabling LSP
servers and features that my main configuration enables - by the time a .nvim.lua
file is sourced, all this is already done.
For a similar reason, using one of the many plugins is also not so attractive, as I ideally want my local configuration to be available before my plugin manager sources all plugins.
Instead, I opted to implement a simple version of the exrc option directly
in my configuration:
--- localconfig.lua
local M = {}
M.CONFIG_FILE_NAMES = { ".schilk.nvim.lua" }
--- Look for local config file
function M.lookup()
for _, filename in ipairs(M.CONFIG_FILE_NAMES) do
filename = vim.fn.findfile(filename, ".;")
if filename ~= "" then
return vim.fn.fnamemodify(filename, ":p")
end
end
end
--- Find & load local config file
function M.source()
local file = M.lookup()
if not file or file == "" then
return
end
local content = vim.secure.read(file)
if content ~= nil then
vim.api.nvim_command("source " .. file)
vim.defer_fn(function()
vim.notify("[local_config]: loaded local config file", vim.log.levels.INFO)
end, 250)
else
vim.defer_fn(function()
vim.notify("[local_config]: local config file found but not trusted!", vim.log.levels.WARN)
end, 250)
end
end
return M
This module looks for a file called .schilk.nvim.lua in the current or
any parent directories.
If one is found, the vim.secure.read() function is used, to allow me to
manually review and approve the content before it is actually sourced.
In my main init.lua, I can then use this module even before my plugin manager lazy
is initialized:
-- init.lua
require("local_config").source()While the config file can contain arbitrary code, most of the time I want to change
some setting or behaviour of my main configuration.
I do this by storing information in the global _G table, which my main configuration
then checks for and applies if it exists.
For example, to install an additional plugin, I place one or more lazy plugin
specs in _G.SCHILK_LOCAL_PLUGINS:
-- Extra plugins to be inserted into the lazy spec
_G.SCHILK_LOCAL_PLUGINS = {
'schilkp/my_cool_plugin'
}
In my main configuration, I then inject any plugins into the default list of plugin specs before starting the plugin manager.
local plugins = {
-- ... list of plugins ...
}
-- Inject local plugins:
_G.SCHILK_LOCAL_PLUGINS = _G.SCHILK_LOCAL_PLUGINS or {}
if _G.SCHILK_LOCAL_PLUGINS then
for _, plugin in ipairs(_G.SCHILK_LOCAL_PLUGINS) do
table.insert(plugins, plugin)
end
end
require("lazy").setup(plugins)
For reference, here are some of the things which I often do in my local config files:
lsp-config.nvim.The one big downside of this approach is that it is very tightly coupled to my neovim configuration, and therefore these config files are not very useful to anyone else. Unfortunately, because neovim configurations are all so incredibly different, I don't see a great way of changing that.
This is reflected in my choice to name the files .schilk.nvim.lua: I doubt anyone else
will directly use them.
For this reason, I also typically don't check them into project git
repositories.
As I doubt that the LLVM project will be interested in a patch
that adds .schilk.nvim.lua to their .gitignore, to avoid having to keep
juggling that change around locally, I just ignore these files in all git repos
using a global .gitignore:
// ~/.gitconfig:
[core]
excludesfile = /home/schilkp/.gitignore_global
// ~/.gitignore_global:
.schilk.nvim.luaYou can find my complete setup directly in my neovim configuration here