Fil-C on Nix

Filnix wiki

nanofil/docs/dterm.md

dterm: nanofil's source as pages, and documentation as relations

dterm draws nanofil's Prolog as web pages (web/dterm.pl, published with the Filnix wiki at mbrock.github.io/filnix/nanofil). This file records where it comes from, what it does, and the change it asks of how nanofil is written: documentation is a relation of the module, doc/3, and its examples are tests.

Lineage

dterm is the latest in a line of renderers that draw code as what it is, not how it was typed.

wisp's dexp (mbrock/wisp) was the first: a structure editor for Lisp in which every list is a box whose side borders are its parentheses. Atoms are inline; boxes flow and wrap with the width, so the layout is responsive without any line-breaking logic.

wisp's dexp: a cond, each clause a box

luv's dexp (mbrock/luv, wiki/dexp.lisp) took that to a whole wiki. A small table of operator layouts (body forms stack, binding forms are two-column grids, cond is a table of tests and bodies) assigns roles in Lisp; the stylesheet only knows the roles. Lambda lists come from introspecting the running image, and docstrings render as prose with symbol references linked.

luv's dexp on luft/star-geometry.lisp

bsd's xslt-doc (mbrock/bsd, src/base/xslt-doc.xsl) went the other way: it documented code with structured data in the language itself. XSLT is XML, so documentation is elements in a doc: namespace between the xsl: declarations: modules, sections, typed notes, algorithms, examples with real XML input, and pattern analysis that explains each predicate of a match. The processor ignores the doc: elements when it runs the stylesheet; the renderer reads them like any other node. The renderer documents itself with the vocabulary it renders. Its weakness showed later: the richest documentation (routing.xsl's 257 doc: elements) was dropped as the code moved on, because nothing checked it.

xslt-doc rendering itself Template bodies drawn as the HTML they produce Pattern analysis: why a match applies

dterm v1 (October 2026) was dexp for Prolog, written in Prolog: a compound is its functor and a parenthesis box, a clause is its head over one goal per row, if-then-else chains are tables, a predicate of short facts is a table, PlDoc comments become signatures. It inferred all its structure from the code and the comments.

dterm v1: a PlDoc note as a signature, then the clauses

Two mockups (made with ChatGPT from v1 screenshots) pointed where to go next. The first gave a predicate a title, numbered its clauses as cases each with a caption, put an argument table beside the signature, and a panel of callers, callees and related predicates. The second, asked to attend to typography, nestled punctuation: commas hugging their arguments, parentheses close to their contents, quieter brackets.

Mockup: reflect_declarator/5 with numbered cases and a side panel Mockup: tighter punctuation and spacing

What we took

From dexp: drawing is structure. A box per compound, flow and wrap, roles assigned in code and styled by CSS. No pretty-printer, no line breaking.

From xslt-doc: documentation in the language's own data model. Prolog has the same advantage XSLT does, and more: code is terms, so documentation can be terms, read by read_term/2 with everything else and, unlike XML, run.

From xslt-doc's decline: documentation that is not checked rots. So examples are goals, and a failing example fails the build.

From the mockups: numbered clauses, an argument table, cross references beside the predicate, and tighter punctuation.

doc/3

A documented module declares :- discontiguous(doc/3). and states, just before each predicate:

doc(usual_arith_conversion(+TypeA, +TypeB, -Type), semidet, [
    TypeA: "the type of one arithmetic operand",
    TypeB: "the type of the other",
    Type: "their common real type",
    "The usual arithmetic conversions. A floating operand wins; otherwise",
    "both are promoted, and when the signedness differs the unsigned type",
    "wins unless the signed one is wider. A signed type exactly as wide",
    "as a higher-ranked unsigned one gives way to its own unsigned type.",
    cites('C11 6.3.1.8p1'),
    example(usual_arith_conversion(int, uint, T), T = uint),
    example(usual_arith_conversion(llong, ulong, T), T = ullong),
    fails(usual_arith_conversion(ptr(int), int, _))
]).

The facts are ordinary clauses of the module: no operator declarations, no runtime cost beyond a few kilobytes of facts, and the same on both hosts. Nothing calls doc/3 but the tests and dterm.

Examples are tests

tests/unit.pl's doc_examples test runs every example of every module listed in documented_module/1 on both hosts, as part of nix build .#checks.x86_64-linux.nanofil. dterm runs them again when it builds the pages and marks each ✓ or ✗, so a page shows whether it was true at its revision.

Planting SizeA >= SizeB in usual_arith_conversion/3 (signed llong wins over ulong) fails the unit test with

  c_types: example(usual_arith_conversion(llong,ulong,_),_=ullong) does not hold
FAILED doc_examples

and the page shows it:

The planted bug, as the page shows it

dterm v2

dterm v2: doc/3 as signature, arguments, prose, citation and checked examples, with calls and callers

Terms as type (October 2026)

v2 drew every compound as a flex box whose arguments were items. That gave the right nesting but the wrong typography: the gap after a comma was a flex gap, so a comma floated halfway between its arguments; a line could break anywhere between items, even between a functor's box and its neck; every paren box was as tall as its line, so tmp(v0) in a run of IR had a frame round it heavier than the name it held; brackets looked like parentheses; and a long term wrapped wherever the width ran out, so C syntax became a staircase of nested frames.

dterm now sets terms as text, after the conventions of mathematical setting and of Lisp's pretty-printers:

The printer

dterm.pl decides what a term is; dterm.js decides where its lines break, at the width the page actually gives each head and goal. It is Bernardy's printer ("A Pretty But Not Greedy Printer", ICFP 2017), cut to dterm, after a first attempt in the style of Zoot and the pretty expressive printer that filled lines as prose and broke deep inside small terms.

