Plugin System

NeoVim has two plugin mechanisms: the traditional runtimepath-based system inherited from Vim, and (as of 0.12) a built-in package manager called vim.pack. Most users interact through a third-party plugin manager, but understanding the underlying system matters.

Runtimepath Basics

NeoVim loads plugins by scanning directories on runtimepath. The key directories:

~/.config/nvim/              -- Your config (always on runtimepath)
  plugin/                    -- Lua/Vim files loaded on startup
  after/plugin/              -- Loaded after all other plugins
  ftplugin/                  -- Filetype-specific config
  lua/                       -- Your Lua modules (require-able)

~/.local/share/nvim/site/    -- User-installed packages
  pack/*/start/*/            -- Auto-loaded packages
  pack/*/opt/*/              -- Opt-in packages (load with :packadd)

When NeoVim starts, it:

  1. Sources init.lua (or init.vim)
  2. Loads all plugin/**/*.lua files from every runtimepath entry
  3. Triggers VimEnter autocommand

Pack Directory Structure

The pack/ convention (from Vim 8) organizes plugins:

~/.local/share/nvim/site/pack/
  core/                      -- Group name (arbitrary)
    start/                   -- Auto-loaded at startup
      telescope.nvim/
      nvim-treesitter/
    opt/                     -- Loaded on demand with :packadd
      nvim-dap/

Each plugin in start/ gets its directories added to runtimepath automatically. Plugins in opt/ are only loaded when you call :packadd plugin-name.

vim.pack - Built-in Package Manager (0.12+)

NeoVim 0.12 adds vim.pack, a built-in Git-based package manager. No external plugin manager needed.

-- In your init.lua
vim.pack.add("neovim/nvim-lspconfig")

-- With options
vim.pack.add("nvim-telescope/telescope.nvim", {
  version = "0.1.*",           -- Semver constraint
  opt = true,                  -- Don't load at startup
})

-- Load an opt plugin later
vim.cmd.packadd("telescope.nvim")

How it works internally (from runtime/lua/vim/pack.lua):

  1. Plugins are cloned to ~/.local/share/nvim/site/pack/core/opt/
  2. A lockfile at ~/.config/nvim/nvim-pack-lock.json pins exact commits
  3. vim.pack.add() with opt = false (default) immediately adds to runtimepath
  4. Version constraints use semver matching against Git tags
  5. Updates are Git fetch + checkout operations

Key functions:

vim.pack.add(name, opts)       -- Add a plugin
vim.pack.update(name?)         -- Update one or all plugins
vim.pack.lock()                -- Write current state to lockfile
vim.pack.list()                -- List installed plugins

Warning: vim.pack is new in 0.12 (currently dev). It works but the API may change. If you need stability now, lazy.nvim or mini.deps are proven alternatives.

Third-Party Plugin Managers

The ecosystem has several popular managers that build on these primitives:

ManagerApproach
lazy.nvimLazy-loading, UI dashboard, lockfile, most popular
mini.depsMinimal, ~300 lines, no frills
packer.nvimDeprecated, predecessor to lazy.nvim
vim-plugVimscript-based, still works, simple

All of them ultimately put files on runtimepath or in pack/. The value-add is lazy loading, dependency resolution, and update management.

Plugin Loading Order

Understanding the load order helps debug conflicts:

1. init.lua runs
   - vim.pack.add() calls queue plugins
   - vim.g.*, vim.o.* settings applied

2. Plugins load (from runtimepath)
   - pack/*/start/*/plugin/*.lua   -- Start plugins
   - ~/.config/nvim/plugin/*.lua   -- Your plugin dir

3. Filetype detection
   - vim.filetype.match() runs
   - ftplugin/*.lua loads for matched filetype

4. after/ plugins load
   - pack/*/start/*/after/plugin/*.lua
   - ~/.config/nvim/after/plugin/*.lua

5. VimEnter fires
   - Autocommands for VimEnter run

Tip: Put plugin configuration in after/plugin/ if it needs to override plugin defaults. The after/ directory loads last, guaranteeing the plugin is already loaded.

Writing a Plugin

A minimal NeoVim plugin is just a Lua module:

my-plugin/
  lua/
    my-plugin/
      init.lua        -- require("my-plugin") loads this
  plugin/
    my-plugin.lua     -- Auto-runs on startup (optional)
-- lua/my-plugin/init.lua
local M = {}

function M.setup(opts)
  opts = opts or {}
  -- Apply user options, set up keymaps, create commands
  vim.api.nvim_create_user_command("MyPlugin", function()
    M.run()
  end, {})
end

function M.run()
  print("Plugin is running")
end

return M

The setup() convention is not enforced by NeoVim - it’s a community pattern. The user calls require("my-plugin").setup({...}) in their config. Files in plugin/ run automatically without any user action.

Filetype Plugins

NeoVim detects filetypes through vim.filetype.match() (a Lua rewrite of the old Vimscript detection). The detection rules live in runtime/lua/vim/filetype.lua (94KB of patterns).

To add custom filetype settings:

-- ~/.config/nvim/ftplugin/go.lua
-- This file runs whenever a Go buffer opens
vim.bo.tabstop = 4
vim.bo.shiftwidth = 4
vim.bo.expandtab = false

Or via autocommands:

vim.api.nvim_create_autocmd("FileType", {
  pattern = "go",
  callback = function()
    vim.bo.tabstop = 4
    vim.bo.expandtab = false
  end,
})

Both approaches work. ftplugin/ files are slightly cleaner for per-filetype config. Autocommands are more flexible (patterns, groups, conditional logic).