nvim-config

$npx mdskill add fredrikaverpil/dotfiles/nvim-config

Writes and reviews native Neovim configs without plugin managers.

  • Solves configuring Neovim using only built-in vim.pack and lsp/ conventions.
  • Depends on Neovim >= v0.12.0 and standard paths like plugin/ and lsp/.
  • Triggers on init.lua, plugin/*.lua, vim.pack.add(), or vim.lsp.enable() tasks.
  • Delivers idiomatic Lua config snippets and directory structure guidance.

SKILL.md

.github/skills/nvim-configView on GitHub ↗
---
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:

```
~/.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: `cd ~/.dotfiles/stow && stow
--target="$HOME" --restow --no-folding --adopt shared "$(uname -s)"`.
Neovim itself is managed by [Bob](https://github.com/MordechaiHadad/bob), not
nixpkgs -- binary at `~/.local/share/bob/nvim-bin/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:

| Topic                     | Help tag              | File           |
| ------------------------- | --------------------- | -------------- |
| Startup & init order      | `:h initialization`   | `starting.txt` |
| Native package manager    | `:h vim.pack`         | `pack.txt`     |
| packages / packpath       | `:h packages`         | `pack.txt`     |
| LSP config auto-discovery | `:h lsp-config`       | `lsp.txt`      |
| Enable/disable servers    | `:h vim.lsp.enable()` | `lsp.txt`      |
| ftplugin directory        | `:h ftplugin`         | `usr_41.txt`   |
| after/ directory          | `:h after-directory`  | `options.txt`  |
| runtimepath               | `:h runtimepath`      | `options.txt`  |
| autoload/                 | `:h autoload`         | `userfunc.txt` |
| colors/                   | `:h colorscheme`      | `syntax.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`:

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

| Directory                 | When                    | Purpose                                                                       |
| ------------------------- | ----------------------- | ----------------------------------------------------------------------------- |
| `init.lua`                | Step 7b, once           | Leader keys, `require("options")`, diagnostics                                |
| `lua/`                    | On `require()`          | Lua modules (never auto-sourced)                                              |
| `plugin/**/*.lua`         | Step 11, once           | Plugin install + setup (alphabetical, subdirs included)                       |
| `ftplugin/<ft>.lua`       | Per-buffer, on FileType | Buffer-local settings (`vim.opt_local`)                                       |
| `indent/<ft>.lua`         | Per-buffer, on FileType | Indent expressions                                                            |
| `syntax/<ft>.vim`         | Per-buffer, on FileType | Legacy syntax highlighting (treesitter overrides)                             |
| `lsp/<server>.lua`        | Startup (discovery)     | LSP config tables, auto-discovered by `vim.lsp.config` (see after/lsp/ below) |
| `parser/<lang>.so`        | On demand               | Treesitter parsers                                                            |
| `queries/<lang>/*.scm`    | On demand               | Treesitter queries (highlights, injections, folds, indents)                   |
| `colors/<name>.{vim,lua}` | On demand               | Colorschemes, loaded by `:colorscheme`                                        |
| `autoload/`               | On first call           | Auto-loaded Vimscript/Lua functions                                           |
| `compiler/`               | On `:compiler`          | Compiler settings                                                             |
| `spell/`                  | On demand               | Spell 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:

| Layer               | Directory         | Role                                                                                     |
| ------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
| **options**         | `lua/options.lua` | All `vim.opt` settings, required from `init.lua`                                         |
| **utility**         | `lua/`            | Shared Lua modules: `lazyload.lua`, `merge.lua`, `fold.lua`, `toggle.lua`, pickers, etc. |
| **plugins**         | `plugin/`         | Self-contained plugin files: install + setup + keymaps                                   |
| **lang plugins**    | `plugin/lang/`    | Per-language plugin installs, custom filetypes, autocmds, and setup                      |
| **editor settings** | `ftplugin/`       | Per-filetype `vim.opt_local` (indent, wrap, conceal)                                     |
| **server config**   | `after/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**):

```
~/.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)
  ftplugin/              -- per-filetype editor settings (vim.opt_local)
  plugin/
    lang/                -- per-language plugins, custom filetypes, autocmds
    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, only for languages that need
genuinely language-specific wiring: language-specific plugins
(`vim.pack.add()`), custom filetypes (`vim.filetype.add`), build hooks, and
autocmds. Tool config (servers, formatters, linters, etc.) lives inline in the
core plugin files, not here. Per-filetype editor settings live in `ftplugin/`,
not here.

**`ftplugin/`** -- One file per filetype for per-buffer editor settings
(`vim.opt_local`: indent, wrap, conceal). Sourced by Neovim's built-in filetype
handling, so it applies to the first buffer opened too.

**`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:

| Column           | Meaning                                                     |
| ---------------- | ----------------------------------------------------------- |
| **clock**        | Wall clock time since process start (ms)                    |
| **self+sourced** | Total time for a file including everything it `require()`'d |
| **self**         | Time spent in that file alone (excluding nested requires)   |

**Per-filetype editor settings** (indent, wrap, conceal) live in native
`ftplugin/<ft>.lua` files, not in `FileType` autocmds. `ftplugin/` is sourced by
Neovim's built-in filetype handling for **every** buffer of that filetype,
including the first one opened — a `FileType` autocmd registered inside an
`on_vim_enter` callback runs too late to catch the initial buffer.

```lua
-- ftplugin/templ.lua
vim.opt_local.expandtab = false
```

Before adding one, check whether Neovim's built-in ftplugin already sets what
you want (`:e $VIMRUNTIME/ftplugin/<ft>.vim`) — e.g. Go's `noexpandtab` and
Python's 4-space indent are already provided, so don't duplicate them. Custom
filetypes (registered with `vim.filetype.add` at the top level of a
`plugin/lang/<ft>.lua` file) get their own `ftplugin/<ft>.lua`.

---

## 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:

| Interface           | Equivalent to        | Notes                                                                |
| ------------------- | -------------------- | -------------------------------------------------------------------- |
| `vim.o`             | `:set`               | Raw string get/set -- no table support                               |
| `vim.bo`            | `:setlocal` (buffer) | Raw buffer-scoped options                                            |
| `vim.wo`            | `:setlocal` (window) | Raw window-scoped options                                            |
| `vim.go`            | `:setglobal`         | Global-only (skips local copy)                                       |
| **`vim.opt`**       | `:set`               | Rich `Option` object: tables, `:append()`, `:remove()`, `:prepend()` |
| **`vim.opt_local`** | `:setlocal`          | Same as `vim.opt` but buffer/window-local                            |

**Convention:** use `vim.opt` in `init.lua` and `lua/options.lua`, use
`vim.opt_local` in `ftplugin/<ft>.lua` 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

| Purpose        | Lua                                         | Typical path             |
| -------------- | ------------------------------------------- | ------------------------ |
| Config dir     | `vim.fn.stdpath("config")`                  | `~/.config/nvim`         |
| Data dir       | `vim.fn.stdpath("data")`                    | `~/.local/share/nvim`    |
| Plugin install | `stdpath("data") .. "/site/pack/core/opt/"` | --                       |
| State dir      | `vim.fn.stdpath("state")`                   | `~/.local/state/nvim`    |
| Runtime        | `vim.fn.expand("$VIMRUNTIME")`              | `.../share/nvim/runtime` |
| Cache          | `vim.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. Testing/debugging/coverage/running: `plugin/neotest.lua`, `plugin/dap.lua`,
   `plugin/nvim_coverage.lua`, `plugin/code_runner.lua`
6. _(if needed)_ `ftplugin/<ft>.lua` -- editor settings (`vim.opt_local`),
   unless Neovim's built-in ftplugin already covers them
7. _(if needed)_ `plugin/lang/<ft>.lua` -- language-specific plugins, custom
   filetypes, autocmds
8. _(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" })
```

More from fredrikaverpil/dotfiles

SkillDescription
aipGoogle API Improvement Proposals (AIP) reference. Use BEFORE designing or reviewing APIs, protobuf definitions, or any work involving Google API design standards. Fetches relevant AIP rules from https://google.aip.dev for the task at hand.
gh-pr>-
golang-styleTry to follow the proverbs:
neovimInteract with the user's running Neovim instance via RPC. Use this skill when you need to execute Lua or Vimscript inside Neovim, query buffer state, send commands, or interact with the Neovim runtime in any way. Triggers when the user asks about their current Neovim session, wants to run something inside Neovim, or when you need to inspect Neovim state (buffers, windows, options, LSP, etc.). Also use when running inside a Neovim terminal and needing to communicate with the parent editor.
nix-installInstall Nix in the Claude Code web sandbox (the cloud environment) as a general-purpose package installer, then install packages from the Nix binary cache with no GitHub access. Use whenever a web session needs a tool that isn't preinstalled and isn't in a language registry (npm/pypi/crates/go) — Neovim, ripgrep, a compiler, anything in nixpkgs. Installs nix-bin via apt, configures single-user mode, and installs packages via nix-env against the nixpkgs channel tarball. Web-sandbox only.
nvim-installInstall Neovim and stand up the nvim-fredrik config in the Claude Code web sandbox (the cloud environment), where no Neovim exists. Use this before testing any Neovim config change in a web session, e.g. checking out a PR branch that touches nvim-fredrik and observing its runtime behavior. Installs Neovim via Nix from the binary cache with no GitHub access, wires the repo's config into place, launches Neovim headless with a listen socket, and exports $NVIM so the `neovim` RPC skill can drive it. Web-sandbox only.
nvim-pluginReference: https://neovim.io/doc/user/lua-plugin/
plan-interviewBefore creating a plan, interview the user
pr-style>-
proofProvide proof to your claim(s)