AgentSkillsCN

nvim-config

原生 Neovim 配置习惯与规范——适用于编写、审查或修改任何使用 Neovim 内置规范的 Neovim 配置,且不使用插件管理框架(无 lazy.nvim、packer 等)。涵盖目录结构、vim.pack 插件管理、lsp/自动发现、插件/加载顺序、键位映射以及标准路径。触发条件为任何涉及 init.lua、plugin/*.lua、lsp/*.lua、vim.pack.add()、vim.lsp.enable() 或“原生 Neovim 配置”的任务——即使用户只是说“添加插件”或“配置 LSP”在原生风格的配置中。

SKILL.md
--- frontmatter
name: nvim-config
description: >
  Native Neovim config idioms and conventions — use whenever writing, reviewing,
  or modifying any Neovim configuration that uses Neovim's built-in conventions
  WITHOUT a plugin manager framework (no lazy.nvim, packer, etc.). Covers
  directory structure, vim.pack plugin management, lsp/ auto-discovery, plugin/
  loading order, keymaps, and standard paths. Trigger on any task
  involving init.lua, plugin/*.lua, lsp/*.lua, vim.pack.add(),
  vim.lsp.enable(), or "native neovim config" — even if the user just says "add
  a plugin" or "configure LSP" in a native-style config.

Native Neovim Config

Reference for Neovim configs using built-in conventions (vim.pack, lsp/, plugin/) without a plugin manager framework. Requires Neovim >= v0.12.0.

This config's location

The native config lives at ~/.dotfiles/nvim-fredrik/ inside the dotfiles repo. It is symlinked into place via GNU Stow:

code
~/.dotfiles/nvim-fredrik/          <- actual files (edit here)
~/.dotfiles/stow/shared/.config/nvim-fredrik -> ../../../nvim-fredrik  (stow entry)
~/.config/nvim-fredrik -> ~/.dotfiles/stow/shared/.config/nvim-fredrik  (stow result)

To launch this config:

sh
NVIM_APPNAME=nvim-fredrik nvim

To apply stow symlinks after changes: ./rebuild.sh --stow from ~/.dotfiles/. Neovim itself is managed by Bob, not nixpkgs -- binary at ~/.local/share/bob/nvim-bin/nvim.

The dotfiles repo also contains nvim-legacy/ -- the previous lazy.nvim-based config (heavily inspired by LazyVim). It can be useful as a reference for how things were solved in the old paradigm. Launched with NVIM_APPNAME=nvim-legacy nvim.

Documentation

Local disk -- docs ship with Neovim at $VIMRUNTIME/doc/. With Bob-managed nightly the path is ~/.local/share/bob/nightly/share/nvim/runtime/doc/. Read them with :h <tag> inside Neovim or directly with your editor/pager.

Key help files for native config work:

TopicHelp tagFile
Startup & init order:h initializationstarting.txt
Native package manager:h vim.packpack.txt
packages / packpath:h packagespack.txt
LSP config auto-discovery:h lsp-configlsp.txt
Enable/disable servers:h vim.lsp.enable()lsp.txt
ftplugin directory:h ftpluginusr_41.txt
after/ directory:h after-directoryoptions.txt
runtimepath:h runtimepathoptions.txt
autoload/:h autoloaduserfunc.txt
colors/:h colorschemesyntax.txt

Online -- https://neovim.io/doc/user/ (mirrors the same help pages). Searching the web for :h <tag> plus "neovim" also works well.


Startup sequence (:h initialization)

The complete Neovim startup sequence, from :h initialization:

StepWhat happens
1Set 'shell' from $SHELL
2Process arguments, execute --cmd args, create buffers (not loaded yet)
3Start server, set v:servername
4Wait for UI to connect (if --embed)
5Setup default mappings and autocmds
6Enable filetype and indent plugins (:runtime! ftplugin.vim indent.vim)
7aSystem vimrc (sysinit.vim)
7bUser config (init.lua) -- leader keys, require("options"), etc.
7c.nvim.lua (exrc) -- project-local config, if 'exrc' is on
8Enable filetype detection (:runtime! filetype.lua)
9Enable syntax highlighting
10Set v:vim_did_init = 1
11Load plugins: plugin/**/*.lua, then packages, then after/ plugins
12Set 'shellpipe' and 'shellredir'
13Set 'updatecount' to zero if -n was given
14Set binary options if -b was given
15Read ShaDa file
16Read quickfix file if -q was given
17Open windows, load buffers -> triggers VimEnter, then UIEnter

