Adventures in Neovim: The Art of Surviving Our Reckless Configurations
Building a module to load fallback configurations
Neovimtures
1. Context and Objectives
How many times have we mistakenly added that malicious character that breaks our configuration into a thousand pieces? A simple r
unnoticed over a bracket and… Surprise! Let’s eat a chunk of error messages at the most inopportune moment!
In this article, I propose to approaching a solution to this by building a module that will help us not only handle the errors in our own config, but also make the process of correcting and dealing with them much more friendly.
To this end, this safety net should fulfill the following tasks:
- Load our configuration as usual.
- In case of error:
- Automatically load a fallback configuration.
- Give us information on where the problems are located.
- Ask us whether or not to open the offending file(s) for editing.
Sounds good? Well, let’s code it!
2. Approach:
As we customize our Neovim experience, we will probably accumulate code to simplify things for us. For example, the typical wrapper to vim.keymap.set
.
A standard way commonly observed in more advanced Neovim configurations, is to group that code or functionality inside a utilities module; the typical utils
module in our lua
path. Thinking about our future selves —and if you have not already done so—, I suggest incorporating this style or any other to better organize our configuration and make it more maintainable in the process.
Current Status
Now, let’s look at a standard example config with the following structure:
1
2
3
4
5
6
7
8
9
10
11
12
$ cd ~/.config
$ tree nvim/
nvim/
├── init.lua
└── lua
├── config
│ ├── mappings.lua
│ └── settings.lua
├── plugins
│ └── etc...
└── utils
└── etc...
In this setup, through init.lua
the settings
, mappings
and plugins
modules are loaded. All standard:
1
2
3
4
-- nvim/init.lua
require("config.settings")
require("config.mappings")
require("plugins")
The problem with this approach to building a configuration capable of supporting our own inoperability is that any error on those modules will lead to chaos and we will end up with the default settings. Depending on the case, this can be a rather frustrating situation, as not only would we be dealing with the error, but we would be doing so without access to our fundamental settings.
3. Our fallback config
For example, I’ll show 2 basic settings for me as examples of the content of this fallback config to explain the criteria I’m following here, the idea is to include only the essentials.
The first setting, simply demanded by my fingers’ memory:
1
2
-- lua/config/mappings.lua
vim.opt.langmap = "ñ:,Ñ\\;"
For those curious, on
ISO-ES
keyboards, theñ
key is located to the right of thel
key, and not;
. With this setting, I not only enable theñ
key in normal mode, but also map it to:
—A two-in-one solution (enable the key plus “invert”;
with:
).
Without this, every time I press the ñ
key (the positional equivalent for :
), nothing happens, and common actions like :w
(ñw
) followed by ZQ
won’t have the expected effect 🥲. (ZQ
is equivalent to :q!
)
In this example, the second fundamental setting for me is enabling relative line numbers for [count]
+j
/l
style moves:
1
2
3
-- lua/config/settings.lua
opt.relativenumber = true -- Show relative line numbers
opt.number = true -- Shows the current line number instead of 0
Not having any of these settings is a complete nuisance to me. So in the fallback configuration, they should definitely go.
Obviously, everyone should adjust the content of their fallback configuration to suit themselves. I suggest aiming for a minimalistic and robust approach.
For the structure of this fallback configuration, let’s replicate the structure of our normal config. Although, unlike what I do here, you could group everything into a single module; it’s up to you. In any case, let’s create the fallback
directory and include settings.lua
and mappings.lua
inside:
1
$ mkdir lua/config/fallbacks
1
2
3
-- Fallback settings (lua/config/fallback/settings.lua)
opt.relativenumber = true -- Show relative line numbers
opt.number = true -- Shows the current line number instead of 0
And also:
1
2
-- Fallback mappings (lua/config/fallback/mappings.lua)
vim.opt.langmap = "ñ:,Ñ\\;"
Ok, all set. I assume everyone is clear on what goes into their fallback config by now. Since we’ve already defined the what, now we just need to focus on the how.
4. Our fallback module
For loading our configurations, we basically have to implement a wrapper around calls to require
, or more specifically, a wrapper around require
using pcall. From the error that pcall
catches, we will be able to get the necessary info and act accordingly.
The module
The time has come to show off our lua skills. Let’s see the basic shape of the module’s code (in lua/utils/loaders.lua
):
1
2
3
4
5
6
7
8
9
10
---Helper functions used to safely load Neovim config modules.
---@class UtilsLoader
local Loaders = {}
---@param module string
function Loaders.load_config(module) end
---@param fallbacks? boolean `true` to load fallback settings if errors are found.
---@return boolean -- Returns `true` if no errors are detected.
function Loaders.check_errors(fallback) end
From here we can infer the idea: instead of using require("config.mappings")
we will use load_config("config.mappings")
. After that, we could check whether there was an error or not with check_errors()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
-- nvim/init.lua
-- require("config.settings")
-- require("config.mappings")
-- require("plugins")
local utils = require("utils")
utils.load_config("config.settings")
utils.load_config("config.mappings")
check_errors(true)
utils.load_config("config.plugins")
utils.check_errors()
Why separate loading from error resolution? Because they are two distinct tasks. First, we want to try loading the standard configuration. Secondly, if a problem arises, instead of throwing the error immediately, we continue loading further modules to gather more potential errors. In this way, if more than one module has problems, we can report them all at once rather than one at a time. This approach makes the loading process more comprehensive and provides more information about the issues in a single run.
load_configs
Let’s review the load_configs
function. We will collect the errors in a table, which we can call catched_errors
. We create it at module level so we can access it later:
1
2
3
4
5
6
7
8
9
10
11
12
---@type table Collection of errors detected by `load_config` (if any).
Loaders.catched_errors = {}
---@param module string
function Loaders.load_config(module)
local ok, call_return = pcall(require, module)
if not ok then
print("- Error loading the module '" .. module .. "':\n " .. call_return)
table.insert(Loaders.catched_errors, module)
end
return call_return
end
check_errors
Now that we’ve collected the errors, we still need to handle them. This will be taken care of by the check_errors
function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
---@param fallbacks? boolean `true` to load fallback settings if errors are found.
---@return boolean -- Returns `true` if no errors are detected.
function Loaders.check_errors(fallbacks)
-- First the happy path:
if #Loaders.catched_errors == 0 then
return true
end
-- If troubles arrive, we load the fallback configuration:
if fallbacks then
vim.notify("Loading fallback configs.", vim.log.levels.ERROR)
require("config.fallback.settings")
require("config.fallback.mappings")
end
-- Finally, we inform the user
local msg = string.format("%s\nDetected errors:\n", string.rep("-", 80))
vim.notify(msg .. vim.inspect(Loaders.catched_errors), vim.log.levels.ERROR)
return false
end
As you see, the logic and code is very simple.
5. Improving the code
Why do we load the full fallback configuration and not just what has failed?
That was my first approach on this, but depending on what we have in both settings
and mappings
, the config could be in an undetermined state. For example, if we associate a shortcut with some utils
function or base it on a specific setting, that relationship would leave the code in a bad state. This is a fallback functionality; let’s avoid potential issues.
Secondly and more importantly, let’s not lose focus. The goal here is to detect that there has been a problem and provide a relatively comfortable environment to fix it, not to have an instance of Neovim that works with as many features as possible.
Anyway, with load_config
and check_errors
, we already have the basic functionality we were looking for. However, let’s take a small step forward and add the last point of our goals: asking whether or not to open the file with problems. As is often the case with Neovim, this is quite simple to do with the provided built-in tools. In this case, we can use vim.fn.input
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Loaders.check_errors(fallbacks)
-- etc.
vim.notify(msg .. vim.inspect(Loaders.catched_errors), vim.log.levels.ERROR)
if vim.fn.input("Attempt to open offending files for editing? (y/n): ") == "y" then
print(" ") -- (I think I added this to make the message look better formatted)
print("Opening files...")
for _, module in pairs(Loaders.catched_errors) do
-- We get the path to the troubled file from the error message
local path = string.format("%s/lua/%s.lua", NeovimPath, module:gsub("%.", "/"))
if vim.fn.findfile(path) ~= "" then
vim.cmd("edit " .. path)
end
end
end
return false
end
NeovimPath
is a global variable defined in myinit.lua
. I’m leaving it here for reference in case you want to use it in your implementation:
1 2 ---Path of the lua config (`nvim/lua/config/`). MyConfigPath = vim.fn.stdpath("config") .. "/lua/config/"
We can refactor the new code a little, by moving the obtaining-path logic to its own function, get_path_from_error
, and also lets add support for more cases:
1
2
3
4
5
6
7
local function get_path_from_error(str)
if str:sub(1, 1) == "/" then
return str
end
return string.format("%s/lua/%s.lua", NeovimPath, str:gsub("%.", "/"))
end
I will add it inside check_errors
to avoid parsing it if no errors are found (check the code below), but it could just as well be defined as a UtilsLoader
or a local function.
6. Putting the parts together
First, we need to choose a way to expose our code. For example, we could simply do something like require("utils.loaders").load("config.mappings")
and call it a day. However, let’s build something a little more ergonomic. In utils/init.lua
:
1
2
3
4
5
6
7
8
9
10
local Utils = {}
-- expose the loaders through utils
local loaders = require("utils.loaders")
Utils.load = loader.load_config
Utils.check_errors = loader.check_errors
-- etc.
return Utils
Let’s incorporate all this into the final code. Since we are civilized people, we should also take the opportunity to add the corresponding annotations to assist our LSP server in its noble and chivalrous task:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
---Helper functions used to load Neovim config modules.
---@class UtilsLoader
local Loaders = {}
---@type table Collection of errors detected by `load_config` (if any).
Loaders.catched_errors = {}
---_Helper function to load the passed module._
---
---If the module returns an **error**, then print it and store it in the
---`catched_errors` table.
---@param module string Name of the module to load.
---@return any call_return Return of the require module call (if any).
function Loaders.load_config(module)
local ok, call_return = pcall(require, module)
if not ok then
print("- Error loading the module '" .. module .. "':\n " .. call_return)
table.insert(Loaders.catched_errors, module)
end
return call_return
end
---_Helper function to handle detected `load_config` errors_
---
---If an error is detected it will load the fallback settings and **ask the
---user** to open or not the offending file.
---@param fallbacks? boolean `true` to load fallback settings if errors are found.
---@return boolean -- Returns `true` if no errors are detected.
function Loaders.check_errors(fallbacks)
if #Loaders.catched_errors == 0 then
return true
end
local function get_path_from_error(str)
if str:sub(1, 1) == "/" then
return str
end
return string.format("%s/lua/%s.lua", NeovimPath, str:gsub("%.", "/"))
end
if fallbacks then
vim.notify("Loading fallback configs.", vim.log.levels.ERROR)
require("config.fallback.settings")
require("config.fallback.mappings")
end
local msg = string.format("%s\nDetected errors:\n", string.rep("-", 80))
vim.notify(msg .. vim.inspect(Loaders.catched_errors), vim.log.levels.ERROR)
if vim.fn.input("Attempt to open offending files for editing? (y/n): ") == "y" then
print(" ")
print("Opening files...")
for _, error in pairs(Loaders.catched_errors) do
local path = get_path_from_error(error)
if vim.fn.findfile(path) ~= "" then
vim.cmd("edit " .. path)
end
end
end
return false
end
return Loaders
But when has the final version of any code ever actually been the final? The most attentive readers will have noticed by now that we have actually ignored an important part of the problem. Let’s look at our init.lua
example:
1
2
3
4
5
6
7
local utils = require("utils")
utils.load_config("config.settings")
utils.load_config("config.mappings")
check_errors(true)
utils.load_config("config.plugins")
utils.check_errors()
And what happens inside require("utils")
? What if there’s a bug in there? After all, it’s a huge bug vector.
Well, it would fail, and all of our work would be for nothing.
7. Back to the beginning?
No. This is not like the typical subscription article on Medium that abandons us the moment we think a little on our own and venture a few millimeters out of its main frame (😗🎶). Remember that we already have our UtilsLoader
module working, and therefore, there is nothing left to do but apply our loader
to —in full Inception style— load the utils
itself.
Loading the loader of the loader
Let’s assume this is the base content of our utils/init.lua
file, responsible for incorporating the different utility modules into one source (narrowed down to avoid distractions):
1
2
3
4
5
6
7
8
9
10
11
12
---A collection of custom helper functions.
---@class Utils
---@field config UtilsConfig
---@field custom UtilsCustom
---@field helpers UtilsHelpers
local Utils = {}
Utils.config = require("utils.config")
Utils.custom = require("utils.custom")
Utils.helpers = require("utils.helpers")
return Utils
In the same way as in our main init
, we have replaced the require
calls with those of our own module; here, we can do the same and even go a little further by adding a loaders loader.
First, we load the loaders
module itself and then replace the require
calls with it (don’t forget to check if there were errors in the importing procedure):
1
2
3
4
5
6
7
8
9
10
11
12
13
---@class Utils
local Utils
local loaders = require("utils.loaders")
-- load the utils modules
Utils.config = loaders.load_config("utils.config")
Utils.custom = loaders.load_config("utils.custom")
Utils.helpers = loaders.load_config("utils.helpers")
assert(loaders.check_errors())
-- etc.
return Utils
But why stop there? What if we have a problem within the loaders
itself? Again, pcall
to the rescue:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local Utils
local ok, loaders = pcall(require, "utils.loaders")
if not ok then
vim.cmd("edit " .. NeovimPath .. "/lua/utils/loaders.lua")
error(string.format("Error in 'utils.loaders':\n\n%s\n", loaders))
end
-- Expose the module through utils
Utils.load = loaders.load_config
Utils.check_errors = loaders.check_errors
-- load the utils modules
Utils.custom = loaders.load_config("utils.custom")
Utils.config = loaders.load_config("utils.config")
Utils.helpers = loaders.load_config("utils.helpers")
assert(loaders.check_errors())
return Utils
Excellent! Now we have all our loads protected, and we finally have our own error-proof armored shoes ready.
Final touches to utils
If we may, let’s now do a little refactoring to group the module loading into a single function (excluding loaders
of course) and separate the steps of loading utils
, which would only load the loaders
, from the loading of the other utility modules. The code explains itself better:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
---A collection of custom helper functions.
---@class Utils
---@field config UtilsConfig
---@field custom UtilsCustom
---@field helpers UtilsHelpers
local Utils = {}
-- Load the loaders. (executed only during the first `require("utils")` call)
local ok, loaders = pcall(require, "utils.loaders")
if not ok then
vim.cmd("edit " .. NeovimPath .. "/lua/utils/loaders.lua")
error(string.format("Can't load 'utils.loaders':\n\n%s\n", loaders))
end
Utils.load = loaders.load_config
Utils.check_errors = loaders.check_errors
-- Load the utils modules.
function Utils.load_utils()
Utils.config = Utils.load("utils.config")
Utils.custom = Utils.load("utils.custom")
Utils.helpers = Utils.load("utils.helpers")
assert(loaders.check_errors())
end
return Utils
Beautiful.
With this, we must remember that now, in addition to using require("utils")
, we must initialize the module with require("utils").load_utils()
.
It’s worth mentioning that with this design approach, the load_utils
function could easily be modified to add parameters for loading our utilities in the future. For example, we could add a profiler, start a DAP session, or simply avoid loading certain modules in specific contexts.
Now, we just need to adjust our init.lua
and enjoy:
1
2
3
4
5
6
7
8
local utils = require("utils")
utils.load_utils()
utils.load("config.settings")
utils.load("config.mappings")
assert(utils.check_errors(true))
utils.load("config.lazy")
utils.check_errors()
1
$ nvim
8. Conclusions
I wish I had something like this in the early stages of my Neovim adventures. But nevertheless, when these days I’m adding something to my setup and feel like I know what I’m doing, this little module comes to the rescue and reminds me that even a self-named level 20 paladin can roll a 1 at the worst moments. In those situations, it’s nice to have done the homework and have a resilient system that protects us from our own cognitive clumsiness. At those moments, when the module arises and saves the day, every time I thought to myself, “Oh, this is the kind of thing I really enjoy about using Neovim”.
If we’re concerned about the performance impact of this approach on our config, I ran a simple comparison between require
and utils.load
. After 20 Neovim startups, the average difference in time was 0.55ms. To put that in perspective, if troubleshooting an issue normally takes an extra 30 seconds without the fallback settings (and of course, that would feel like much more if you keep pressing ñ
to open the command line instead of :
), the time saved by fixing a single error equals the load time of 54.545 Neovim executions. In that analysis, of course, the time spent building the module and going through all this reading is invaluable pure joy.
Well, I hope it has been an interesting read and that some of the ideas presented here will be useful, especially for those who, fearful of ruining their configuration, refrain from exploring the options offered by this fantastic “editor”.
Good luck!
If you are curious, I use this technique in my own configuration. You can check it out here. It’s essentially the same as what I’ve shown here, but with some additions like handling lazy specs and debugging capabilities.