Files
nixos-config/modules/nvim.nix
T
daniil-bergandClaude Opus 5 0f7f1dade2 docs(nvim): record why this is hand-rolled, not a distro
State the reason for nixvim over LazyVim/NvChad — lazy.nvim fetches and updates
plugins at runtime, putting the editor outside the flake — along with what that
costs in ergonomics, and that borrowing a distro's keymaps and defaults into
this file remains open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015D4NsszUG7hy9ghKZ4nfhm
2026-09-15 13:31:06 +02:00

353 lines
16 KiB
Nix

# Neovim, configured declaratively via nixvim. The editor — plugins, LSP,
# completion, keymaps — is defined here and pinned by the flake lock; nothing is
# downloaded at runtime. The nixvim home-manager module that provides
# `programs.nixvim` is imported for consumers by `homeModules.default` in
# flake.nix, so this file only sets options.
#
# Why this rather than a distro (LazyVim, NvChad, AstroNvim): those drive
# lazy.nvim as a package manager, which git-clones plugins into
# ~/.local/share/nvim on first launch and updates them in place. The editor's
# actual contents would then live outside the flake — unpinned, unreproducible,
# and invisible to a rollback.
#
# That choice has a price, and it is paid in ergonomics. A distro ships hundreds
# of defaults other people already sanded smooth; this config does not. Rough
# edges surface one at a time, mid-task, and each gets fixed by hand here (see
# the `<leader>bd` and `<leader>Q` keymaps for two such fixes). Anyone extending
# this should expect that cost rather than be surprised by it.
#
# The middle path stays open: the objection is to lazy.nvim as a *runtime*
# package manager, not to a distro's ergonomics. Individual keymaps, option
# sets, and plugin configurations can be lifted from LazyVim & co. into this
# file, and nixvim installs any plugin set declaratively. Borrow freely — just
# keep the installation declarative.
#
# Consumer knobs (the option-with-default contract): `local.nvim.enable` to opt
# out wholesale, `local.nvim.colorscheme` to swap theme in one line, and
# `local.nvim.lspServers` to reshape the language set. Everything else is
# opinionated — but since nixvim exposes every plugin as an option, a consumer can
# still override any `programs.nixvim.plugins.*` directly from their own config.
{ config, lib, pkgs, ... }:
let
cfg = config.local.nvim;
in
{
options.local.nvim = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the nixvim-based Neovim setup.";
};
colorscheme = lib.mkOption {
type = lib.types.str;
default = "catppuccin";
example = "tokyonight";
description = ''
nixvim colorscheme module to enable (the attr under `colorschemes.*`,
e.g. "catppuccin", "tokyonight", "gruvbox"). Catppuccin is tuned below
(mocha flavour); for another scheme set its `colorschemes.<name>.settings`
from your own config if you want to tweak it.
'';
};
lspServers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"nixd" # Nix — this repo's own language, so a sensible public baseline
"bashls" # Shell — likewise generic enough to default on
];
example = [ "nixd" "bashls" "rust_analyzer" "basedpyright" ];
description = ''
lspconfig server names to enable under `plugins.lsp.servers.<name>`.
Each must exist in nixvim's server list and its binary in nixpkgs. The
public default is deliberately minimal (Nix + shell); a consumer sets its
own language set here — and any per-server tuning a pick needs (e.g.
rust_analyzer's `installRustc`) via `programs.nixvim.plugins.lsp.servers`.
Note: standalone Lua wants "lua_ls", but Lua embedded in Nix strings is
invisible to any LSP, so it is not needed just to edit this config.
'';
};
};
config = lib.mkIf cfg.enable {
programs.nixvim = {
enable = true;
# Point nixvim at our (followed) nixpkgs instead of the rev it pins. Both
# track nixos-26.05, so the version skew nixvim warns about does not arise,
# and this keeps a single nixpkgs in the closure. Setting it explicitly is
# also what silences that warning.
nixpkgs.source = pkgs.path;
# System clipboard on Wayland: yanks go to / puts read from wl-clipboard,
# so copy/paste crosses the nvim boundary. Enabling the provider pulls the
# wl-clipboard binaries in as a dependency.
clipboard = {
register = "unnamedplus";
providers.wl-copy.enable = true;
};
globals = {
mapleader = " ";
maplocalleader = " ";
};
opts = {
number = true;
relativenumber = true; # relative line numbers for quick j/k motions
signcolumn = "yes"; # always show the gutter so text doesn't jump
cursorline = true;
termguicolors = true; # 24-bit colour, required by catppuccin & co.
scrolloff = 8; # keep 8 lines of context around the cursor
expandtab = true; # spaces, not tabs
shiftwidth = 4;
tabstop = 4;
smartindent = true;
ignorecase = true; # case-insensitive search…
smartcase = true; # …unless the query has a capital
undofile = true; # persistent undo across sessions
splitright = true;
splitbelow = true;
# What :mksession stores (persistence.nvim). Drops "blank" and "terminal"
# from nvim's default so empty/plugin windows (neo-tree, toggleterm) aren't
# serialised — they restore as broken empty splits otherwise. Adds
# "globals", which some plugins rely on. Pairs with the PersistenceSavePre
# neo-tree close below.
sessionoptions = "buffers,curdir,folds,globals,tabpages,winsize";
};
# Theme. Swap via local.nvim.colorscheme; catppuccin gets the mocha flavour.
# mkMerge keeps the computed `${cfg.colorscheme}` key from colliding with the
# literal `catppuccin` one when they happen to be the same attr.
colorschemes = lib.mkMerge [
{ ${cfg.colorscheme}.enable = true; }
(lib.mkIf (cfg.colorscheme == "catppuccin") {
catppuccin.settings.flavour = "mocha";
})
];
plugins = {
# Shared icon set for the tree, statusline, completion menu.
web-devicons.enable = true;
# Syntax via tree-sitter. The default grammar set is broad, so most
# languages get highlighting without an LSP or any per-language wiring.
treesitter = {
enable = true;
settings = {
highlight.enable = true;
indent.enable = true;
};
};
# LSP. Servers come from local.nvim.lspServers; blink-cmp feeds its
# completion capabilities in automatically (setupLspCapabilities).
lsp = {
enable = true;
servers = lib.genAttrs cfg.lspServers (_: { enable = true; });
keymaps = {
silent = true;
lspBuf = {
gd = "definition";
gD = "declaration";
K = "hover";
"<leader>rn" = "rename";
"<leader>ca" = "code_action";
};
# gr/gi (references, implementations) are wired to Telescope pickers in
# the global keymaps below instead — fuzzy + preview browsing beats the
# quickfix list that vim.lsp.buf.references would populate. gd stays a
# direct jump here (usually one target; Ctrl-o returns).
diagnostic = {
# Idiomatic vim bracket pair for stepping a list, kept as the public
# default. A consumer whose layout makes brackets awkward (they need
# AltGr on QWERTZ) can add comfortable aliases from their own config:
# this option is attrsOf, so extra keys merge in without conflict.
"[d" = "goto_prev";
"]d" = "goto_next";
"<leader>ds" = "open_float"; # show the diagnostic under the cursor
};
};
};
# Completion. blink.cmp — faster and lighter to configure than nvim-cmp.
blink-cmp = {
enable = true;
settings = {
# "enter" preset: <CR> accepts, Up/Down and C-n/C-p move, Tab is
# left to snippet jumping. Custom keys below merge over the preset.
keymap = {
preset = "enter";
# Vim's own ins-completion confirm key, kept alongside <CR>.
"<C-y>" = [ "select_and_accept" "fallback" ];
# Dismiss the menu and undo the auto_insert preview, staying in
# insert mode. `cancel` rather than the preset's `hide`, which
# would leave the previewed text in the buffer without the
# additionalTextEdits (imports) and snippet expansion that a real
# accept applies — a degraded accept nobody wants. This also
# matches builtin ins-completion, where C-e restores the typed
# text. With no menu open both keys fall back to their native
# meaning (Esc leaves insert mode).
"<C-e>" = [ "cancel" "fallback" ];
"<Esc>" = [ "cancel" "fallback" ];
};
appearance.nerd_font_variant = "mono";
sources.default = [ "lsp" "path" "snippets" "buffer" ];
completion = {
documentation.auto_show = true;
# No item is selected until one is picked with Down/C-n, so <CR>
# inserts a newline unless a completion was deliberately chosen.
list.selection.preselect = false;
};
};
};
# Fuzzy finder — files, live grep, open buffers (buffer switching lives
# here rather than a bufferline; see the S-h/S-l binds below too).
telescope.enable = true;
# File tree.
neo-tree.enable = true;
# Statusline, NvChad-ish blocks: powerline separators, one global bar,
# theme following the colorscheme.
lualine = {
enable = true;
settings.options = {
theme = "auto";
globalstatus = true;
section_separators = { left = ""; right = ""; };
component_separators = { left = ""; right = ""; };
};
};
# Inline colour swatches for #rrggbb / colour names.
colorizer.enable = true;
# Git signs in the gutter.
gitsigns.enable = true;
# Session persistence: auto-saves a session per project directory into XDG
# state, restorable later. <leader>S restores the current dir's session; a
# consumer wanting auto-restore on a bare `nvim` adds a VimEnter autocmd.
persistence.enable = true;
# Terminal split (shares nvim's cwd), toggled via <leader>t{h,v} below.
# No open_mapping — the leader binds cover both directions; size applies
# to whichever split is open (rows when horizontal, cols when vertical).
toggleterm = {
enable = true;
settings = {
direction = "horizontal";
size = 15;
};
};
# Popup of the pending keybinds after leader — a live, always-accurate
# cheatsheet. The group labels name each leader subtree so the popup reads
# by category; <leader>? opens the full list on demand (keymaps below).
which-key = {
enable = true;
settings.spec = [
{ __unkeyed-1 = "<leader>f"; group = "Find (Telescope)"; }
{ __unkeyed-1 = "<leader>d"; group = "Diagnostics"; }
{ __unkeyed-1 = "<leader>b"; group = "Buffer"; }
{ __unkeyed-1 = "<leader>c"; group = "Code"; }
{ __unkeyed-1 = "<leader>r"; group = "Refactor"; }
{ __unkeyed-1 = "<leader>s"; group = "Search"; }
{ __unkeyed-1 = "<leader>t"; group = "Terminal"; }
];
};
};
# Moodle/Mustache templates. nvim detects `.mustache` as filetype mustache
# but ships no syntax file for it, so the buffer renders as plain text.
# Tree-sitter is not an option here: the Handlebars grammar (glimmer)
# rejects mustache's argument-less sections, `{{{unescaped}}}`, partials
# and Moodle's `{{< parent }}` inheritance, and a single error node drops
# highlighting for the rest of the file. This plugin carries the syntax,
# indent and ftdetect scripts. nixpkgs marks it unfree only because
# upstream ships no license metadata; `allowUnfree` in modules/nixos.nix
# already covers it.
extraPlugins = [ pkgs.vimPlugins.vim-mustache-handlebars ];
# Global (non-LSP) keymaps. Deliberately small — a starting point to grow.
keymaps = [
{ mode = "n"; key = "<leader>w"; action = "<cmd>w<cr>"; options.desc = "Save"; }
{ mode = "n"; key = "<leader>q"; action = "<cmd>q<cr>"; options.desc = "Quit window"; }
# `confirm` prompts Save/Discard/Cancel per modified buffer rather than
# aborting with E37, so quitting never dead-ends and never writes silently.
{ mode = "n"; key = "<leader>Q"; action = "<cmd>confirm qa<cr>"; options.desc = "Quit all (prompt to save)"; }
# Restore the saved session for the current directory (persistence.nvim).
{ mode = "n"; key = "<leader>S"; action.__raw = "function() require('persistence').load() end"; options.desc = "Restore session"; }
{ mode = "n"; key = "<leader>e"; action = "<cmd>Neotree toggle<cr>"; options.desc = "File tree"; }
{ mode = "n"; key = "<Esc>"; action = "<cmd>nohlsearch<cr>"; }
# Telescope
{ mode = "n"; key = "<leader>ff"; action = "<cmd>Telescope find_files<cr>"; options.desc = "Find files"; }
{ mode = "n"; key = "<leader>fg"; action = "<cmd>Telescope live_grep<cr>"; options.desc = "Live grep"; }
{ mode = "n"; key = "<leader>fb"; action = "<cmd>Telescope buffers<cr>"; options.desc = "Buffers"; }
{ mode = "n"; key = "<leader>fh"; action = "<cmd>Telescope help_tags<cr>"; options.desc = "Help tags"; }
# LSP navigation via Telescope pickers (fuzzy + preview) for the
# many-result lookups; gd/gD stay direct jumps (plugins.lsp.keymaps above).
{ mode = "n"; key = "gr"; action = "<cmd>Telescope lsp_references<cr>"; options.desc = "References"; }
{ mode = "n"; key = "gi"; action = "<cmd>Telescope lsp_implementations<cr>"; options.desc = "Implementations"; }
# Buffer navigation (no bufferline: cycle with Shift-h/l, list via <leader>fb)
{ mode = "n"; key = "<S-l>"; action = "<cmd>bnext<cr>"; options.desc = "Next buffer"; }
{ mode = "n"; key = "<S-h>"; action = "<cmd>bprevious<cr>"; options.desc = "Prev buffer"; }
# Plain `:bdelete` closes the window when no other listed buffer is left
# for it to show, which with neo-tree open leaves a full-width tree.
# Point the window at another buffer (or a fresh empty one) first, so the
# layout survives; `confirm` prompts rather than failing when modified.
{
mode = "n";
key = "<leader>bd";
action.__raw = ''
function()
local cur = vim.api.nvim_get_current_buf()
local listed = vim.tbl_filter(
function(b) return vim.bo[b].buflisted end,
vim.api.nvim_list_bufs()
)
if #listed > 1 then vim.cmd('bprevious') else vim.cmd('enew') end
vim.cmd('confirm bdelete ' .. cur)
end
'';
options.desc = "Close buffer";
}
# Terminal: toggle a horizontal or vertical split (toggleterm). Same key
# hides it again. From inside the terminal, <C-w> drops to normal mode and
# runs the window command, so leaving is one chord (no <C-\><C-n> dance).
{ mode = "n"; key = "<leader>th"; action = "<cmd>ToggleTerm direction=horizontal<cr>"; options.desc = "Terminal (horizontal)"; }
{ mode = "n"; key = "<leader>tv"; action = "<cmd>ToggleTerm direction=vertical size=80<cr>"; options.desc = "Terminal (vertical)"; }
{ mode = "t"; key = "<C-w>"; action = ''<C-\><C-n><C-w>''; options.desc = "Leave terminal to window"; }
# Cheatsheet: <leader>? pops the full which-key list; <leader>sk fuzzy-
# searches every active mapping via Telescope (catches non-leader binds too).
{ mode = "n"; key = "<leader>?"; action = "<cmd>WhichKey<cr>"; options.desc = "All keybinds (which-key)"; }
{ mode = "n"; key = "<leader>sk"; action = "<cmd>Telescope keymaps<cr>"; options.desc = "Search keymaps"; }
];
# persistence saves the window layout via :mksession, but neo-tree's special
# buffer doesn't serialise — a saved tree window restores as a blank split.
# Close neo-tree just before a session is written so the layout stays clean.
autoCmd = [
{
event = "User";
pattern = "PersistenceSavePre";
callback.__raw = ''function() pcall(vim.cmd, "Neotree close") end'';
}
];
};
};
}