Skip to content

Commit e5f2091

Browse files
authored
feat: diff preview, structured renderers, dag template, test coverage (#2)
* feat: add diff preview and json/toml/yaml renderers Diff app (nix run .#diff-files) shows what the writer would change without writing. Supports --verbose for full diffs via difftastic. Structured data options on files.file: json, toml, yaml serialize Nix values directly — no pkgs.writers.* boilerplate needed. * feat: add dag demo template for composing files across modules Shows how multiple flake-parts modules can each contribute sections to a README with ordering constraints (entryBefore, entryAfter, entryBetween), then wire the dag-rendered output into files.file. * style: fix deadnix and statix warnings, format all files - Remove unused psArgs binding (deadnix) - Use inherit instead of assignment (statix W04) - Group repeated keys into attrsets (statix W20) - Fix dag template URL to denful/dag - Format all files with treefmt * fix: enforce mutual exclusivity for text/json/toml/yaml, fix diff /dev/null * chore: add .worktrees to gitignore * fix: enable-false test must run writer before checking * fix: use shellcheck-safe pattern for negated test
1 parent 85652ff commit e5f2091

26 files changed

Lines changed: 688 additions & 136 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
2+
.worktrees
13
/.pre-commit-config.yaml
24
result

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ it across [den](https://github.com/denful/den)'s template ecosystem:
6262
- **Works with and without flake-parts**`flakeModule` for flake-parts,
6363
`module` for vanilla flakes via `evalModules`
6464
- **`files.file` convenience API**`environment.etc`-style attrset with
65-
`text` and `source` options
65+
`text`, `source`, `json`, `toml`, and `yaml` options
66+
- **Structured data renderers**`json`, `toml`, and `yaml` options
67+
serialize Nix values directly, no `pkgs.writers.*` boilerplate
68+
- **Diff preview**`nix run .#diff-files` shows what would change
69+
without writing; `--verbose` for full diffs
6670
- **[treefmt-nix](https://github.com/numtide/treefmt-nix) integration**
6771
`files.treefmt.enable` formats all entries through `nix fmt`
6872
- **Formatters** — global `files.formatters` by extension and per-file
@@ -82,6 +86,7 @@ Working examples live in [`templates/`](templates/):
8286
- [`flake-parts`](templates/flake-parts/) — flake-parts + import-tree with treefmt, global formatters, per-file overrides, onChange hooks, and both APIs
8387
- [`bare-flake`](templates/bare-flake/) — vanilla flake using `evalModules`, no flake-parts dependency
8488
- [`no-flake`](templates/no-flake/) — pure `default.nix` with `import`, no flake infrastructure
89+
- [`dag`](templates/dag/) — composing a single file from sections across multiple modules using [dag](https://github.com/theutz/dag) for topological ordering
8590

8691
## Quick start
8792

@@ -116,8 +121,10 @@ Working examples live in [`templates/`](templates/):
116121
```
117122

118123
```sh
119-
nix run .#write-files # write files to disk
120-
nix flake check # verify they match
124+
nix run .#write-files # write files to disk
125+
nix run .#diff-files # preview what would change
126+
nix run .#diff-files -- -v # preview with full diffs
127+
nix flake check # verify they match
121128
```
122129

123130
## With treefmt
@@ -217,6 +224,9 @@ the project root. Use slashes for subdirectories.
217224
| ------------ | ------------------------------------ | ------- | ------------------------------------------------------------------------------- |
218225
| `enable` | `bool` | `true` | Set `false` to suppress this file. |
219226
| `text` | `nullOr lines` | `null` | Inline text content. Sets `source` automatically. |
227+
| `json` | `nullOr anything` | `null` | JSON value to serialize. Sets `source` automatically. |
228+
| `toml` | `nullOr anything` | `null` | TOML value to serialize. Sets `source` automatically. |
229+
| `yaml` | `nullOr anything` | `null` | YAML value to serialize. Sets `source` automatically. |
220230
| `source` | `path` || Path or derivation for the file content. |
221231
| `executable` | `bool` | `false` | `chmod +x` after writing. |
222232
| `onChange` | `str` or `{ runtimeInputs, script }` | `""` | Shell commands run after all files are written. Runs under `set -euo pipefail`. |
@@ -245,6 +255,14 @@ perSystem = { config, pkgs, ... }: {
245255
};
246256
};
247257
258+
# structured data — no pkgs.writers.* boilerplate
259+
files.file."data/config.json".json = { version = 1; debug = false; };
260+
files.file."data/settings.toml".toml = { database.host = "localhost"; };
261+
files.file."data/compose.yaml".yaml = {
262+
version = "3";
263+
services.app.image = "myapp:latest";
264+
};
265+
248266
# disable a file defined by a shared module
249267
files.file."docs/internal.md".enable = false;
250268
@@ -316,9 +334,11 @@ files.files = [
316334
| -------------------- | ----------------- | --------------- | ----------------------------------------------------------------------------------------------------- |
317335
| `root` | `path` || Root for check comparisons. Auto-set to `self` via flake-parts; set `root = self;` in vanilla flakes. |
318336
| `relativeRoot` | `str` | `""` | Subdir from git root for the writer. |
319-
| `generateApp` | `bool` | `false` | Expose writer as `nix run .#write-files`. |
337+
| `generateApp` | `bool` | `false` | Expose writer and diff as `nix run .#write-files` / `nix run .#diff-files`. |
320338
| `writer.exeFilename` | `singleLineStr` | `"write-files"` | Writer executable name. |
321339
| `writer.drv` | `package` | _(computed)_ | The writer derivation (read-only). |
340+
| `diff.exeFilename` | `singleLineStr` | `"diff-files"` | Diff executable name. |
341+
| `diff.drv` | `package` | _(computed)_ | The diff derivation (read-only). Shows what would change without writing. |
322342
| `checks` | `attrsOf package` | _(computed)_ | Per-file check derivations (read-only). |
323343

324344
## Without flake-parts

flake-module.nix

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
files.treefmt.package = lib.mkIf (config.files.treefmt.enable && options.formatter.isDefined) (
1717
lib.mkDefault config.formatter
1818
);
19-
checks = config.files.checks;
19+
inherit (config.files) checks;
2020
apps = lib.mkIf config.files.generateApp {
2121
${config.files.writer.exeFilename} = {
2222
type = "app";
2323
program = lib.getExe config.files.writer.drv;
2424
};
25+
${config.files.diff.exeFilename} = {
26+
type = "app";
27+
program = lib.getExe config.files.diff.drv;
28+
};
2529
};
2630
};
2731
}

module.nix

Lines changed: 137 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,34 @@ in
150150
Sets `source` automatically via `pkgs.writeText`.
151151
'';
152152
};
153+
json = lib.mkOption {
154+
type = lib.types.nullOr lib.types.anything;
155+
default = null;
156+
description = ''
157+
JSON value to serialize. Sets `source` automatically.
158+
'';
159+
};
160+
toml = lib.mkOption {
161+
type = lib.types.nullOr lib.types.anything;
162+
default = null;
163+
description = ''
164+
TOML value to serialize. Sets `source` automatically.
165+
'';
166+
};
167+
yaml = lib.mkOption {
168+
type = lib.types.nullOr lib.types.anything;
169+
default = null;
170+
description = ''
171+
YAML value to serialize. Sets `source` automatically.
172+
Requires `pkgs.yj` for JSON-to-YAML conversion.
173+
'';
174+
};
153175
source = lib.mkOption {
154176
type = lib.types.path;
155177
description = ''
156178
Path or derivation to use as the file content.
157-
Set automatically when `text` is provided.
179+
Set automatically when `text`, `json`, `toml`, or `yaml`
180+
is provided.
158181
'';
159182
};
160183
executable = lib.mkOption {
@@ -198,7 +221,30 @@ in
198221
'';
199222
};
200223
};
201-
config.source = lib.mkIf (config.text != null) (pkgs.writeText name config.text);
224+
config.source =
225+
let
226+
contentSources = lib.filter (x: x != null) [
227+
config.text
228+
config.json
229+
config.toml
230+
config.yaml
231+
];
232+
in
233+
assert lib.assertMsg (
234+
builtins.length contentSources <= 1
235+
) "files.file.\"${name}\": only one of text, json, toml, yaml may be set";
236+
if config.text != null then
237+
pkgs.writeText name config.text
238+
else if config.json != null then
239+
pkgs.writers.writeJSON name config.json
240+
else if config.toml != null then
241+
(pkgs.formats.toml { }).generate name config.toml
242+
else if config.yaml != null then
243+
pkgs.runCommand name { nativeBuildInputs = [ pkgs.yj ]; } ''
244+
yj -jy < ${pkgs.writers.writeJSON name config.yaml} > $out
245+
''
246+
else
247+
lib.mkDefault config.source;
202248
}
203249
)
204250
);
@@ -272,11 +318,24 @@ in
272318
readOnly = true;
273319
};
274320
};
321+
322+
diff = {
323+
exeFilename = lib.mkOption {
324+
type = lib.types.singleLineStr;
325+
default = "diff-files";
326+
description = "The diff executable filename.";
327+
};
328+
drv = lib.mkOption {
329+
description = "The diff executable derivation (read-only).";
330+
type = lib.types.package;
331+
readOnly = true;
332+
};
333+
};
275334
};
276335
};
277336