Key takeaway: All plugin/ files run at step 11. VimEnter (step 17) fires after everything. The lazyload.lua module queues setup callbacks to run at VimEnter/UIEnter -- async by default (via vim.schedule()), or synchronous with { sync = true }. Only lualine uses { sync = true }; everything else runs async.


Runtime directories

Neovim searches these directories in every runtimepath entry (:h 'runtimepath'). Each directory has a specific purpose and timing:

DirectoryWhenPurpose
init.luaStep 7b, onceLeader keys, require("options"), diagnostics
lua/On require()Lua modules (never auto-sourced)
plugin/**/*.luaStep 11, oncePlugin install + setup (alphabetical, subdirs included)
ftplugin/<ft>.luaPer-buffer, on FileTypeBuffer-local settings (vim.opt_local)
indent/<ft>.luaPer-buffer, on FileTypeIndent expressions
syntax/<ft>.vimPer-buffer, on FileTypeLegacy syntax highlighting (treesitter overrides)
lsp/<server>.luaStartup (discovery)LSP config tables, auto-discovered by vim.lsp.config (see after/lsp/ below)
parser/<lang>.soOn demandTreesitter parsers
queries/<lang>/*.scmOn demandTreesitter queries (highlights, injections, folds, indents)
colors/<name>.{vim,lua}On demandColorschemes, loaded by :colorscheme
autoload/On first callAuto-loaded Vimscript/Lua functions
compiler/On :compilerCompiler settings
spell/On demandSpell checking files

after/ directory

The after/ tree loads after all non-after paths. This config uses nvim-lspconfig for base LSP server configs and puts overrides in after/lsp/ (not lsp/). Because nvim-lspconfig ships its own lsp/ defaults, placing overrides in after/lsp/ ensures they take precedence. Docs: :h after-directory

Per-project overrides (exrc)

With vim.opt.exrc = true (set in lua/options.lua), Neovim sources .nvim.lua from the current working directory at step 7c -- before plugin/ files (step 11), and before filetype detection (step 8). This is the native equivalent of lazy.nvim's .lazy.lua. Docs: :h exrc, :h initialization

Because .nvim.lua runs before plugins, direct require("conform").setup() calls will be overwritten by plugin setup at VimEnter. Use lazyload.on_override to patch plugin config per-project -- it runs after all VimEnter callbacks:

lua
-- .nvim.lua (project root)
require("lazyload").on_override(function()
  require("conform").setup({
    formatters_by_ft = { markdown = { "mdformat" } },
  })
end)

Notes

  • The LspAttach autocmd (in the lsp.lua plugin file) bridges startup and per-buffer: keymaps are registered per-buffer when the LSP server attaches, even though the autocmd itself is registered once at startup.

Architecture: layers and their roles

This config has no framework -- each directory has a single responsibility:

LayerDirectoryRole
optionslua/options.luaAll vim.opt settings, required from init.lua
utilitylua/Shared Lua modules: lazyload.lua, merge.lua, fold.lua, toggle.lua, pickers, etc.
pluginsplugin/Self-contained plugin files: install + setup + keymaps
lang pluginsplugin/lang/Per-language plugin installs, autocmds, editor settings, and setup
server configafter/lsp/All LSP server config tables (in after/ to override package defaults)

Each plugin file is self-contained -- it installs its own packages, sets up the plugin inline, and defines its own keymaps.

Cross-plugin data sharing via _G.Config: Write to _G.Config at the top level of the producer file (outside on_vim_enter), and read it inside the consumer's lazyload block. Top-level assignments execute when Neovim sources plugin/ files (step 11, before any VimEnter callback runs), so the data is always available by the time lazyload blocks fire:

lua
-- plugin/producer.lua
_G.Config.some_data = { "foo", "bar" }
require("lazyload").on_vim_enter(function() ... end)

-- plugin/consumer.lua
require("lazyload").on_vim_enter(function()
  local some_data = _G.Config.some_data or {}
end)

Directory structure

Conceptual layout (:h initialization, step 11 uses plugin/**/*.{vim,lua} -- subdirectories included):

