Returning to the use of globals for all neovim configuration would be a step backward for the Neovim plugin ecosystem, especially when another better and easier possibility exists.

Some History

Recently, there’s been a bit of a debate going on in the Neovim community around how we should structure our plugins. One of the arguments that’s been put forward has been that we should return to the older “vim” style of plugin configuration, i.e global variables that are picked up by our plugins at runtime to handle configuration.

This argument is a response to the current trend of .setup{} being overloaded by being used for both initialization and configuration. This makes it more difficult to decide when a plugin should be configured vs initialized. It’s also difficult, it’s argued, to keep the loading order for plugins correct when one plugin depends on another.

These arguments are only relevant when taken in the context of poorly utilized plugin management, and poorly isolated plugin configurations. But, this isn’t only the fault of the author. Many well known Neovim users publish, and presumably use, terribly fragile, frankly bad configurations. Many simply have lists of plugins to be installed (nominally using either Lazy.nvim or Packer) and then they either follow that with a large file of plugin configurations or they require a collection of files, which is functionally identical.

These configs might look like:

require("lazy").setup({
  "folke/which-key.nvim",
  "nvim/lspconfig-nvim",
  "folke/neodev.nvim",
  "uga-rosa/ccc.nvim",
  "kosayoda/nvim-lightbulb",
  "williamboman/mason.nvim",
  "williamboman/mason-lspconfig.nvim",
  "neovim/nvim-lspconfig",
  "SmiteshP/nvim-navbuddy",
  "SmiteshP/nvim-navic",
  "MunifTanjim/nui.nvim",
  "weilbith/nvim-code-action-menu",
  "j-hui/fidget.nvim",
})
 
local lspconfig = require("lspconfig")
lspconfig.nixd.setup({ })
lspconfig.gdscript.setup({ })
 
etc...

If this how my configuration was structured, I would run to global config vars too (or possibly just switch to VSCode). vim.g is a well known convention and was the de-facto configuration mechanism for years. But the relative benefits listed are either not convincing, or short sighted.

Backwards compatibility with Vim

Most neovim plugins are written entirely in Lua, making Vim compatibility a non-issue.

”Fewer errors”

It’s much harder to get feedback from plugins about mis-configuration issues. This leads to:

  • Confusing errors
  • Unintended behaviors when subtly wrong data is passed in
  • Intended configuration being ignored, and default behavior continuing

Configuration is always present

If the creation of configuration data itself contains side effects (e.g. reading from a file), that side effect will happen regardless of if the plugin itself is loaded.

No breakage if the plugin is missing

This “benefit” is precisely what we want to avoid. Our configurations shouldn’t have orphaned global variables.

Global, mutable variables aren’t guaranteed to be side effect free, they just guarantee that you won’t know what the side effect is when you declare them.

None of this is to say, though, that there aren’t issues with the .setup{} paradigm, even when used with the current state of the art plugin management.

Lazy.nvim, spooky action, and the problem with setup

Right now, Lazy.nvim is the best out there in terms of plugin management, in large part because it treats nearly all configuration as data1. Keybinds, filetypes, and events that can trigger a plugin to be loaded are all very simple data structures (usually lists of strings). This is how it’s able to live up to it’s name sake and effectively lazy-load plugins at just the right time. This core functionality becomes unwieldy, though, if we separate out plugin declaration and configuration, like in the above example.

In that world, it’s much harder to tell when a plugin has been loaded. And while Lazy.nvim’s killer feature, the automatic loading of plugins whenever they’re required, makes working with dependencies much more robust, it doesn’t guarantee that your plugin will have the right configuration when it’s loaded. Keeping plugin specification together with it’s declaration by using Lazy.nvim’s config and init fields alleviates much of this pain, but not all of it. A dependency, especially when optional, can have a complicated relationship to its dependent, sometimes requiring subtle changes to the dependent’s specification which have to be removed if the dependency is ever removed2.

Even more difficult to manage are partial, pseudo dependencies3. These are plugins that aren’t required for their dependent to function, but do require a change in their dependents configuration to be used 4. Collocation helps in these types of plugin relationships, but since the “dependency” isn’t truly managing its configuration, collocation is broken.

The best way to handle this is to specify the partial dependencies in the dependent’s plugin spec in Lazy.nvim and then simply remember the link between them. Here’s an example from the configuration of cmp in LazyVim. Note, even though this represents the current state of the art (in my opinion), there are still numerous implicit dependencies, and the effects of many partial dependencies:

