In the ever-evolving Neovim plugin ecosystem, the usage and appropriateness of a setup
function
for initializing plugins has become somewhat a hot topic.
This discussion has sparked differing opinions among plugin developers.
Like some other plugin authors, I’ve recently found myself reverting back to the more traditional
Vim convention of employing vim.g.<namespaced_table>
or vim.g.<namespaced_option>
for configuration, and leaning on the inherent mechanisms of Neovim for initialization.
In this post, I aim to unpack my perspective on this debate, considering both the present landscape and the potential trajectory of the Neovim plugin ecosystem.
Drawing parallels: The design journey of my first Neovim plugin
While haskell-tools.nvim
wasn’t technically my first
Neovim plugin, it holds the distinction of being the first that wasn’t merely an adapter or extension
for another.
If the name resonates with you, it’s likely because I drew inspiration from the popular
rust-tools.nvim
.
Both plugins, though tailored for different programming languages, share a parallel purpose.
rust-tools
, for historical reasons, depends on nvim-lspconfig
.
It relies on a setup
function to kickstart the lspconfig.rust_analyzer.setup
, among other things.
As I was relatively new to Lua and Neovim plugin development, this structure felt like a logical
blueprint for haskell-tools
.
Which led to this module:
local M = {
config = nil,
lsp = nil,
hoogle = nil,
repl = nil,
}
function M.setup(opts)
-- initialization omitted for brevity
end
return M
As a Haskell developer, seeing state - initialized with nil
- was profoundly unsettling.
Despite my reservations, the setup
paradigm was omnipresent in most Lua plugins I was using.
So I decided to go along with it. This, as @HiPhish puts it very well,
was cargo cult programming.
Tracing the origins of setup
Neovim embraced Lua as a first-class citizen with version 0.5
.
Though the initial API wasn’t as powerful as the one we enjoy today,
it marked the onset of an explosive growth in Lua plugins.
However, the roots of the setup
pattern trace back even earlier. Neovim contributor @norcalli
introduced a library designed “to standardize the creation of Lua based plugins in Neovim.”,
almost a year before Lua’s elevated status1.
From this effort, setup
was born:
- Plugins shouldn’t use the api unless the user explicitly asks them to by calling a function.
- For one time initialization for a plugin, this is achieved by having a
setup()
function which will do any initialization required unrelated to key mappings, commands, and events.- Every other call should be encapsulated by functions exported by the plugin.
Observant readers may notice a subtle difference between the foundational approach and the
conventions that are prevalent today.
In @norcalli’s blueprint, configuration and initialization were decoupled,
with an export
function designated for configuration and setup
exclusively managing initialization.
In contrast, many of today’s plugins meld these two by passing a configuration table directly to setup
.
We will revisit this later on…
Neovim 0.7 - Lua everywhere!
Remember, this setup
concept originated before Lua’s deep integration in Neovim.
Well before init.lua
and auto-loading Lua files on the runtimepath
arrived with version 0.7.
This version brought with it many improvements to the Lua API.
And it was a few months after the release of Neovim 0.7 that @mfussenegger posted
an article
which made me realise I had stuctured my haskell-tools
plugin wrong.
His article (which I strongly recommend reading) differentiates between global and filetype-specific plugins. For Lua plugins, he presents some advantages and disadvantages of various structuring approaches and two configuration methods:
- A
setup
function, which is “useful if the plugin performs expensive initialization or if what it initializes depends on the configuration”, but forces users torequire
the plugin, which may impact startup if not managed properly. - A single global configuration table, like
vim.g.foobar_settings
, which omits the need for arequire
, and provides direct access across multiple modules, but may be harder to validate.
haskell-tools
redesigned
Continuing the trajectory of Neovim 0.7’s advancements, filetype.lua
,
emerged as a notable (experimental) addition. By the time Neovim 0.9 rolled around,
it had effectively replaced the older filetype.vim
.
Initially, haskell-tools.setup
employed autocommands for Haskell and Cabal files.
This approach could bog down startup times, especially if multiple plugins adopted it.
Addressing this, I rolled out the start_or_attach
function for more efficient initialization,
tailored for lazy invocation within users’ after/ftplugin/<haskell|cabal>.lua
scripts.
This shift also severed the plugin’s tie to nvim-lspconfig
for LSP tasks.
But, in a nod to backward compatibility,
the original setup
function remained, bringing along its inherent codebase intricacies.
After recent consideration, I have finally decided to release version 2 of haskell-tools.nvim
.
The pitfalls of setup
@mfussenegger clarifies in his article that he does not advocate against a setup
function.
While I think his article is a great read, I have personally come to the conclusion that
setup
as we know it must burn. Here’s why…
A false sense of “consistency”
The most common argument I hear in favour of defaulting to setup
is “consistency”. In fact,
today’s most popular Lua plugin manager, lazy.nvim
, defaults to
calling require(MAIN).setup(opts)
in the absence of a config
option.
But as we’ve delved into before, the term “setup” is used ambiguously across plugins.
For instance, while plugins like nvim-treesitter
and telescope.nvim
lean on Neovim’s inherent
initialization and employ setup
solely for configuration,
others like nvim-cmp
and nvim-lspconfig
use the same term for both roles.
This facade of uniform naming masks its varied functionalities, leading to false consistency.
Furthermore, global configuration variables or tables, prefixed with a namespace specific to the plugin, are both consistent and compatible with Vimscript2.
The mirage of setup
-driven expectations
The setup
convention in Neovim plugins has inadvertently set certain expectations among users.
A notable interaction I had with a user when I decided to remove it from haskell-tools.nvim
showcased this:
the absence of a familiar setup
function made the configuration feel “strange” and “out of place” to them.
Further, they worried it would prevent them from conditionally loading or initializing the plugin.
This sentiment points to a broader issue.
The prevalent use of the setup
function has conditioned users to expect it as a standard
for configuring and initializing plugins.
This expectation can overshadow alternative, and sometimes more flexible, configuration methods.
Neovim itself offers built-in mechanisms for conditional plugin loading,
and many plugin managers support similar functionalities.
However, the ubiquity of the setup
convention might be inadvertently limiting our view and adaptability,
leading us to perceive deviations as anomalies, even when they may offer superior flexibility.
require
-ing plugins at startup can break your config
To create robust plugins, it’s imperative to differentiate between configuration and initialization.
This becomes particularly crucial when plugins have interdependencies.
Careless require
calls during the configuration phase can induce unexpected hiccups,
primarily influenced by the order of initialization.
The best practice? Make such calls deferred or lazy.
Let’s illustrate with an example:
Not ideal
-- May fail if foo is not initialized
-- before lspconfig
local bar = require('foo').bar
require('lspconfig').clangd.setup {
on_attach = function(_)
bar.do_something()
end,
}
Better
require('lspconfig').clangd.setup {
on_attach = function(_)
-- Will fail only if foo is never initialized
require('foo').bar.do_something()
end,
}
Now, imagine a scenario where nvim-lspconfig
isn’t even present or loaded
(e.g. when using a single configuration on multiple devices with different
sets of plugins).
Neovim’s startup would choke on require('lspconfig')
,
halting further configurations even if the plugin isn’t immediately required.
However, if nvim-lspconfig
leveraged filetype.lua
and vim.g
, things would look different:
- Configuration snippet:
vim.g.lspconfig = {
clangd = {
filetypes = {'c', 'cpp'},
-- additional settings...
}
}
- The initialization script (orchestrated by the plugin, not the user):
-- ftplugin/c.lua
local clangd = vim.g.lspconfig and vim.g.lspconfig.clangd
if clangd.filetypes and vim.tbl_contains(clangd.filetypes, 'c') then
-- config validations and clangd initialization performed and cached in 'lspconfig.cache.clangd'
require('lspconfig.cache.clangd')
require('lspconfig.configs').clangd.launch()
end
In such a setup, Neovim doesn’t break a sweat,
even if nvim-lspconfig
remains unloaded when init.lua
processes vim.g.lspconfig.clangd
.
Automatic dependency management’s Achilles’ Heel
Efforts to resolve pain points in Neovim’s plugin ecosystem have gravitated towards automatic dependency management.
Two notable initiatives3 are:
- Hosting plugins on LuaRocks.org
and installing them with
luarocks
. - The
pkg.json
(packspec
) format specification for plugin metadata and dependencies.
Given Vim’s architecture (and by extension Neovim’s), there’s a specific initialization order where user preferences load before plugins. Ring a bell?
A lurking issue arises when plugins blend their configuration and initialization phases in one
setup
function.
By doing this and handing over the initialization reins to the user,
the core advantages of automatic dependency management risk getting undermined.
It should be a plugin’s duty to articulate its dependencies rather than leaving it to users or plugin managers. In the same spirit, plugins should defer their own initialization until all their dependencies are up and running.
Take a look at this snippet from neodev.nvim
’s4 README:
-- IMPORTANT: make sure to setup neodev BEFORE lspconfig
require("neodev").setup({
-- add any options here, or leave empty to use the default settings
})
-- then setup your lsp server as usual
local lspconfig = require('lspconfig')
-- example to setup lua_ls and enable call snippets
lspconfig.lua_ls.setup({
settings = {
Lua = {
completion = {
callSnippet = "Replace"
}
}
}
})
Highlighting this, had there been a distinct line between configuration and initialization responsibilities, warnings like these wouldn’t be necessary.
Safeguarding initialization in dynamic environments
If you’re like me, and rely heavily on static type checking to fortify your code base, you’ll appreciate the confidence in not worrying about whether parts of your plugin are properly initialized.
Recall how I hinted at the beginning of this post that nil
is evil?
Consider this example from a plugin I use,
which features a telescope.nvim
extension.
From a configuration standpoint, the typical way to add a Telescope extension looks something like this:
require('telescope').load_extension('yank_history')
Now, this is what happens if a user tries to invoke :Telescope yank_history
before calling require('yanky').setup()
:
If setup
has side-effects, it puts plugin authors in a tricky spot: They cannot confidently delegate
control to users and simultaneously ensure that every component initializes when needed.
This issue illustrates a broader challenge: without type safety or strict initialization processes, we run into unpredictable behaviors. While dynamic languages like Lua offer flexibility, they also introduce the potential for such oversights, especially when initialization processes are handed over to the end-users.
What about namespace clashes and clutter?
The traditional way in Vimscript was to have a vim.g.<some_option>
for each configuration option,
typically prefixed with the plugin name, to avoid namespace clashes.
This can introduce a lot of clutter. Fortunately, in Lua, vim.g.foo_bar
can also be a single table (or a function that returns a table),
which is no more likely to clash than a module name.
-- Instead of:
vim.g.plugin_name_option1 = "value1"
vim.g.plugin_name_option2 = "value2"
vim.g.plugin_name_option3 = "value3"
-- We can have:
vim.g.plugin_name = {
option1 = "value1",
option2 = "value2",
option3 = "value3"
}
This approach keeps the global namespace clean and reduces the chances of clashes.
What about a configure
function?
While there’s no inherent issue with a configure
function (or even a setup
function solely
dedicated to configuration), its use can indeed simplify the validation of user configurations.
So, why do I caution against its adoption?
Remember the evolution of @norcalli’s export
/setup
pattern into a unified function?
The crux of the matter lies in the fact that Lua functions can be inherently impure.
This means there’s nothing stopping plugin developers from crafting their configure
functions
in less than ideal ways, such as:
M.configure(opts) = function
-- <do something with opts>
vim.api.nvim_launch_nuclear_warhead()
end
On the other hand, employing a vim.g
variable offers an implicit assurance of its singular role
in configuration.
While this might introduce complexity in validating configurations, Neovim offers a robust solution:
the :checkhealth
mechanism.
For an illustration of its effective utilization, see this example
from my haskell-tools.nvim
plugin.
But… Global variables are an antipattern?
Yes and no. Using global mutable state is frowned upon as an antipattern. However, we are discussing global configuration tables which we only read from. It’s common knowledge among developers, even those with little experience, that mutating global variables is a bad idea. Yet, it’s surprising how many seasoned developers overlook the importance of isolating side-effects.
Enter a world without setup
In light of these considerations, it is increasingly clear that the Neovim community may benefit
from moving beyond the setup
function, or at least the way it’s been traditionally employed.
The setup
function, as it stands today, isn’t the villain.
However, the consequences of its misuse, ambiguity, and lack of clear separation of concerns in
many plugins are real issues that need addressing.
So, what should the ideal look like?
Decoupled Configuration and Initialization: This has been reiterated multiple times, but it’s worth emphasizing. Configuration should be separated from initialization. This ensures that configuration is pure, readable, and unlikely to produce unexpected side effects.
Utilize
vim.g
for Configuration: As has been demonstrated,vim.g
provides an ideal means to store plugin configuration. It aligns well with Vim conventions, provides an implicit assurance of its configuration-only role, and circumvents the need torequire
the plugin at startup.Smart Initialization: Instead of relying on users or external mechanisms for activation, tools should employ intelligent self-initialization. For example, leveraging constructs like
filetype.lua
can defer their loading until genuinely required. However, for plugins with minimal startup footprints, lazy loading might be excessive. For plugins liketelescope.nvim
andnvim-cmp
that support extensions, it would be nifty if Neovim provided a specification akin to:h runtimepath
, so that extensions could register themselves automatically. Something along the lines ofextension/<plugin>/register.lua
5, which plugins could source at runtime.Explicit Dependency Declaration: Plugins should clearly specify any dependencies, and the initialization of such plugins should ensure that these dependencies are loaded before proceeding.
Some caveats
No approach is without its drawbacks. Some possible criticisms of moving away from setup
are:
Learning Curve: This change would introduce a learning curve, especially for newer users who are already accustomed to the
setup
pattern. On the other hand, this approach discourages cargo cult programming and promotes genuine understanding.Migration: Existing plugins that heavily rely on the
setup
pattern might need significant rewrites. While this is an investment in future robustness, it might be daunting for some plugin authors and users alike.Performance: While unlikely, there’s always the potential for performance implications when making a sweeping change. However, any such implications would likely be minimal and far outweighed by the benefits.
vim.g
is a Vimscript/Lua bridge: As such, it may not be suitable for all use cases. For example, metatables are erased6. Although I consider metatables excessive for plugin configuration, a dedicated Lua API might be needed to address such concerns.
[Update] Change isn’t chaos
Addressing reservations some readers have about embracing change; to draw an analogy:
Just as transitioning away from fossil fuels doesn’t plunge us into perpetual darkness,
or shifting to a more sustainable diet doesn’t lead to a world overrun by unchecked animal populations,
the Neovim community won’t descend into chaos if we reconsider the use of setup
.
Change can be daunting, but it’s also a path to improvement.
[Update] Some additional points by @HiPhish
setup
locks users into Lua. Some people prefer Vimscript for configuration.- Many
setup
functions do too much. There’s no need for inconsistent custom DSLs when Neovim already provides better mechanisms (like<Plug>
for keymaps). - A Lua function for configuration forces it all into one place. Maybe I want separate files for all my mappings and for all my custom highlight groups.
Guided migration: A step-by-step plan
For plugin maintainers, especially those with a large user base,
the thought of introducing breaking changes can be daunting.
However, with a structured approach, you can ensure a smooth transition for both you and your users.
Here’s a suggested plan to phase out the setup
function:
Embrace a Rigorous Release Cycle: Start by tagging releases with semantic versions and/or maintaining a stable branch if you haven’t already. This provides a clear framework for introducing changes.
Prioritize Separation of Concerns: If your plugin conflates configuration and initialization in a single function, consider splitting these concerns. Initially, transition to a
setup
function dedicated only to configuration while optimizing initialization. This change is unlikely to disrupt most users.Support Both APIs Temporarily: By concurrently supporting both the old and new APIs, you provide users with a transition period. Consider implementing prompts (with an option to disable them) to guide users towards the new method.
Introduce the New API in a Separate Branch: Create a branch that omits the
setup
function, and actively promote this to your users. Encourage them to switch and provide feedback.
In Conclusion
In the constantly evolving world of Neovim plugins, it’s important to reflect on established patterns
and consider their effectiveness. The setup
pattern, while helpful in certain contexts,
has shown potential pitfalls.
By championing a clear division between configuration and initialization and embracing tools like vim.g
for the former,
we pave the way for a more robust, predictable, and user-friendly Neovim plugin ecosystem.
As developers and users of Neovim plugins, it’s up to us to guide this evolution in a direction that benefits the entire community. Let’s strive for clarity, simplicity, and robustness as we move forward!
Credits
Thanks to the neorocks surgeons for proof-reading and input:
The credit for tracing the origins of
setup
goes to @RRethy.↩︎Some Neovim users find Vimscript more ergonomic for configuration than Lua.↩︎
Notably, these work in progress approaches are not mutually exclusive.↩︎
For the record, I’m quite a fan of this plugin.↩︎
This is analogous to
queries/<language>/<query-file>.scm
.↩︎Thanks to @echanovski for pointing this out to me.↩︎