code
~/.config/nvim-fredrik/
  init.lua               -- leader keys, require("options"), diagnostics, keymaps
  lua/
    lazyload.lua         -- VimEnter/UIEnter deferred setup queues
    merge.lua            -- deep merge helper (appends+deduplicates lists, recurses dicts)
    options.lua          -- all vim.opt settings
    dev.lua              -- local dev plugin loader
    ...                  -- other utility modules (fold, toggle, pickers, icons, etc.)
  lsp/                   -- (unused; nvim-lspconfig provides base configs)
  parser/                -- treesitter parser .so files (managed by nvim-treesitter)
  colors/                -- custom colorschemes (loaded by :colorscheme)
  snippets/              -- custom snippet files (loaded by blink.cmp)
  plugin/
    lang/                -- per-language plugins and setup
    blink.lua            -- completion (VimEnter)
    conform.lua          -- formatting (VimEnter)
    dap.lua              -- debugging (deferred to first use)
    lint.lua             -- linting (VimEnter)
    lsp.lua              -- LSP enable + LspAttach keymaps (VimEnter)
    lualine.lua          -- statusline (VimEnter, sync)
    mason.lua            -- tool installation (VimEnter)
    neotest.lua          -- testing (deferred to first use)
    <name>.lua           -- other feature plugins (snacks, treesitter, oil, etc.)
  after/
    lsp/                 -- all LSP server configs (overrides package defaults)
    queries/<lang>/      -- treesitter query extensions (injections.scm, etc.)
    syntax/<ft>.vim      -- legacy syntax overrides/extensions

init.lua -- Minimal entrypoint: leader keys, require("options"), diagnostics, keymaps. Docs: :h initialization

lua/ -- Lua modules loaded via require(). Never auto-sourced. Includes lazyload.lua (VimEnter/UIEnter setup queues), merge.lua (deep merge with list append+dedup), options.lua (editor options), and shared utilities (fold, toggle, pickers, icons, dev).

lua/lazyload.lua -- Provides on_vim_enter(fn, opts?) and on_ui_enter(fn, opts?) for queuing setup functions. Default is async (via vim.schedule()). Pass { sync = true } for synchronous execution. Also provides on_override(fn) for project-local overrides (runs after all VimEnter callbacks). Only lualine uses { sync = true }; everything else runs async.

lua/merge.lua -- Deep merge function. Appends and deduplicates lists, recurses into dicts, overwrites scalars. Use vim.NIL as a value to explicitly remove a key.

lua/dev.lua -- Local development plugin loader. Loads a plugin from a local clone if it exists, otherwise falls back to vim.pack.add().

plugin/ -- Each file is self-contained: vim.pack.add() -> setup -> keymaps. Sourced alphabetically; subdirectories included via the ** glob. Docs: :h initialization (step 11)

plugin/lang/ -- One file per language. Installs language-specific plugins (vim.pack.add()), registers filetype autocmds (including per-filetype editor settings via vim.opt_local), and performs setup.

after/lsp/ -- Each file returns a vim.lsp.Config table; filename becomes the server name. Placed in after/ so they override any base configs from packages. No setup() call needed. Enable servers in plugin/lsp.lua (vim.lsp.enable(...)). Docs: :h lsp-config


vim.pack -- built-in plugin management

lua
-- Install (if missing) and load plugins. Code is available immediately after.
vim.pack.add({
  "https://github.com/user/repo",                                    -- string form
  { src = "https://github.com/user/repo" },                          -- table form
  { src = "https://github.com/user/repo", name = "repo" },           -- custom name
  { src = "https://github.com/user/repo", version = "main" },                   -- branch/tag/commit
  { src = "https://github.com/user/repo", version = vim.version.range("1.*") }, -- semver range
})
  • load option:
    • During init.lua/plugin/ sourcing, defaults to false (:packadd! -- on runtimepath but the plugin's own plugin/ files are deferred to Neovim's normal runtime loader pass instead of sourced inline).
    • After startup, defaults to true (:packadd without bang -- the plugin's plugin/ and after/plugin/ files source immediately).
    • Pass load = true explicitly when you need a plugin's plugin/ files sourced right now (rare -- only matters if vim.pack.add runs during startup and something inspects the plugin's runtime state before step 11 finishes).
    • Pass load = function() end (empty function) to register the plugin on disk without loading it at all. The plugin stays off the packpath entirely until you explicitly call vim.cmd.packadd("<name>"). This is the cornerstone of the "truly lazy" pattern (see below).
  • Install location: stdpath("data") .. "/site/pack/core/opt/<name>"
  • Lockfile: $XDG_CONFIG_HOME/nvim/nvim-pack-lock.json -- commit to VCS for reproducible installs across machines.