{
  "hrsh7th/nvim-cmp",
  version = false,
  event = "InsertEnter",
  dependencies = {
    "hrsh7th/cmp-nvim-lsp",
    "hrsh7th/cmp-buffer",
    "hrsh7th/cmp-path",
    "saadparwaiz1/cmp_luasnip",
  },
  opts = function()
    vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })
    local cmp = require("cmp")
    local defaults = require("cmp.config.default")()
    return {
      snippet = {
        expand = function(args)
          require("luasnip").lsp_expand(args.body) -- Explicit dependency
        end,
      },
      mapping = cmp.mapping.preset.insert({
        -- mappings omitted for brevity
      }),
      sources = cmp.config.sources({
        { name = "nvim_lsp" }, -- effect of dependency on dependent config
        { name = "luasnip" },-- effect of dependency on dependent config
        { name = "buffer" },-- effect of dependency on dependent config
        { name = "path" },-- effect of dependency on dependent config
      }),
      -- rest of config omitted for brevity
    }
  end,
},

This config is very good for many reasons, but if we were to remove cmp-path, we’d also need to remove the source from cmp’s base config. This example is simple and straightforward enough to be almost completely harmless. But in a more complicated module, like the treesitter module, or the lsp config in LazyVim, it might become very difficult to manage those types of small changes.

A brighter future

Lazy.nvim currently gives us great tools to control configuration data in the form of the opts field. This field essentially represents the table that will be passed to the plugin’s .setup() function on load. This separation means that we can not only split up the creation of plugin configuration from it’s initialization, it also means that other plugins can potentially modify this data before the plugin is initialized. Cmp sources can add themselves as sources (even without using the existing require('cmp').register_source function), and treesitter modules can manage their configuration sections in the treesitter setup table. This helps with collocation but makes us even more dependent on Lazy.nvim when there’s a much easier solution possible.

Split .setup() in two

Tired:

require("plugin").setup({
    configuration = {
        value_1 = 1,
        value_2 = 2,
    }
})

Wired:

require("plugin").config({
    configuration = {
        value_1 = 1,
        value_2 = 2,
    }
})
 
require("plugin").load()

Where plugin.lua looks something like this

-- Default values
local Config = {
    configuration = {
        value_1 = "default value 1",
        value_2 = "default value 2",
    }
}
local Module = {}
 
Module.config = function(user_config)
    -- This is just one, very simple and permissive, possible implementation of handling changing user config
    Config = vim.tbl_deep_extend("keep", user_config, Config)
    -- This would also be where decisions have to be made around how to handle
    -- configuration changes after loading.
end
 
Module.load = function()
    -- create the functionality of the plugin
    -- * setup autocmds
    -- * register functions on Module to be used by user level keymaps
    -- etc.
    -- An easy migration strategy would simply be to move the existing
    -- implementation using `setup` into a new file, and call that with `Config`
end

This approach fully decouples configuration and loading, which let’s us require('plugin') without fear of suddenly loading a slow or computationally expensive main file from our plugin. The config function is cheap to call, and can also house simple logic about handling default values and rules around extension. Importantly, this also means that plugin authors will have to create sensible defaults. By design, load() will always be called with no parameters, since it handles no configuration, and will only load the plugin with the default configuration if config is never called.

In the mean time

All of this relies heavily on moving the large, if agile, community of existing neovim plugins and configurations. But, I think there’s a way to grab this benefit before anyone’s migrated over to this new strategy.

Lazy.nvim has some really excellent tools to help us, all predicated on the philosophy of specifying our configuration in pure data as much as possible. Lazy.nvim also will merge together duplicate specifications for the same plugin. This means we can specify another spec entry for cmp that looks like this5:

{
    "nvim-cmp",
    dependencies = { "hrsh7th/cmp-path" },
    opts = function(_, opts)
      table.insert(opts.sources, { name = "path" })
    end,
},
{
  "hrsh7th/nvim-cmp",
  version = false,
  event = "InsertEnter",
  opts = function()
    -- normal opts with path absent from the sources table
  end,
}

This let’s us keep our lazy-loading goodness, and separates out configuration from initialization without resorting to unwieldy and hard to manage global state. We can also comfortably delete the entire spec containing cmp-path without breaking our default cmp config at all. There’s a little more mental overhead involved with this approach, but it very well might be worth it in some cases.

EDIT:

Originally I also included a half-baked idea about a new field that Lazy.nvim could add, that I called modifies, that represents this sort of extension relationship. I removed it and added this after thinking more about it, and reading Folke’s comment on it5.

The original version though, can be found here.

Conclusion

I would be sad to see us return to the global configurations and brittle specs of the past, and I want to see this community move toward more robust configurations. Enough posts litter our forums about configurations breaking for seemingly no reason. Too many configurations that started life simple and reliable become unreadable and brittle. Configuration doesn’t need to be so hard.

Footnotes

  1. A great example of this style of configuration is LazyVim, and while I think more people should make their own configs from scratch, this is the one to start with if you want some inspiration.

  2. Common examples would be the way treesitter’s modules, cmp’s plugins, or the many plugins that modify lspconfig’s behavior are configured.

  3. The majority of examples for these types of plugins are what Marc calls extensions. I think that’s a very good way of thinking about the problem, and would probably be worth focusing on as it’s own class of configuration problem.

  4. E.g. [rust-tools.nvim] with lspconfig

  5. Thanks to Folke for this suggestion in a comment on reddit. 2