No description
  • Python 52.7%
  • Nix 47.1%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium 4e83c66f45
Do not ask again about a tree already answered
The stress target was at 279 seconds and sixteen `nix eval` runs, and the
runs are the whole of it: isolating an abort is cheap in runs now, but every
run still evaluates the flake from the start, because a dead process leaves
nothing behind and `nix eval` has no evaluation cache across processes.

The first thing measured was where a run goes, since the obvious suspect was
the second evaluation the flake-parts adapter costs. It is not: the probe
that forces discovery and membership and asks no query answers in 3.6
seconds, so seventeen of a run's twenty seconds are the four thousand facts.
Making discovery cheaper buys nothing, and neither does answering `perSystem`
for fewer systems, since all three carry the same eleven files. The lever is
not to run at all.

The key is the tree as Nix resolves it rather than a hash of the pages gloss
read, because the evaluation reads `flake.lock` and every non-Nix file too
and a key that covered only the pages would keep answering after one of them
changed. Added to it: the questions the driver would send, the evaluator
library's hash, the protocol version and the system.

Cold 279s, warm 2.4s on the stress target; 13.8s and 2.3s on the tutorial.
The run bound is untouched, because a miss runs what it ran before. A flake
named by an expression is never cached — that is the sandboxed build, which
is a derivation and has Nix's cache — and sixteen answers are kept, because
a rebase asks a new question of a new tree every time.

Assisted-by: Claude Code:Fable 5.1
2026-09-15 11:54:52 +09:00
docs/design Do not ask again about a tree already answered 2026-09-15 11:54:52 +09:00
examples Tell a package file from a module by how its caller has to call it 2026-09-15 11:04:29 +09:00
gloss Do not ask again about a tree already answered 2026-09-15 11:54:52 +09:00
lib Give an attribute a freeform type made legal a value 2026-09-15 11:45:29 +09:00
tests Give an attribute a freeform type made legal a value 2026-09-15 11:45:29 +09:00
.gitignore Add a working pipeline from .nix source to a document with hover and go-to-definition 2026-09-10 01:37:28 +09:00
flake.lock Add a working pipeline from .nix source to a document with hover and go-to-definition 2026-09-10 01:37:28 +09:00
flake.nix Export the sandboxed documentation build as a function 2026-09-15 10:05:54 +09:00
README.md Do not ask again about a tree already answered 2026-09-15 11:54:52 +09:00

gloss

Literate programming for Nix with the .nix file as the only source of truth. Prose lives in RFC 145 doc comments, and a renderer produces a document in which every identifier carries its evaluated value and a link to its definition.

Nothing produces Nix. The document is a view.

Using it

gloss is run against a repository, and that repository needs nothing added to it. Given a flake with a nixosConfigurations entry:

nix run git+https://git.natsukium.com/natsukium/gloss#gloss -- build --flake . --out site
open site/index.html

Every .nix file under the tree becomes a page, following the directory tree, with a page per directory listing what is in it. There is no list of documents to maintain and no configuration file.

The fixture in examples/ is the flake a NixOS tutorial ends with — a flake.nix, a configuration.nix, the hardware-configuration.nix the installer wrote, and home-manager as a NixOS module with a home.nix. It is the target every rule has to work on:

nix run .#gloss -- build --flake examples --out site

How it works

Three stages and two JSON contracts, written down in docs/design/contracts.md.

sources ─parse─▶ syntax.json ─queries─▶ nix eval ─▶ facts.json ─join+render─▶ site/
  1. Parse — gloss/extract.py reads every .nix file under the tree with tree-sitter and emits the segment partition, the highlight spans, the occurrence of every identifier resolved to a local binding or to a module argument, and the queries that follow: the option paths the file reaches through config and options, the packages it names, the lib members it uses. No Nix runs here.
  2. Evaluate — lib/default.nix is a Nix library of a flake and a query set. It finds the evaluations from the standard flake outputs, reaches the nested ones through the module system's own valueMeta rather than by injecting anything, reads each evaluation's membership from its graph, and answers the queries of the files that evaluation includes. Every query is forced under tryEval; a missing attribute or a type error is not catchable there, so the evaluator names each query on stderr before forcing it and the tool, from outside the dead process, reads the last name it wrote.
  3. Render — gloss/build.py joins the two over one site-wide symbol table, groups each field of a card by distinct value with the evaluations that produce it, and writes static HTML. Hover cards and definition links are baked in; the page needs no server.