dterm.pl marks the groups: data-d=args on a compound or list, whose .arg children are its items, and data-d=op on an operator a line may break at. The printer reads each head and goal as a document in Bernardy's operators (text, <> with its later lines aligned where it began, flush, and choice), measured in pixels with canvas. The craft is in which layouts each group offers:

Each document gets its Pareto frontier by height, width, last-line width and badness (a hung or Lisp layout is a little worse than an aligned one), computed once per piece since a layout's measures do not depend on where it starts. A layout wider than the width, or a single line longer than --ribbon (34em: how much one line may hold however wide the page), is dropped when it is made. The printer takes the fewest lines, then the least bad; if nothing fits it lets the ribbon go, but keeps the available width. If no plan fits even then, the term keeps its natural wrapping markup rather than forcing an oversized no-break layout.

A conditional is a table only while it fits and every branch is one goal; otherwise it is a stack whose IF, THEN and ELSE share one column, so every branch starts at the same place.

The plan is written as kpre (mbrock/kpre) writes a paragraph: a break is a <br class="zbr"> and an indent, every other separator a no-break space, so the browser draws exactly the plan. html[data-planner=off] shows the text unbroken; the workbench's Line breaks knob compares the two, and its ribbon and hang knobs tune the printer.

The workbench (web/workbench/) sets representative terms twice, under presets or any knob (paren ink, stroke, bow and spacing, bracket style, comma ink and weight, operator spacing, the ribbon and hang, v2's layout), at phone, tablet or half-window width. The values above were chosen there against v2, quieter and firmer ink, glyph parentheses, and the looser spacing of the second mockup: glyph parentheses in Public Sans are tall and light and do not nest visibly; looser spacing pulls commas away from what they end; quieter ink loses the nesting in IR.

Praxis

nanofil's AGENTS.md now asks, for modules listed in documented_module/1:

c_types.pl's Integers section was the first converted. Its typing rules followed: compatible and composite types (6.2.7), the typed tree (elaborate/3), lvalues and value conversion (6.3.2.1), and the operators, casts, assignment and ?: (6.5). Together they are 34 predicates with 121 examples. Writing them as documents also flattened nested conditionals into named relations such as composite_size/3, identifier_type/4, designator/3 and pointed_type/4, and wrote -> as elab(member(un(deref, E), F)), as 6.5.2.3p4 defines it.

Module headers as prose (October 2026)

A file's header comment is the lede of its page, and it had been written for an 80-column terminal: term grammars as aligned columns, fields as indented runs, all drawn as one grey preformatted block. The comment now takes a little Markdown, chosen so the plain text still reads well in an editor:

Indented lines are still a preformatted block, so an unconverted header draws as before. c_types.pl was converted first, its type terms, the environment and its definitions and members each a table; the other modules followed.

The guide as a page (October 2026)

guide.md is drawn by web/dterm_guide.pl, included in dterm.pl, as nanofil/guide.html beside the module pages. The Markdown stays the source and reads the same on GitHub. On the page:

A reference that does not resolve fails the site build and swipl web/dterm.pl --check docs/guide.md docs/formal.md src, which the Nix check runs.

formal.md, the formal campaign, is drawn the same way as nanofil/formal.html (dterm.pl takes any number of --doc= documents). Each module page lists every document's sections that name it; a document without a module map, like formal.md, lists the sections that mention the module. A concept mentioned in one document links to its page, and a link to a .nix file goes to its dterm-nix page.

Diagrams

A ```diagram fence, in a module header or the guide, stays box-drawing text in the source and on GitHub. web/dterm_diagram.pl reads it as one of four shapes and draws it as HTML that reflows at phone width, with module and predicate names linked:

A shape is used only when it accounts for every line. Anything else stays text in a block that scrolls sideways, so an unusual diagram never draws wrong.

Nix (November 2026)

dterm-nix draws filnix's Nix the same way, on the wiki at mbrock.github.io/filnix/nix: a Nix grammar written as a DCG reads each file into terms with positions and comments, attribute sets and let become tables of bindings, and dterm.js breaks the inline terms as it breaks Prolog's. It shares dterm.js, dterm.css and web/dterm_html.pl (the HTML DCGs and the Markdown of comments) with these pages. Two of its pages are facts rather than source: ports.nix as a table of steps per port, and an option index from evaluating the NixOS and finix modules.

Rocq (November 2026)

web/rocq, dterm-rocq, draws the formal campaign's Rocq (formal.md) the same way, on the wiki at mbrock.github.io/filnix/nanofil/rocq: a Rocq grammar written as a DCG reads each .v file into terms with positions and comments; inductive types become tables of constructors, step relations and theorems inference rules with their proofs folded under them, and dterm.js breaks the inline terms as it breaks Prolog's. Rocq in formal.md's prose (cap := None | Obj base, Ret v, the Theorem) is drawn as Rocq, beside the Prolog it transcribes drawn as Prolog. It draws the Campaign 1 development in rocq/.

Concept nodes (November 2026)

nanofil is meant to read as one literate program in three languages: the Prolog that compiles, the Rocq that models it, and the documents that explain both. Their terms (a capability, a renew, a doomed frame) were explained in the guide's glossary, again in a module's header, and again in a .v file's header, each a little differently. A concept node is the one place a term is explained, after Roam's and Obsidian's pages: a file concepts/ID.md, linked from everywhere it is used.

# doomed frame / doomed / doom

A frame whose check_ahead failed early: ...

C in prose (November 2026)

C written into documents and comments is drawn from nanofil's own parse (web/c): c_lex lexes it and c_parse reads it, as the compiler would, and c_draw sets what they read the way dterm sets terms.

Not yet