- Python 52.7%
- Nix 47.1%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| docs/design | ||
| examples | ||
| gloss | ||
| lib | ||
| tests | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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/
- Parse —
gloss/extract.pyreads every.nixfile 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 throughconfigandoptions, the packages it names, thelibmembers it uses. No Nix runs here. - Evaluate —
lib/default.nixis 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 ownvalueMetarather than by injecting anything, reads each evaluation's membership from itsgraph, and answers the queries of the files that evaluation includes. Every query is forced undertryEval; 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. - Render —
gloss/build.pyjoins 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,homeConfigurationsandnixOnDroidConfigurationsare discovered, and home-manager nested under any of them. A nix-on-droid evaluation's constructor keepsconfig,optionsandpkgsof the evaluation it made and drops thegraphmembership 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 readsconfig. The home-manager nested inside one is unreachable for a different reason: the option's type isnullOra submodule, andnullOrerases thevalueMetaa 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 animportslist is the case the graph answers better. A page no member names still belongs to no evaluation, which is what keepsexamples/lib/tinylib.nixthe 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.legacyPackagesandchecksare deliberately not walked: one is a package set, and the other holds each host'stoplevel, 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.packagesandself.libbuilt 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 ownoutputswithmkFlakereplaced byevalFlakeModule; that costs a second evaluation of the flake, and it is pinned to names flake-parts exposes today (evalFlakeModule,allSystems, theperSystemdebugoption). An overlay set still has no positions, because merging alazyAttrsOfrebuilds 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
configoroptions, 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
freeformTypemade 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 evalprocess rather than throwing undertryEval— a missing attribute, a type error — is isolated from outside the process and its card says so. It costs twonix evalruns 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
withresolves only when thewithis over a dotted path rooted at a module argument, and the facts have the name. Whichwitha 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 asnix flake metadatahashes it, the questions the parse produced, the evaluator's own hash, the protocol version and the system; sixteen are kept.--no-cacheasks anyway, and a flake named by--flake-expris never cached, because that is the sandboxed build and it has Nix's own cache. - The book, translation and
gloss.tomlare designed and not built. lib.mkDocsassembles the flake from its lock, so a repository with an input taken asflake = falsecannot use it;nix runhas no such limit, because it asks Nix for the flake rather than assembling one.