lua
vim.pack.update()               -- interactive update with confirmation buffer
vim.pack.update({"name"}, { force = true })   -- update specific plugin, skip confirm
vim.pack.del({"name"})          -- remove from disk
vim.pack.get()                  -- list all managed plugins

No URL shorthand helpers in this config. The upstream docs suggest local gh = function(x) ... end but since we scatter vim.pack.add() across many plugin/ files (one per plugin), a central helper adds no value. Use full URLs directly.


after/lsp/ config files

Each file returns a vim.lsp.Config table. The filename (without .lua) becomes the server name. Placed in after/lsp/ to override any base configs shipped by packages.

lua
-- after/lsp/gopls.lua
---@type vim.lsp.Config
return {
  cmd = { "gopls" },
  filetypes = { "go", "gomod", "gowork", "gosum" },
  root_markers = { "go.work", "go.mod", ".git" },
  settings = {
    gopls = {
      analyses = { unusedparams = true },
      staticcheck = true,
    },
  },
}

Servers are enabled in plugin/lsp.lua via vim.lsp.enable(servers). To disable a server: vim.lsp.enable("gopls", false).


Key idioms

Three patterns cover every plugin in this config. Pick the one that matches when the plugin's code needs to run, not how fancy you want the file to look.

Pattern 1: eager (setup at step 11)

Use when the plugin must take effect before the first paint, or when another plugin's deferred setup callback or a pre-VimEnter autocmd require()s it. Colorscheme, snacks.nvim (dashboard), mini.icons, treesitter.lua, blink.cmp (dependency of lsp.lua's callback).

lua
-- plugin/oil.lua
vim.pack.add({
  { src = "https://github.com/stevearc/oil.nvim" },
})

require("oil").setup({
  view_options = { show_hidden = true },
})

vim.keymap.set("n", "-", "<cmd>Oil<cr>", { desc = "Open file explorer" })

Pattern 2: deferred to VimEnter (pack.add inside the callback)

Use for plugins you want loaded every session but that don't need to be ready before the first paint. This is the default pattern for deferred plugins in this config. Fold vim.pack.add into the same on_vim_enter callback as setup() so both the install/source cost and the setup cost land after startup rather than at step 11:

lua
-- plugin/conform.lua
vim.g.auto_format = true

require("lazyload").on_vim_enter(function()
  vim.pack.add({
    { src = "https://github.com/stevearc/conform.nvim" },
  })

  require("conform").setup({
    formatters_by_ft = {
      go = { "goimports", "gci", "gofumpt", "golines" },
      lua = { "stylua" },
    },
  })
end)

vim.keymap.set("n", "<leader>uf", require("toggle").auto_format, { desc = "Toggle auto-format" })

Why not bare vim.schedule()? lazyload.on_vim_enter gives you sync-vs-async control, VimEnter/UIEnter split, and the on_override hook for exrc overrides -- none of which bare vim.schedule provides.

Build hooks (PackChanged) must stay eager when the plugin uses this pattern. Register the autocmd at file scope before the on_vim_enter call -- autocmd registration is cheap and the hook needs to be live by the time the deferred vim.pack.add triggers a first-bootstrap install.

Pattern 3: truly lazy via { load = function() end } (first use)

Use for plugins that may never run in a session: debuggers, test runners, diff viewers, etc. The empty load callback registers the plugin on disk (so install + lockfile still work) but keeps it off the packpath entirely. The plugin is fully invisible until the user triggers the first-use gate (typically a keymap, command, or filetype autocmd), at which point vim.cmd.packadd brings it in:

