Managing Vim plugins without a plugin manager

Recently I have switched my Neovim setup to using the new native Vim package system directly instead of relying on a plugin manager. I had to use Git submodules for another project anyway, so I figured I could combine them with Vim packages and see how that might work out. It is actually working pretty well, to the point where I don't think that I will be returning to plugin managers for the foreseeable future.

From here on when I say “Vim” I mean both Vim and Neovim.

Vim's plugin system, or lack thereof

Vim does not actually have a plugin system. Instead there is the notion of a runtime patch (:h 'rtp') which is an option that contains a list of directories. When Vim looks up a file name it searches these directories until it finds the file in the appropriate subdirectory. For example, if you open a file with file type vim, Vim will look for a file named syntax/vim.vim (or syntax/vim.lua for Neovim) in each directory of the runtime path and source each one it finds.

To manually load a file we can either use the :runtime command to find it in one of the runtime path directories, or use the :source command with the actual path to the file.

Old-style manual plugin management

The simplest way of installing a Vim plugin is to download a script and place it into the appropriate subdirectory inside ~/.vim (or ~/.config/nvim for Neovim). This works fine as long as the plugin is just a single file. We can update the plugin by swapping out the file and we can remove it by deleting the file. However, once a plugin is made up of several files this approach becomes impractical.

This is where the runtime path comes in. We can download the plugin to its own dedicated directory and add it to the runtime path.

" Append a new directory path to the list
set runtimepath += /path/to/some/plugin

Now every plugin has its own directory and the files are not intermingled. To update a plugin we swap out the directory for a newer version. To disable a plugin we remove it from the runtime path. To uninstall a plugin we remove it from the runtime path and delete the directory.

Vim package managers

All Vim package managers essentially work by performing the above steps automatically. They download repositories, pull in new commits, adjust the file path and remove old repositories. The user only has to declare which plugins to use. Usually they use Git for managing remote plugins, but you can also specify a local directory.

I have used Vundle, vim-plug and most recently packer.nvim. They all work more or less the same: you specify your plugins in Vim script (or Lua), add a bit of bootstrapping code at the beginning, and then from within Vim use custom commands to install, update and remove plugins.

This all works fine, but I have found two annoyances:

  • There is a chicken-and-egg problem where you need a plugin to manage plugins, so bootstrapping on a fresh install can take a number of manual steps

  • I have my own plugins which I want to use and develop on at the same time, so they should not be version-controlled by the plugin manager

None of these are deal breakers. On the other hand, we can do better.

Vim's native package system

Packages were introduces in Vim 8 and ported to Neovim. A Vim package (:h packages) is a collection of plugins, and there can be an arbitrary number of packages. The package directory is ~/vim/pack for Vim, and stdpath('data')/site/pack for Neovim (see :h stdpath()). The name of the package is arbitrary and it contains two directories: start for plugins to automatically load and opt for plugins to load on demand.

Here is an example of what a package might look like

~/.local/share/nvim/site/pack/3rd-party-plugins/
├── opt
│   └── foo
└── start
    ├── bar
    └── baz

The plugins bar and baz are always available. The plugin foo only becomes available after executing :packadd foo. Behind the scenes Vim does the same runtime path hackery that plugin managers do. However, Vim does not manage the plugins, we have to download, update and remove them ourselves.

Managing plugins through Git

There are three types of plugins I want to manage: 3rd party plugins, my own plugins, and experimental plugins.

3rd party plugins

This is the most common type of plugin. Git has a submodule feature which lets us embed repositories within repositories (see the man page gitsubmodules(7)). A submodule is stored as an actual directory inside the main repository and we can explicitly tell Git which path to use. See git help submodules for details.

First I create a regular Git repository pack/3rd-party. It contains the usual files (README, LICENSE and so on). These belong to the repository. Then I add the plugin as a submodule, either inside the opt or start directory. Example:

# I want this plugin to always be active
git submodule add https://github.com/neovim/nvim-lspconfig.git start/nvim-lspconfig

# This is rarely used, so I will load it only when necessary
git submodule add https://github.com/Olical/conjure.git opt/conjure

I can use Git directly to update the plugins, choose a particular branch or check out a particular commit. The file .gitmodules serves as the declarative configuration file: I can re-arrange the order of submodules and add comments. Here is what the above configuration would look like:

# Language Server Protocol configuration
[submodule "start/nvim-lspconfig"]
    path = start/nvim-lspconfig
    url = https://github.com/neovim/nvim-lspconfig
    ignore = untracked

# Live code evaluation while editing (Used mostly by the Lisp family)
[submodule "opt/conjure"]
    path = opt/conjure
    url = https://github.com/Olical/conjure

Note that we can add comments and move entries around, we are not limited to the content Git has generated.

And the best part: since changes to a submodule are changes to the parent repository I can commit each update. If an update breaks my configuration I can easily roll back the parent repository to a previous commit. I get transactional updates for free!

The only downside is that I have not been able to find a simple way of removing submodules. This snippet of shell code works for me:

git rm "$MODULE" && git commit
rm -rf ".git/modules/$MODULE"

For frequently used actions, such as updating packages or removing them, I have a directory of short shell scripts. Here are some examples:

# Add plugins from GitHub
while [ -n "$1" ]; do
	git submodule add "https://github.com/$1" "start/$(echo "$1" | cut -d/ -f2)"
	shift
done

# Remove all traces of the specified plugins
while [ -n "$1" ]; do
	git rm "$1" && git commit
	rm -rf ".git/modules/$1"
done

# Update all the specified plugins to the latest version
git submodule update --remote $*

Each example should be written to its own shell script because it uses the command-line arguments as input.

My own plugins

This one is simple. I create a new package (not a Git repository this time) and add the repositories of my plugins to either opt or start. That's it. The big difference compared to a plugin manager is that I do not have to declare a list of plugins in advance, Vim will simply use what is there. If a particular plugin is missing Vim won't know about it.

Experimental plugins

These are plugins I just want to try out, experiment a bit and either throw them away when done or move properly to my plugins collection. I have a separate package for these where nothing is committed. Just clone, experiment and move on when done.

A plugin manager always required me to add it to my configuration file and thus alter my editor configuration during the experimentation phase.

Conclusion

Vim packages are collections of plugins. By using what I already have and a little bit of manual legwork I was able to meet all my requirements. I have been using this system for about a month now and I have found the following upsides:

  • No additional manager plugin needed

  • Can reuse already existing Git knowledge

  • Different packages can serve different purposes (3rd-party plugins, personal plugins, experimental plugins)

  • Git submodules allow tracking remote repositories and provide transactional version control

  • Easier to bootstrap

  • Can feel smug on the internet

On the other hand, these are the downsides:

  • Requires good knowledge of Git and the shell

  • Plugin configuration is separate from editor configuration and these two need to be kept synchronized

I will let the reader be the judged on how the weight these points against each other. Personally though, I don't mind.