278-
config = {
279-
files.files =
337+
config.files = {
338+
files =
280339
let
281340
toListEntry =
282341
name:
@@ -299,7 +358,7 @@ in
299358
lib.mapAttrsToList toListEntry enabledFiles;
300359

301360
# apply formatting to all files.files entries (both attrset and list API)
302-
files._formattedFiles =
361+
_formattedFiles =
303362
let
304363
extOf =
305364
name:
@@ -336,12 +395,8 @@ in
336395
formatter =
337396
if format != null then
338397
format
339-
else if cfg.formatters ? ${ext} then
340-
cfg.formatters.${ext}
341-
else if cfg.treefmt.enable then
342-
treefmtFormat
343398
else
344-
null;
399+
cfg.formatters.${ext} or (if cfg.treefmt.enable then treefmtFormat else null);
345400
in
346401
(removeAttrs entry [ "format" ])
347402
// {
@@ -350,7 +405,7 @@ in
350405
in
351406
map applyFormat cfg.files;
352407

353-
files.writer.drv =
408+
writer.drv =
354409
let
355410
formattedFiles = cfg._formattedFiles;
356411
activeHooks = builtins.filter ({ onChange, ... }: onChange.script != "") formattedFiles;
@@ -417,7 +472,77 @@ in
417472
lib.concatLines ([ preamble ] ++ writeCommands ++ onChangeHooks);
418473
};
419474

420-
files.checks = lib.pipe cfg._formattedFiles [
475+
diff.drv =
476+
let
477+
formattedFiles = cfg._formattedFiles;
478+
479+
preamble = ''
480+
cd "$(git rev-parse --show-toplevel)"
481+
''
482+
+ lib.optionalString (cfg.relativeRoot != "") ''
483+
cd ${lib.escapeShellArg cfg.relativeRoot}
484+
'';
485+
486+
diffCommands = map (
487+
{ path, drv, ... }:
488+
let
489+
escapedPath = lib.escapeShellArg path;
490+
in
491+
''
492+
if ! [ -f ${escapedPath} ]; then
493+
echo " create ${path}"
494+
_changes=$((_changes + 1))
495+
if [ "$_verbose" = 1 ]; then
496+
cat ${drv}
497+
fi
498+
elif ! cmp -s ${drv} ${escapedPath}; then
499+
echo " update ${path}"
500+
_changes=$((_changes + 1))
501+
if [ "$_verbose" = 1 ]; then
502+
difft --display inline ${escapedPath} ${drv} || true
503+
fi
504+
else
505+
echo " ok ${path}"
506+
fi
507+
''
508+
) formattedFiles;
509+
in
510+
pkgs.writeShellApplication {
511+
name = cfg.diff.exeFilename;
512+
runtimeInputs = [
513+
pkgs.gitMinimal
514+
pkgs.difftastic
515+
];
516+
derivationArgs = {
517+
allowSubstitutes = false;
518+
preferLocalBuild = true;
519+
};
520+
text =
521+
if formattedFiles == [ ] then
522+
''echo "No files configured. Add entries to files.file or files.files."''
523+
else
524+
''
525+
_changes=0
526+
_verbose=0
527+
for arg in "$@"; do
528+
case "$arg" in
529+
-v|--verbose) _verbose=1 ;;
530+
esac
531+
done
532+
''
533+
+ preamble
534+
+ lib.concatLines diffCommands
535+
+ ''
536+
if [ "$_changes" -eq 0 ]; then
537+
echo "All files up to date."
538+
else
539+
echo "$_changes file(s) would change."
540+
exit 1
541+
fi
542+
'';
543+
};
544+
545+
checks = lib.pipe cfg._formattedFiles [
421546
(map (
422547
{ path, drv, ... }:
423548
{

templates/bare-flake/flake.nix

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,43 @@
1919
modules = [
2020
files.module
2121
{
22-
config._module.args = {
23-
inherit pkgs;
24-
};
25-
config.files.root = self;
22+
config = {
23+
_module.args = {
24+
inherit pkgs;
25+
};
26+
files = {
27+
root = self;
2628

27-
# --- files.file attrset API ---
28-
config.files.file.".gitignore".text = ''
29-
result
30-
.direnv
31-
'';
29+
# --- files.file attrset API ---
30+
file = {
31+
".gitignore".text = ''
32+
result
33+
.direnv
34+
'';
3235

33-
config.files.file."README.md".text = ''
34-
# bare-flake demo
35-
Uses `evalModules` with `files.module` directly.
36-
'';
36+
"README.md".text = ''
37+
# bare-flake demo
38+
Uses `evalModules` with `files.module` directly.
39+
'';
3740

38-
config.files.file."scripts/hello.sh" = {
39-
text = ''
40-
#!/bin/sh
41-
echo "hello from bare flake!"
42-
'';
43-
executable = true;
44-
};
41+
"scripts/hello.sh" = {
42+
text = ''
43+
#!/bin/sh
44+
echo "hello from bare flake!"
45+
'';
46+
executable = true;
47+
};
48+
};
4549

46-
# --- files.files list API ---
47-
config.files.files = [
48-
{
49-
path = "data/version.txt";
50-
drv = pkgs.writeText "version.txt" "0.1.0";
51-
}
52-
];
50+
# --- files.files list API ---
51+
files = [
52+
{
53+
path = "data/version.txt";
54+
drv = pkgs.writeText "version.txt" "0.1.0";
55+
}
56+
];
57+
};
58+
};
5359
}
5460
];
5561
};

templates/ci/modules/dogfood.nix

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
perSystem =
44
{ config, ... }:
55
{
6-
files.root = inputs.files;
7-
files.generateApp = true;
8-
files.treefmt.enable = true;
6+
files = {
7+
root = inputs.files;
8+
generateApp = true;
9+
treefmt.enable = true;
10+
};
911
make-shells.default.packages = [ config.files.writer.drv ];
1012
};
1113
}

templates/ci/modules/gitignore.nix

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
];
1212
};
1313
config = {
14-
gitignore = "result";
14+
gitignore = ''
15+
.worktrees
16+
result
17+
'';
1518
perSystem =
1619
{ pkgs, ... }:
1720
{

0 commit comments

Comments
 (0)