feat(nvim): configure Neovim via nixvim with Nix-managed plugins

Add an nvim Module that configures Neovim declaratively through nixvim,
wired as a flake input and consumed as its home-manager module. Options,
globals, keymaps, and plugin settings are typed Nix; the colorscheme
call and two autocmds live in modules/nvim/config.lua via extraConfigLua.
Plugins come from nixpkgs (no plugin manager, no runtime cloning); git,
ripgrep, and fd are provided from Nix; treesitter grammars are built by
Nix so no runtime compiler is needed.

Functionally matches the previous config (plugins, keymaps, options, the
nord colorscheme, markdown conceal, the Neogit blame toggle), verified
headless against the generated init.
This commit is contained in:
2026-07-18 23:30:48 -04:00
parent 98fecc314f
commit 6f9309d329
7 changed files with 318 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
---
spec: laptop-mvi
blocked-by: 0001-skeleton-and-building-host
---
## What to build
An nvim `Module` that gives the primary user Neovim configured declaratively through **nixvim**, with **functional parity** to the operator's existing config.
Parity is about the "what" — the same plugins, keymaps, options, colorscheme, and behaviour — not the "how".
The mechanism is deliberately free to follow NixOS's declarative paradigm rather than transplanting the imperative lazy.nvim setup: plugins are managed by Nix (no plugin manager, no runtime cloning, no lockfile), and as much of the config as possible is expressed as typed Nix, with raw Lua kept only as an escape hatch.
## Acceptance criteria
- [x] An nvim `Module` (following the `Enable convention`) is enabled on `neogaia`.
- [x] Neovim is configured via **nixvim**, wired as a flake input (`nixvim.inputs.nixpkgs.follows = "nixpkgs"`), consumed as its home-manager module under `home-manager.users.<user>.programs.nixvim`.
- [x] Functional parity with the previous config: the same plugins (neogit, diffview, gitsigns, oil, snacks, gbprod-nord, render-markdown, which-key, treesitter), keymaps, `vim` options, the `nord` colorscheme, the Neogit blame-toggle autocmd, and markdown concealment — verified headless against the generated config.
- [x] Runtime dependencies `git`, `ripgrep`, and `fd` are provided by Nix; `gcc` is not needed because Nix builds the treesitter grammars.
- [x] The `neogaia` toplevel still builds with the nvim `Module` enabled.
## Implementation Notes
- **nixvim, typed Nix first.** `modules/nvim/nvim.nix` enables `programs.nixvim` with `opts`, `globals`, `keymaps`, and typed `plugins.*` settings. Treesitter uses `plugins.treesitter` (`highlight.enable`, `indent.enable`, `grammarPackages` from the module's own `builtGrammars`) covering nix, lua, bash, fish, markdown, rust, python, java, kotlin, c, cpp, html, css, javascript, typescript, go.
- **The imperative remainder** — the `gbprod/nord.nvim` setup + colorscheme call, the markdown `conceallevel` autocmd, and the Neogit blame-toggle `BufUnload` autocmd — lives in `modules/nvim/config.lua`, pulled in via `extraConfigLua = builtins.readFile ./config.lua`. `gbprod-nord` comes in through `extraPlugins` because nixvim's `colorschemes.nord` is a different plugin.
- **Verification.** `nix build .#…programs.nixvim.build.package` exits 0 and the whole toplevel evaluates. The generated config was exercised headless (launched with `-u` the generated init and a scratch `HOME`, since the wrapper otherwise loads the dev host's real `~/.config/nvim`): all options, keymaps, plugins, the `nord` colorscheme, treesitter highlight + indent, and markdown conceal load with no errors.

View File

@@ -29,3 +29,8 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path). - chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays. That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
- The remote is self-hosted Gitea (`git.alexion.dev`); the forge CLI is `tea` (login `axi`), and `gh` is not installed. - The remote is self-hosted Gitea (`git.alexion.dev`); the forge CLI is `tea` (login `axi`), and `gh` is not installed.
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`); the config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
- nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`).
- Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows.
- To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one.
- **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors).

62
flake.lock generated
View File

@@ -41,6 +41,27 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixvim",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-schemas": { "flake-schemas": {
"locked": { "locked": {
"lastModified": 1780327564, "lastModified": 1780327564,
@@ -160,6 +181,28 @@
"type": "github" "type": "github"
} }
}, },
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1784057377,
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixvim",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"chaotic": "chaotic", "chaotic": "chaotic",
@@ -167,7 +210,24 @@
"home-manager": "home-manager_2", "home-manager": "home-manager_2",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim"
}
},
"systems": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"type": "github"
},
"original": {
"owner": "nix-systems",
"ref": "future-26.11",
"repo": "default",
"type": "github"
} }
} }
}, },

View File