The tool runs the three in order and calls nix eval itself. It has to: the queries come from a parse, and feeding a parse result into an evaluation from inside a derivation would be import-from-derivation.

Two ways to read one file

By default a document is code with prose beside it: every documented node is a row, prose on the left, source on the right.

A chapter comment can instead take over and pull the code in. A line that is nothing but <<name>> inside a /*! comment names a documented node — by attribute path, or by the slug of its heading — and the renderer splices that node in at that point, so the document's order has nothing to do with the file's. It is noweb's reference with the direction reversed: the code on disk stays whole, and the prose decides what to show and when. An ambiguous or missing name fails the build.

examples/dotfiles/ghq.nix is written that way; the others are not.

Either way the document shows the whole file. A stretch of source that no doc comment covers — a module's argument list, a let that only exists because Nix needs it to, the closing brace — is a row with code and no prose beside it. Self-evident code does not have to be explained, but it does have to be visible: otherwise the document is a selection and nothing in it says so.

Many evaluations, one file

A file takes part in any number of evaluations. configuration.nix is in the NixOS evaluation and, because the definition of home-manager.users.alice is itself a module of the nested evaluation, in that one too. Every fact is recorded per evaluation and the card groups each field by distinct value, naming the evaluations behind each group, so a page never has to pretend that a path means one thing everywhere.

A submodule instance is an evaluation too. my.programs.ghq.root is answered by the evaluation that submodule instance ran, and is recorded under it — nixos/tutorial/home-manager.users/alice/my.programs.ghq — so a card for an option inside a submodule names the instance that produced each value rather than the host that contains it.

The flake's outputs are evaluations of the same kind. A binding outside a module has a value only relative to a root, and each output gloss knows — self itself, lib, overlays, packages.<system> and the rest — is one, so flake.nix and the files its outputs are assembled from get values the same way a module does, and a binding two systems evaluate differently shows two groups.

A file no root and no evaluation reaches says so at the top of its page and shows local resolution and externals alone. examples/lib/tinylib.nix is that file: nothing in the tutorial flake calls it, and gloss does not invent a root for it.

Building the documentation as a derivation

nix build .#docs && open result/index.html

The same three stages, inside one sandboxed build. nix eval runs there too, against a store of the build's own, so nothing is imported from a derivation and no experimental feature beyond nix-command is needed. nix flake check builds it, which is what makes a dangling reference a build failure rather than a stale link.

A sandbox cannot fetch, so builtins.getFlake is no use inside one: the flake is assembled from its lock and the input store paths the derivation already depends on (lib/compat.nix).

The builder is exported, so any repository gets the same output for itself:

{
  inputs.gloss.url = "git+https://git.natsukium.com/natsukium/gloss";

  outputs = { self, nixpkgs, gloss, ... }: {
    packages.x86_64-linux.docs = gloss.lib.mkDocs {
      flake = self;
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    };
    checks.x86_64-linux.docs = self.packages.x86_64-linux.docs;
  };
}

flake is anything with an outPath and an inputs, which self is; exclude takes paths not to page and name renames the derivation. What the repository has to satisfy: every node of its lock is a plain source with a flake.nix, so an input taken with flake = false breaks the assembly; its outputs performs no import-from-derivation; and the build has nix and gloss on its path and nothing else, so anything the evaluation would fetch is a failure rather than a download.

This flake's own packages.docs is the same function applied to the fixture under examples/, so nix flake check exercises the builder consumers use.

Tests

nix flake check

Golden JSON for the parse stage over the fixture, the evaluate stage against a plain lib.evalModules fixture with no home-manager in it, and the driver against a fixture whose option default reaches a missing attribute, which is the failure tryEval cannot catch. The last runs nix eval inside the sandbox, which is also how packages.docs works. The suite runs in seconds; tests/update.sh accepts the current output as golden.

Known limits

The design notes are under docs/design/; architecture.md is authoritative and its last section says exactly where this implementation is narrower than it. The ones a reader meets first:

  • nixosConfigurations, darwinConfigurations, homeConfigurations and nixOnDroidConfigurations are discovered, and home-manager nested under any of them. A nix-on-droid evaluation's constructor keeps config, options and pkgs of the evaluation it made and drops the graph membership is read from, so its files are placed by the fallback instead: a file a declaration position or a definition location names is a member, which misses a module that only reads config. The home-manager nested inside one is unreachable for a different reason: the option's type is nullOr a submodule, and nullOr erases the valueMeta a nested evaluation is read through.
  • A page nothing merges is placed by the path that names it. Every path a file writes that resolves to a page of the tree is an import edge — import ./x.nix, callPackage ./x, a path in any list — and an evaluation's files are closed under those edges, stopping at any page some evaluation's graph already names, because an imports list is the case the graph answers better. A page no member names still belongs to no evaluation, which is what keeps examples/lib/tinylib.nix the page with local resolution and nothing else.
  • A binding outside a module has a value only where a flake output reaches it. The walk is a schema — lib, overlays, packages.<system>, devShells, apps, the module registries and the configuration classes — and it does not descend into arbitrary sets, so a binding under an output gloss does not know, or one nothing in the flake calls at all, keeps its position and no value. legacyPackages and checks are deliberately not walked: one is a package set, and the other holds each host's toplevel, whose name cannot be rendered without instantiating the system.
  • The walk finds only outputs the flake writes as attributes. A repository that assembles its outputs with flake-parts has self.packages and self.lib built by the module system, and their attributes have no position in the repository, so nothing under them is reached. The flake-parts evaluation itself is reached instead, by applying the flake's own outputs with mkFlake replaced by evalFlakeModule; that costs a second evaluation of the flake, and it is pinned to names flake-parts exposes today (evalFlakeModule, allSystems, the perSystem debug option). An overlay set still has no positions, because merging a lazyAttrsOf rebuilds the attribute set.
  • A package file is placed by its derivation's meta.position, which is where nixpkgs saw the derivation's own name written. A package built through a wrapper — runCommand, writeScriptBin, .override — has that name written inside nixpkgs, so the position points into an input and places nothing.
  • Whether a file is a module is read off its head. The module system calls a module with arguments it did not ask for and reads a set of option definitions back, so a module is a file whose head is an attribute-set pattern that is open or names config or options, and whose body is an attribute set. { stdenv, hello }: stdenv.mkDerivation { … } is neither, and its bindings are attributes rather than option definitions. Both halves are shape tests: a module written { pkgs }: { … } is read as a plain attribute set, and a library written { lib, ... }: { f = …; } is read as a module.
  • An attribute a freeformType made legal has a value and nothing else — no type, no description, no declaration, because none exists — and it is filed under the evaluation that was asked rather than under a submodule instance, since the option tree is what names instances and this is a path the option tree does not have.
  • A path with a computed component — services.${name}.enable — is not a query, and the reference resolves to nothing.
  • A query that aborts the nix eval process rather than throwing under tryEval — a missing attribute, a type error — is isolated from outside the process and its card says so. It costs two nix eval runs per distinct aborting symbol, and a query whose abort the evaluator's marker does not attribute falls back to halving the set.
  • A name introduced by with resolves only when the with is over a dotted path rooted at a module argument, and the facts have the name. Which with a name really comes from is a question only evaluation answers, so the innermost one the facts account for wins.
  • Derivations render by name rather than by store path: a page has to render even when the derivation it mentions cannot be instantiated on this system.
  • A tree already answered is not asked again. The answers are kept under $XDG_CACHE_HOME/gloss, keyed by the tree as nix flake metadata hashes it, the questions the parse produced, the evaluator's own hash, the protocol version and the system; sixteen are kept. --no-cache asks anyway, and a flake named by --flake-expr is never cached, because that is the sandboxed build and it has Nix's own cache.
  • The book, translation and gloss.toml are designed and not built.
  • lib.mkDocs assembles the flake from its lock, so a repository with an input taken as flake = false cannot use it; nix run has no such limit, because it asks Nix for the flake rather than assembling one.