引言
写 Nix 的挫败感通常来自反馈太晚:属性名拼错、inherit 写错位置、with 引入的作用域遮蔽了变量——这些都不会在保存文件时提醒你,而是要等到某次 nixos-rebuild 或 nix build 求值到那一行才炸。更麻烦的是 flake.nix:inputs 是一个动态属性集,没有补全就只能靠记忆写 URL 与 follows 关系。
补齐这条反馈链需要三件东西:语言服务器(补全、跳转、诊断)、格式化器(风格统一,减少 diff 噪音)、静态检查器(在求值之前发现反模式与死代码)。本文对比 nil 与 nixd 的取舍、给出 Neovim/VS Code/Helix/Emacs 的接入配置、解释 nixd 选项补全的原理与代价,并用 treefmt 与 pre-commit 把检查固化进 nix flake check。
目录
- 1. 为什么 Nix 需要语言服务器
- 2. 生态选型:nil 与 nixd
- 3. 安装与基础配置
- 4. 编辑器接入
- 5. nixd 的选项补全
- 6. 格式化工具的选择
- 7. 静态检查:statix 与 deadnix
- 8. 用 treefmt 与 pre-commit 固化
- 9. CI 中的检查与常见坑
- 10. 速查表与一句话记忆
1. 为什么 Nix 需要语言服务器
1.1 语言的三个特征决定了反馈会迟到
① 动态类型:pkgs.foo 是否存在,只有求值那一刻才知道
② 惰性求值:写错的属性可能永远不被求值,直到某条代码路径被触发
③ 属性集即接口:flake.nix 的 inputs/outputs 是运行时结构,不是静态声明
结果:编辑器默认只提供语法高亮,拼写错误零提示。
1.2 语言服务器能补上什么
| 能力 | 价值 | 依赖 |
|---|---|---|
| 补全 | 属性名、包名、函数参数 | 需要知道 pkgs 与作用域 |
| 跳转定义 | 从调用点跳到定义处(含进 nixpkgs) | 需要解析与索引 |
| 诊断 | 未定义变量、未使用绑定、类型可疑处 | 需要语法与部分语义分析 |
| 重命名 | 跨文件改名 | 需要引用索引 |
| 悬停文档 | 选项说明与默认值 | 需要选项声明(见第 5 节) |
但要明确边界:语言服务器不会真的去构建,因此无法发现"选项组合非法"这类语义错误,
补全也可能不完整(惰性语言的可达集合无法完全静态推导)。
正确心智模型:LSP 负责"写的时候少犯错",nix flake check / nix build 负责"提交前兜底"。
记忆:Nix 反馈迟到源于三个特征(动态类型、惰性求值、属性集即接口);语言服务器负责补全/跳转/诊断/重命名,但它不做求值,因此必须与
nix flake check配合,而不是替代它。
2. 生态选型:nil 与 nixd
2.1 两个主流实现
| 实现 | 解析器 | 强项 | 弱项 |
|---|---|---|---|
| nil | rnix(Rust 重写) | 快、内存占用低、诊断稳、适合大型仓库 | 不理解选项语义,无选项补全 |
| nixd | Nix 自身的 C++ 库(libnixf/libexpr) | 支持选项补全、可借助求值获得更准的补全 | 求值带来开销,配置更复杂 |
| rnix-lsp | rnix | 历史实现 | 已被 nil 取代,不建议新用 |
怎么选:默认用 nil(启动快、诊断准,日常编辑 flake 与模块足够);
写 NixOS / Home Manager 配置时切到 nixd,为的是选项名与文档补全;
同一编辑器里不要同时挂两个,否则容易产生重复诊断。
记忆:nil 快且诊断稳(rnix 解析器,无选项语义),nixd 有选项补全(借助 Nix 自身库与求值,代价是开销);默认用 nil,写 NixOS/Home Manager 配置时用 nixd,同一编辑器里不要同时挂两个。
3. 安装与基础配置
3.1 安装与 devShell 固定版本
nix run nixpkgs#nil -- --version # 临时试用
nix profile install nixpkgs#nil nixpkgs#nixd # 装进用户 profile
# 更好的做法:把工具链与项目绑定,避免"我这儿能补全"
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [ nil nixd nixfmt-rfc-style statix deadnix treefmt ];
};
}
3.2 让 nix fmt 可用
{
formatter = pkgs.nixfmt-rfc-style; # nix fmt 使用它
}
nix fmt # 按 flake 声明的格式化器格式化全仓库
nix fmt -- --check . # 只检查不修改(CI 用)
3.3 nixd 的最小配置
// .nixd.json(放在项目根目录;expr 里的 flake 路径必须是绝对路径)
{
"nixd": {
"formatting": { "command": [ "nixfmt" ] },
"options": {
"nixos": {
"expr": "(builtins.getFlake \"/abs/path/to/flake\").nixosConfigurations.myhost.options"
}
}
}
}
记忆:安装三件套——
nix profile install nixpkgs#nil nixpkgs#nixd试用、devShell 里固定版本、formatter = pkgs.nixfmt-rfc-style让nix fmt生效;nixd 的.nixd.json里 flake 路径必须写绝对路径。
4. 编辑器接入
4.1 Neovim
-- Neovim 0.11+:用 vim.lsp.config / vim.lsp.enable
vim.lsp.config('nil', {})
vim.lsp.enable('nil')
vim.lsp.config('nixd', { settings = { nixd = { formatting = { command = { 'nixfmt' } } } } })
-- 0.10 及以前用 nvim-lspconfig:require('lspconfig').nil_ls.setup({})
4.2 VS Code
{
"nix.enableLanguageServer": true,
"nix.serverPath": "nil",
"nix.serverSettings": { "nil": { "formatting": { "command": [ "nixfmt" ] } } },
"nix.formatterPath": "nixfmt",
"editor.formatOnSave": true
}
4.3 Helix 与 Emacs
# ~/.config/helix/languages.toml
[language-server.nil]
command = "nil"
[[language]]
name = "nix"
language-servers = [ "nil" ]
formatter = { command = "nixfmt" }
auto-format = true
;; Emacs:用 eglot 接入 nil
(add-hook 'nix-mode-hook #'eglot-ensure)
(add-to-list 'eglot-server-programs '(nix-mode . ("nil")))
4.4 通用建议
- 保存即格式化(formatOnSave)配合"格式化器与 CI 一致"才有意义
- 大仓库里把 LSP 的索引范围限制在工作区,避免每次打开都全量扫描
- 改动 flake.lock 后重启语言服务器,否则补全仍基于旧输入
记忆:编辑器接入的要点是「服务器路径 + 格式化器命令 + 保存即格式化」三件事;Neovim 用
vim.lsp.config/vim.lsp.enable,Helix 用languages.toml,VS Code 用 Nix IDE 扩展;改完 flake.lock 记得重启服务器。
5. nixd 的选项补全
5.1 为什么需要「求值」才能补全
NixOS 的 option 不是语法结构,而是模块系统在求值过程中构建出来的:
每个模块通过 options.xxx = lib.mkOption {...} 注册,
最终的选项集合要等模块合并(merge)完成才知道。
因此补全 option 名,必须先对一个真实的 nixosConfiguration 求值,取出它的 options 属性集。
5.2 配置方式
{
"nixd": {
"options": {
"nixos": {
"expr": "(builtins.getFlake \"/home/me/infra\").nixosConfigurations.web01.options"
},
"home-manager": {
"expr": "(builtins.getFlake \"/home/me/infra\").homeConfigurations.\"me@laptop\".options"
}
}
}
}
配置要点:
- 键名(nixos / home-manager)是逻辑分组名,需与编辑器侧约定一致
- expr 必须能求值成功,flake 路径用绝对路径
- 只想补全某个子树时,可以把 expr 收窄到子属性(如 .options.services)
5.3 代价与分工
开销:首次打开时求值一次,nixpkgs 规模下可能需要数秒;
flake.lock 更新后需要重新求值。
风险:expr 求值失败时补全会静默失效(表现为"没有补全");
排查方法:在终端里手工执行同一表达式,确认它能否求值。
分工:nil 做语法级补全(局部变量、函数参数、属性访问),快且覆盖日常;
nixd 做语义级补全(options、pkgs 属性),慢但写配置时价值最大。
记忆:nixd 的选项补全依赖对真实配置求值取出 options 属性集,因此
.nixd.json里的expr必须指向一个能求值的 nixosConfigurations/homeConfigurations,路径用绝对路径;代价是首次求值数秒、expr 失败则静默无补全。
6. 格式化工具的选择
6.1 三种格式化器
| 工具 | 风格 | 现状 |
|---|---|---|
| nixfmt-rfc-style | 官方 RFC 166 风格,2 空格缩进 | nixpkgs 采用,事实标准 |
| alejandra | 3 空格缩进、更激进的换行策略 | 仍在用,但与 nixfmt 风格差异明显 |
| nixpkgs-fmt | 早期社区风格 | 已不推荐新项目使用 |
6.2 迁移的现实成本
切换格式化器会让整仓库产生一次巨大的 diff:
- 单独一个 commit 做纯格式化,并在 .git-blame-ignore-revs 中登记
- 之后再改逻辑,避免格式化与逻辑改动混在一起难以 review
- 向 nixpkgs 提交或 fork nixpkgs 时,直接用 nixfmt-rfc-style 对齐上游
多语言仓库里用 treefmt 统一入口,避免为每种语言记一条命令。
记忆:格式化器首选
nixfmt-rfc-style(RFC 166,nixpkgs 事实标准);切换格式化器要单独一个纯格式化 commit 并登记.git-blame-ignore-revs;在 flake 里用formatter = pkgs.nixfmt-rfc-style让nix fmt生效。
7. 静态检查:statix 与 deadnix
7.1 statix:反模式检查
statix check . # 报告问题(退出码非零表示有发现)
statix fix . # 自动修复可修复项
常见检出项:
- 手写 inherit 的等价写法((a = a;) → inherit a;)
- with 使用导致的隐式作用域(可读性差、易遮蔽)
- 冗余的 rec、可简化的条件表达式
7.2 deadnix:死代码检查
deadnix . # 报告未使用的 let 绑定与函数参数
deadnix --edit . # 自动删除(建议先 review 差异)
典型检出:let 中定义但从未引用的变量、函数形参中未使用的参数
(如 { lib, pkgs, ... }: 里未用的 pkgs)。
注意:惰性求值下"未使用"是静态判断,若某绑定通过 with 或动态属性访问被使用,
可能误报——因此自动修复前必须看 diff。
编辑器内联诊断反馈更快,但规则集可能与命令行不完全一致;
最终以 CI 的退出码为唯一标准最稳妥。
记忆:statix 查反模式(手写 inherit、with 滥用、冗余 rec)、deadnix 查死代码(未使用 let 绑定与形参);两者都支持自动修复,但惰性求值下静态判断可能误报,
--edit/fix前必须看 diff,最终以 CI 退出码为准。
8. 用 treefmt 与 pre-commit 固化
8.1 treefmt-nix:统一格式化入口
# flake.nix 片段
{
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
outputs = { self, nixpkgs, treefmt-nix, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs.nixfmt.enable = true;
programs.statix.enable = true;
programs.deadnix.enable = true;
};
in {
formatter = treefmtEval.config.build.wrapper;
checks.formatting = treefmtEval.config.build.check self;
};
}
关键点:projectRootFile 决定 treefmt 从哪里开始向上找根;
formatter 输出让 `nix fmt` 走 treefmt;
checks.formatting 让 `nix flake check` 包含格式检查 → CI 免费获得门禁。
8.2 pre-commit-hooks.nix:提交前拦截
{
pre-commit-check = pre-commit-hooks.lib.x86_64-linux.run {
src = ./.;
hooks = {
nixfmt-rfc-style.enable = true;
statix.enable = true;
deadnix.enable = true;
};
};
}
pre-commit install # 在 devShell 的 shellHook 中执行,安装 git 钩子
8.3 三层分工
| 手段 | 触发时机 | 适合拦什么 |
|---|---|---|
| pre-commit 钩子 | 本地 git commit | 快速、单文件的检查 |
| treefmt check(flake checks) | CI / 手动 | 全仓库一致性,作为最终门禁 |
| 编辑器内联诊断 | 编辑时 | 即时反馈 |
原则是:本地尽量早发现,CI 作为不可绕过的最后一道。
记忆:固化检查两层——treefmt-nix 提供统一入口(
formatter+checks.formatting,让nix flake check免费带上格式门禁),pre-commit-hooks.nix 在git commit时拦截;原则是本地尽早发现、CI 作为不可绕过的最后一道。
9. CI 中的检查与常见坑
9.1 CI 步骤骨架
nix build .#checks.x86_64-linux.formatting # 先跑快的格式检查
nix flake check -L # 再跑完整检查
注意:`nix flake check` 会求值所有输出,在大型 flake 上较慢;
因此把格式检查单独抽成一个 check,在流水线里优先执行。
9.2 常见坑
① flake 输入的假报错:语言服务器无法解析 inputs.foo 的真实路径,
于是把 inputs.foo.bar 标成未定义——属于工具限制,不是代码错误。
② nixd 求值开销:在巨大 flake 上首次打开要数秒,可收窄 options.expr 的范围。
③ 格式化器不一致:本地 alejandra、CI nixfmt,导致"本地过了 CI 不过";
解决:把格式化器版本与命令写进 devShell,所有人用同一个。
④ flake.lock 变更后补全过期:重启语言服务器。
⑤ statix 的 with 规则争议:团队若不用 with,可在配置里禁掉该规则。
⑥ deadnix 误删:惰性求值下静态判断可能误报,自动修复前看 diff。
9.3 落地清单
☐ devShell 固定 nil/nixd/nixfmt/statix/deadnix 版本
☐ flake 声明 formatter,`nix fmt` 可用
☐ checks.formatting 接入 treefmt,`nix flake check` 覆盖格式
☐ pre-commit 钩子在本地拦截
☐ CI 先跑快的格式检查,再跑完整 flake check
☐ .git-blame-ignore-revs 登记纯格式化 commit
记忆:CI 骨架是「先跑快的格式检查、再跑完整
nix flake check」;六个常见坑是 flake 输入的假报错、nixd 求值开销、本地与 CI 格式化器不一致、flake.lock 变更后补全过期、statix 的 with 规则争议、deadnix 误删。
10. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| 补全与诊断 | nil(快)或 nixd(选项补全) |
| 格式化 | nixfmt-rfc-style,nix fmt 统一入口 |
| 反模式检查 | statix check . / statix fix . |
| 死代码检查 | deadnix . / deadnix --edit . |
| 固化 | treefmt-nix + pre-commit-hooks.nix |
| 门禁 | nix flake check 含 checks.formatting |
一句话记忆:Nix 的反馈迟到源于动态类型、惰性求值与「属性集即接口」,所以要在编辑器里补上语言服务器——nil 快且诊断稳、nixd 靠对真实配置求值取出 options 从而提供选项补全;格式化统一用 nixfmt-rfc-style(切换时单独一个纯格式化 commit 并登记 .git-blame-ignore-revs),反模式与死代码交给 statix 与 deadnix(自动修复前看 diff);最后用 treefmt-nix 的 checks.formatting 与 pre-commit 钩子把「本地尽早发现、CI 不可绕过」两层固化下来,注意 flake 输入被标未定义属于工具限制而非代码错误。
延伸阅读
- Nix 语言基础
- Nix 语言深度
- Flake 模板与项目脚手架
- nixpkgs 贡献与维护
- 开发者体验与自助门户 — 工具链一致性的工程实践
- Git 工作流规范 — 钩子、提交规范与 blame 治理
- nil 项目仓库
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。