@@ -16,6 +16,13 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Neovim configured declaratively in Nix. Must follow our nixpkgs so its
# plugins build against the same package set.
nixvim = {
url = "github:nix-community/nixvim";
inputs.nixpkgs.follows = "nixpkgs";
};
# Declarative disk partitioning. Each Host declares its own layout; a Host # Declarative disk partitioning. Each Host declares its own layout; a Host
# that preserves an existing pool simply declares none. # that preserves an existing pool simply declares none.
disko = { disko = {

View File

@@ -42,6 +42,9 @@
# tmux as the terminal multiplexer. # tmux as the terminal multiplexer.
modules.tmux.enable = true; modules.tmux.enable = true;
# Neovim, configured declaratively via nixvim.
modules.nvim.enable = true;
# Locale preferences for the base system. # Locale preferences for the base system.
time.timeZone = "America/New_York"; time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8"; i18n.defaultLocale = "en_GB.UTF-8";

27
modules/nvim/config.lua Normal file
View File

@@ -0,0 +1,27 @@
-- Imperative configuration that has no typed nixvim option: the colorscheme
-- call and two autocmds. Everything expressible as Nix lives in ./nvim.nix.
-- gbprod/nord.nvim, provided as an extra plugin from nixpkgs.
require("nord").setup({
transparent = true,
})
vim.cmd.colorscheme("nord")
-- Conceal markdown syntax in markdown buffers (previously an after/ftplugin).
-- conceallevel is window-local, so it is set with opt_local when the filetype
-- is applied to the buffer's window.
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function()
vim.opt_local.conceallevel = 2
end,
})
-- Turn line blame off again once the Neogit status buffer is closed.
vim.api.nvim_create_autocmd("BufUnload", {
callback = function(args)
if vim.bo[args.buf].filetype == "NeogitStatus" then
require("gitsigns").toggle_current_line_blame(false)
end
end,
})

191
modules/nvim/nvim.nix Normal file
View File

@@ -0,0 +1,191 @@
{
config,
lib,
pkgs,
inputs,
...
}:
# Neovim for the primary user, configured declaratively through nixvim. Options,
# keymaps, and plugin settings are typed Nix; the imperative remainder (the
# colorscheme call and the Neogit blame-toggle autocmd) lives in ./config.lua.
# Plugins come from nixpkgs — no plugin manager and no runtime cloning — and
# treesitter grammars are built by Nix, so no compiler is needed at runtime. git
# backs the git plugins; ripgrep and fd back the picker.
let
cfg = config.modules.nvim;
user = config.user.name;
in
{
options.modules.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf cfg.enable {
home-manager.sharedModules = [ inputs.nixvim.homeModules.nixvim ];
home-manager.users.${user} = hm: {
programs.nixvim = {
enable = true;
extraPackages = with pkgs; [
git # neogit and gitsigns shell out to git
ripgrep # backs the picker's live grep
fd # backs the picker's file finder
];
globals.mapleader = " ";
globals.clipboard = "osc52"; # neovim's built-in OSC 52 provider, no external binary needed
opts = {
clipboard = "unnamedplus";
number = true;
relativenumber = true;
shiftwidth = 2;
tabstop = 2;
expandtab = true;
mouse = "a";
undofile = true;
ignorecase = true;
smartcase = true;
splitright = true;
splitbelow = true;
wrap = false;
scrolloff = 8;
cursorline = true;
};
keymaps = [
{
mode = "n";
key = "<C-h>";
action = "<C-w>h";
options.desc = "Move focus left";
}
{
mode = "n";
key = "<C-j>";
action = "<C-w>j";
options.desc = "Move focus down";
}
{
mode = "n";
key = "<C-k>";
action = "<C-w>k";
options.desc = "Move focus up";
}
{
mode = "n";
key = "<C-l>";
action = "<C-w>l";
options.desc = "Move focus right";
}
{
mode = "n";
key = "<Esc>";
action = "<cmd>nohlsearch<CR>";
options.desc = "Clear search highlight";
}
{
mode = "n";
key = "<leader>e";
action = "<cmd>Oil<CR>";
options.desc = "Open file browser";
}
{
mode = "n";
key = "<leader>f";
action.__raw = "function() require('snacks').picker.files() end";
options.desc = "Find files";
}
{
mode = "n";
key = "<leader>s";
action.__raw = "function() require('snacks').picker.grep() end";
options.desc = "Search text";
}
{
mode = "n";
key = "<leader>b";
action.__raw = "function() require('snacks').picker.buffers() end";
options.desc = "Switch buffer";
}
{
mode = "n";
key = "<leader>g";
action.__raw = ''
function()
require('gitsigns').toggle_current_line_blame(true)
require('neogit').open()
end
'';
options.desc = "Open git (Neogit)";
}
];
plugins = {
gitsigns = {
enable = true;
settings.current_line_blame = false;
};
neogit.enable = true;
diffview.enable = true; # neogit's diff integration
oil = {
enable = true;
settings.view_options.show_hidden = true;
};
snacks = {
enable = true;
settings = {
picker.enabled = true;
notifier.enabled = true;
input.enabled = true;
};
};
which-key.enable = true;
render-markdown.enable = true;
treesitter = {
enable = true;
highlight.enable = true;
indent.enable = true;
grammarPackages = with hm.config.programs.nixvim.plugins.treesitter.package.builtGrammars; [
nix
lua
bash
fish
markdown
markdown_inline
rust
python
java
kotlin
c
cpp
html
css
javascript
typescript
go
];
};
};
# gbprod/nord.nvim; nixvim's colorschemes.nord is a different plugin. Set
# up in ./config.lua.
extraPlugins = [ pkgs.vimPlugins.gbprod-nord ];
extraConfigLua = builtins.readFile ./config.lua;
};
};
};
}