Statically-checked Lisp for Nix. A small Racket #lang that
compiles to the Nix language, paired with a checker that catches
unknown option paths, type mismatches, and enum violations at
file:line:col precision — before nix-build runs.
$ nisp-validate
modules/printing/default.rkt:6:7: unknown option services.pipwire.alsa.enable
did you mean: services.pipewire.alsa.enable or services.pipewire.pulse.enable?
modules/foo/default.rkt:9:34: type mismatch at services.openssh.enable:
expected bool, got string
hosts/laptop/configuration.rkt:11:47: type mismatch at boot.loader.systemd-boot.consoleMode:
"atuo" not in enum {"0", "1", "2", "5", "auto", "max", "keep"} — did you mean "auto"?
The reason this works isn't the parens — it's that compiling from an eager language to a lazy one buys you something Nix itself can't easily do: a walkable AST stage before emission. NixOS validates option paths and types too, but only during module evaluation, by which point the original authoring context has been discarded and errors point at the force site instead of the mistake. nisp validates earlier, against the concrete source AST, with the option schema NixOS already publishes.
Is this really statically typed? The type system being checked is NixOS's options schema, not a type system defined inside nisp — so strictly, nisp is gradually typed via external schema. Closer in spirit to TypeScript over JavaScript or
ajvover JSON than to ML. Practically: errors before runtime, at the source line, with did-you-mean. That's the bar most people mean.
The DSL itself is small and predictable — every Nix construct has a form, mappings are mechanical:
#lang nisp
(raw-file
(att
(services.openssh.enable #t)
(networking.firewall.allowedTCPPorts (lst 80 443))
(users.users.tom (att (isNormalUser #t)
(shell pkgs.zsh)
(extraGroups (lst "wheel" "docker"))))))→
{
services.openssh.enable = true;
networking.firewall.allowedTCPPorts = [ 80 443 ];
users.users.tom = {
isNormalUser = true;
shell = pkgs.zsh;
extraGroups = [ "wheel" "docker" ];
};
}Both .rkt source and emitted .nix are committed; the flake reads
ordinary Nix. You're not trapped — drop down to raw Nix anytime, or
stop using nisp by deleting the .rkt files.
-
The DSL (
#lang nisp) — every construct in Nix's expression grammar has a corresponding nisp form. -
nisp/validate(library) — AST walker, value-type inference, schema-driven type checker, Levenshtein did-you-mean. Pure functions over a parsed nisp source and a schema-table. -
bin/nisp-extract-schema— dumps an options tree (NixOS, home-manager, nix-darwin, any Nix-options-system tree) into a JSON schema cache. -
bin/nisp-validate— discovers option-path references in your.rktsources, lazy-expands submodules on demand, type-checks values, reports errors withfile:line:colprecision.--auto-fixrewrites unambiguous typos in place (best did-you-mean at distance ≤ 2 with a clear gap to the runner-up). -
bin/nisp-import— convert any existing.nixfile (or stdin) to nisp source. Built on rnix-parser (a tiny Rust shim — 100% pass rate on all 2,332 nixpkgs/nixos/modules, byte-equivalent round-trip on real-world configs). Comments preserved through the import. -
bin/nisp-schema— query the cached options schema. Three modes:nisp-schema services.openssh.enable— exact lookup (type/enum/inner)nisp-schema --children services.openssh— list all sub-options under a prefixnisp-schema --search ssh— fuzzy substring search across all 16k+ paths
All three accept
--jsonfor machine-readable output. -
bin/nisp-rename— rename an option path across every.rktin the flake. Word-boundary matching avoids partial collisions;--dry-runpreviews. Skips matches inside string literals. -
bin/nisp-lsp— Language Server Protocol implementation. Diagnostics (real-time validation), hover (option type/enum), completion (option-path autocomplete), code actions (one-click apply did-you-mean), goto-definition (jump from option path to its NixOS module source). Speaks LSP over stdio. -
bin/nisp-edit— programmatic edits to nisp source files. Source-text-preserving (uses Racket's read-syntax for AST positions, does text-level surgery). Operations:set/unsetfor(set 'PATH val)forms,enable-add/enable-removefor(enable a b c)multi-arg list manipulation. Comments and formatting outside the edit region are preserved.Editor configs:
;; Doom Emacs (lsp-mode): (after! lsp-mode (add-to-list 'lsp-language-id-configuration '(racket-mode . "nisp")) (lsp-register-client (make-lsp-client :new-connection (lsp-stdio-connection "nisp-lsp") :major-modes '(racket-mode) :server-id 'nisp-lsp)))
# Helix (languages.toml): [language-server.nisp] command = "nisp-lsp" [[language]] name = "racket" language-servers = ["nisp"]
-- Neovim (lspconfig): require'lspconfig.configs'.nisp = { default_config = { cmd = {'nisp-lsp'}, filetypes = {'racket'}, root_dir = require'lspconfig.util'.root_pattern('flake.rkt', 'flake.nix'), }, } require'lspconfig'.nisp.setup{}
The CLIs are configurable via --target, --cache-dir, --flake,
--hm-roots. nixosConfigurations.<host>.options is the default
target — override for home-manager-only, nix-darwin, or anything else.
A NixOS configuration framework built on top of all this lives separately at firnos — modules, bundles, host configs, scaffolding, the firn CLI for daily workflow.
Requires Racket 8.x. Nix is needed for nisp-validate's submodule
expansion. Cargo is needed (one-time) to build nisp-import's Rust
parser shim.
git clone https://github.com/tompassarelli/nisp
cd nisp
raco pkg install --link --auto
cd nix-parser && cargo build --release && cd .. # builds bin for nisp-importAdd nisp/bin to your PATH:
export PATH="$HOME/code/nisp/bin:$PATH"
nisp-extract-schema # cache a schema for your current host
nisp-validate # validate every .rkt in the cwd's flake
nisp-import some-config.nix # convert existing Nix to nisp| nisp | nix |
|---|---|
(att (k v) ...) |
{ k = v; ... } |
(rec-att (k v) ...) |
rec { k = v; ... } |
(lst a b c) |
[ a b c ] |
(s "lit " expr) |
"lit ${expr}" |
(ms "line1" "line2") |
''<NL> line1<NL> line2<NL>'' |
(p "./foo") |
./foo |
(let-in ([k v]...) body) |
let k = v; ... in body |
(with-do ns body) |
with ns; body |
(if-then c t e) |
if c then t else e |
(fn (a b) body) |
a: b: body |
(fn-set (a (b "default")) body) |
{ a, b ? "default" }: body |
(fn-set-rest (a b) body) |
{ a, b, ... }: body |
(fn-set@ self (a b) body) |
{ a, b } @ self: body |
(call f x y) |
f x y |
(inh a b) / (inh-from ns a b) |
inherit a b; / inherit (ns) a b; |
(not x) / (neg x) |
!x / -x |
(and a b c) / (or a b) / (impl a b) |
a && b && c / a || b / a -> b |
(== a b) / (!= a b) / (< a b) etc. |
comparison |
(+ a b c) / (- a b) / (* a b) / (/ a b) |
arithmetic (variadic) |
(get base 'a.b.c) |
base.a.b.c |
(get-or base 'a.b.c default) |
base.a.b.c or default |
(has base 'a.b.c) |
base ? a.b.c |
(assert-do cond body) |
assert cond; body |
(spath "nixpkgs") |
<nixpkgs> |
(pipe-to x f) / (pipe-from f x) |
x |> f / f <| x (Nix 2.15+) |
(merge a b) / (concat-list a b) / (cat a b) |
a // b / a ++ b / a + b |
nisp exports the AST nodes and emitter so other tools can produce or
consume nisp programs:
(require nisp)
(define expr
(att
(services.openssh.enable #t)
(networking.firewall.allowedTCPPorts (lst 80 443))))
(displayln (emit expr 0))
;; →
;; {
;; services.openssh.enable = true;
;; networking.firewall.allowedTCPPorts = [ 80 443 ];
;; }The full AST is exposed via (struct-out ...) for nix-bool,
nix-int, nix-string, nix-attrs, nix-let, nix-lambda,
nix-binop, nix-unop, nix-select, nix-has-attr, nix-assert, etc.
nisp/validate provides the building blocks for source-aware
validation: walk a parsed source, extract every option-path reference,
infer each value's static shape, and check it against your schema:
(require nisp/validate)
;; You provide schema-table: hash from path → entry hash with 't, 'inner, 'enum.
(define schema-table
(hash "services.openssh.enable" (hasheq 't "bool")
"networking.firewall.allowedTCPPorts"
(hasheq 't "listOf" 'inner (hasheq 't "unsignedInt16"))))
;; Walk syntax, validate every set/enable.
(walk-syntax (read-syntax 'src port)
(λ (stx in-hm?)
(for ([pr (in-list (extract-from-form stx))])
(define p (path-ref-path pr))
(cond
[(hash-has-key? schema-table p)
(when (path-ref-val-stx pr)
(define vt (infer-value-type (path-ref-val-stx pr)))
(define result (check-type (hash-ref schema-table p) vt))
(when (and (pair? result) (eq? (car result) 'mismatch))
(eprintf "type mismatch at ~a: ~a\n" p (cadr result))))]
[else
(eprintf "unknown option ~a (did you mean: ~a?)\n"
p (find-similar-strs p (hash-keys schema-table)))]))))Bring your own schema source — the NixOS options tree, a home-manager options dump, your own custom config schema. nisp doesn't care.
raco test tests/30 rackunit cases covering every AST node and surface form.
- firnos — a NixOS
configuration framework built on nisp, with a schema-aware validator
(
firn-validate), CLI (firn), and module/bundle conventions. If you want "Doom Emacs for NixOS config", that's the one.
See AGENTS.md for the agent-targeted context: repo layout, how to add a new DSL form / CLI / LSP capability, release process, and the don't-do list. Aimed at AI coding agents but useful for any contributor.
v0.11.0 — Language + validation library + 7 CLI tools
(nisp-validate, nisp-extract-schema, nisp-import, nisp-schema,
nisp-rename, nisp-lsp, nisp-edit). Full Nix surface coverage. 75
tests. nisp output is byte-equivalent to hand-written Nix on a
real-world ~200-module config; nisp-import handles 100% of nixpkgs
(2,332 modules) via rnix-parser. LSP provides diagnostics, hover,
completion, code actions, and goto-definition. nisp-edit supports
multi-arg (enable …) list manipulation. nisp-validate --auto-fix
rewrites unambiguous option-path typos. (svc name) shortcut mirrors
(pkg name) for service modules. New (tags …) clause inside
module-file records orthogonal facets (gpu-required, gui-only,
proprietary, …) — read by external tooling for discovery, never
emitted into the generated Nix. API may shift before v1.0 based on
usage feedback.
MIT