Nix 语言服务器与编辑器工具链:补全、格式化与静态检查

Nix 是动态类型、惰性求值的语言,写错一个属性名往往要等到求值才报错。语言服务器把补全、跳转、诊断搬进编辑器,格式化器统一风格,statix 与 deadnix 提前拦下反模式与死代码。本文对比 nil 与 nixd 的取舍、给出各编辑器的接入配置、讲清 nixd 的选项补全原理,并用 treefmt 与 pre-commit 把检查固化到 CI。

引言

写 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。

前置:Nix 语言基础 、Nix 语言深度 。

目录

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 两个主流实现

实现解析器强项弱项
nilrnix(Rust 重写)快、内存占用低、诊断稳、适合大型仓库不理解选项语义,无选项补全
nixdNix 自身的 C++ 库(libnixf/libexpr)支持选项补全、可借助求值获得更准的补全求值带来开销,配置更复杂
rnix-lsprnix历史实现已被 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 采用,事实标准
alejandra3 空格缩进、更激进的换行策略仍在用,但与 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」更多文章

  1. Store 垃圾回收与存储优化:gc root、去重与瘦身
  2. 构建沙箱与可复现性:Nix 如何隔离构建过程
  3. nixos-anywhere 远程部署:把裸机变成 NixOS