lua
-- plugin/dap.lua
local packages = {
  { src = "https://codeberg.org/mfussenegger/nvim-dap", name = "nvim-dap" },
  { src = "https://github.com/rcarriga/nvim-dap-ui", name = "nvim-dap-ui" },
  { src = "https://github.com/nvim-neotest/nvim-nio", name = "nvim-nio" },
}
vim.pack.add(packages, { load = function() end })

local initialized = false

local function init()
  if initialized then
    return
  end
  initialized = true

  for _, p in ipairs(packages) do
    vim.cmd.packadd(p.name)
  end

  require("dapui").setup()
  -- ... rest of setup
end

vim.keymap.set("n", "<leader>dc", function()
  init()
  require("dap").continue()
end, { desc = "Continue" })

Notes:

  • Give every spec an explicit name. The init() loop uses those names for :packadd, so leaving them implicit forces the file to re-derive the name from the URL.
  • after/plugin/ files of the lazy-loaded plugin do not source automatically via bare :packadd. vim.pack's normal path sources them (see pack.lua:801) but the truly-lazy path bypasses that. If a plugin you lazy-load this way ships after/plugin/*.lua and you rely on them, source them manually in init(). (None of the config's current lazy plugins -- dap, neotest, codediff -- have after/plugin/ files.)
  • Compare to Pattern 2: Pattern 2 still loads the plugin every session, just not during startup. Pattern 3 doesn't load it at all if the user never triggers the gate. For DAP, you pay zero cost on sessions where you never debug.

Deferred filetype-specific plugin (csv, log, schemastore, etc.). Wrap require() + .setup() in a FileType autocmd with once = true:

lua
-- plugin/lang/csv.lua
vim.pack.add({
  { src = "https://github.com/hat0uma/csvview.nvim" },
})

vim.api.nvim_create_autocmd("FileType", {
  pattern = "csv",
  once = true,
  callback = function()
    require("csvview").setup()
  end,
})

Local dev plugins via lua/dev.lua -- loads from a local clone if it exists, otherwise falls back to vim.pack.add():

lua
-- plugin/lang/go.lua
require("dev").use({
  dev = "~/code/public/neotest-golang",
  fallback = function()
    vim.pack.add({
      { src = "https://github.com/fredrikaverpil/neotest-golang" },
    })
  end,
})

Build hooks for plugins that need a build step after install or update. Use the PackChanged autocmd:

Important: PackChanged hooks must be registered before the vim.pack.add() call that installs the plugin. Otherwise the hook won't fire on first bootstrap.

lua
vim.api.nvim_create_autocmd("PackChanged", {
  callback = function(ev)
    if ev.data.spec.name == "nvim-treesitter" then
      vim.cmd("TSUpdate")
    end
  end,
})

vim.pack.add({
  { src = "https://github.com/nvim-treesitter/nvim-treesitter", version = "main" },
})

Event data: ev.data.kind ("install", "update", "delete"), ev.data.spec (plugin spec), ev.data.path (full path to plugin directory).

Use do/end blocks to scope locals and visually separate sections in long plugin files. This keeps helpers from leaking into the rest of the file and makes boundaries between logical sections obvious:

lua
require("lazyload").on_vim_enter(function()
  local lint = require("lint")

  lint.linters_by_ft = { ... }

  -- protobuf linters
  do
    local cached_config = nil
    local function find_config() ... end
    vim.api.nvim_create_autocmd(...)
  end

  lint.try_lint()
end)

Always pass { clear = true } to nvim_create_augroup -- prevents duplicate autocmds if the file is re-sourced.

Do NOT defer plugins needed from the first frame or first keystroke: colorscheme, snacks (dashboard). Most plugins use lazyload.on_vim_enter(fn) (async). Only lualine uses lazyload.on_vim_enter(fn, { sync = true }) (synchronous, must be ready before paint).

Profile startup with --startuptime:

sh
NVIM_APPNAME=nvim-fredrik nvim --startuptime /tmp/startup.log --headless +q

The log columns are:

ColumnMeaning
clockWall clock time since process start (ms)
self+sourcedTotal time for a file including everything it require()'d
selfTime spent in that file alone (excluding nested requires)

Per-filetype editor settings live in plugin/lang/ files via FileType autocmds, not in ftplugin/:

lua
-- plugin/lang/go.lua
vim.api.nvim_create_autocmd("FileType", {
  group = vim.api.nvim_create_augroup("native-go-opts", { clear = true }),
  pattern = { "go", "gomod", "gowork", "gohtml" },
  callback = function()
    vim.opt_local.expandtab = false
  end,
})

Plugin file layout

Layout depends on which pattern the file uses (see "Key idioms" above).

Eager (Pattern 1):

lua
-- 1. Build hooks (must be registered BEFORE vim.pack.add)
vim.api.nvim_create_autocmd("PackChanged", { ... })

-- 2. Install + load
vim.pack.add(...)

-- 3. Setup
require("plugin").setup({ ... })

-- 4. Keymaps
vim.keymap.set(...)

Deferred to VimEnter (Pattern 2):

lua
-- 1. File-scope setup that doesn't need the plugin loaded (globals, etc.)
vim.g.some_flag = true

-- 2. Build hooks (must be registered BEFORE the deferred vim.pack.add fires)
vim.api.nvim_create_autocmd("PackChanged", { ... })

-- 3. Install + load + setup, all deferred
require("lazyload").on_vim_enter(function()
  vim.pack.add(...)
  require("plugin").setup({ ... })
end)

-- 4. Keymaps (file scope -- Neovim routes them to the plugin after load)
vim.keymap.set(...)

Truly lazy (Pattern 3):

lua
-- 1. Register on disk without loading
local packages = { { src = "...", name = "plugin-name" } }
vim.pack.add(packages, { load = function() end })

-- 2. First-use gate
local initialized = false
local function init()
  if initialized then return end
  initialized = true
  for _, p in ipairs(packages) do
    vim.cmd.packadd(p.name)
  end
  require("plugin").setup({ ... })
end

-- 3. Keymaps / commands / FileType autocmds call init() before first use
vim.keymap.set("n", "<leader>xx", function() init(); ... end, ...)

Option interfaces

Neovim exposes several Lua interfaces for setting options (:h vim.o, :h vim.opt). This config uses vim.opt and vim.opt_local exclusively:

InterfaceEquivalent toNotes
vim.o:setRaw string get/set -- no table support
vim.bo:setlocal (buffer)Raw buffer-scoped options
vim.wo:setlocal (window)Raw window-scoped options
vim.go:setglobalGlobal-only (skips local copy)
vim.opt:setRich Option object: tables, :append(), :remove(), :prepend()
vim.opt_local:setlocalSame as vim.opt but buffer/window-local

Convention: use vim.opt in init.lua and lua/options.lua, use vim.opt_local in FileType autocmds within plugin/lang/ files. The only exception is vim.wo[win][0] for setting window+buffer-scoped options on a specific window (e.g. LSP foldexpr override in LspAttach).


Standard paths

PurposeLuaTypical path
Config dirvim.fn.stdpath("config")~/.config/nvim
Data dirvim.fn.stdpath("data")~/.local/share/nvim
Plugin installstdpath("data") .. "/site/pack/core/opt/"--
State dirvim.fn.stdpath("state")~/.local/state/nvim
Runtimevim.fn.expand("$VIMRUNTIME").../share/nvim/runtime
Cachevim.fn.stdpath("cache")~/.cache/nvim

With NVIM_APPNAME=nvim-fredrik, paths use nvim-fredrik instead of nvim.


Adding a new language

  1. Add LSP server to the servers list in plugin/lsp.lua
  2. Add mason tools to the ensure_installed list in plugin/mason.lua
  3. Add formatters to formatters_by_ft in plugin/conform.lua
  4. Add linters to linters_by_ft in plugin/lint.lua
  5. plugin/lang/<ft>.lua -- editor settings (vim.opt_local via FileType autocmd), language-specific plugins, autocmds
  6. (optional) after/lsp/<server>.lua -- override nvim-lspconfig base config

Adding a shared utility (toggle, custom picker, etc.)

  1. Create lua/<name>.lua returning a module table
  2. require("<name>") it from whatever plugin/ file needs it

Example -- lua/toggle.lua:

lua
local M = {}
function M.auto_format()
  vim.g.auto_format = not vim.g.auto_format
  vim.notify("Auto-format: " .. (vim.g.auto_format and "on" or "off"))
end
return M

Used in plugin/conform.lua:

lua
vim.keymap.set("n", "<leader>uf", require("toggle").auto_format, { desc = "Toggle auto-format" })