From 4ed552776650e8b568887973fedb709e4dd6f6a5 Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 18:39:29 +0200 Subject: [PATCH 1/6] Restructure as flake-parts modules with dendritic layout Replace the Makefile and base template with flake-parts modules that consumers import as aspects (flakeModules.{default,r,quarto,cloudflare-pages}), discovered via an in-house import-tree (no extra inputs). - core: devshell, treefmt, tasks (apps), generated files with drift check (.gitignore, .envrc, CI stubs, VS Code settings), site, github - lang/r: R env from DESCRIPTION, air (jarl opt-in), R CMD check - lang/quarto: quartoMinimal, site package, preview/render tasks, R composition - deploy/cloudflare-pages: wrangler deploy task and CI wiring - reusable ci.yml / maintenance.yml workflows called by generated stubs - dev partition (flake-schemas, dogfooding) keeps consumer locks at 5 nodes - contract tests: unit tests plus md/r/quarto fixtures and templates - templates default/r/quarto; README rewritten Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .envrc | 3 +- .github/workflows/ci.yml | 55 +++ .github/workflows/cron.yml | 24 +- .github/workflows/maintenance.yml | 22 + .github/workflows/push.yml | 28 +- .gitignore | 16 +- .vscode/extensions.json | 7 +- .vscode/settings.json | 30 +- Makefile | 1 - README.md | 116 ++++- _quarto.yml | 3 + dev/flake.lock | 25 + dev/flake.nix | 7 + flake.lock | 471 +------------------ flake.nix | 159 +------ lib/import-tree.nix | 9 + lib/r.nix | 73 +++ make/help.mk | 55 --- make/main.mk | 21 - make/nix.mk | 34 -- make/quarto.mk | 4 - modules/core/devshell.nix | 43 ++ modules/core/direnv.nix | 7 + modules/core/editor.nix | 72 +++ modules/core/files.nix | 99 ++++ modules/core/github.nix | 99 ++++ modules/core/gitignore.nix | 32 ++ modules/core/pkgs.nix | 18 + modules/core/site.nix | 19 + modules/core/tasks.nix | 84 ++++ modules/core/treefmt.nix | 38 ++ modules/deploy/cloudflare-pages.nix | 61 +++ modules/dev/dogfood.nix | 26 + modules/dev/partition.nix | 16 + modules/dev/schemas.nix | 50 ++ modules/dev/tests.nix | 219 +++++++++ modules/flake-parts.nix | 6 + modules/lang/quarto/editor.nix | 3 + modules/lang/quarto/gitignore.nix | 8 + modules/lang/quarto/package.nix | 28 ++ modules/lang/quarto/r.nix | 21 + modules/lang/quarto/site.nix | 78 +++ modules/lang/r/checks.nix | 84 ++++ modules/lang/r/devshell.nix | 7 + modules/lang/r/editor.nix | 6 + modules/lang/r/gitignore.nix | 9 + modules/lang/r/options.nix | 43 ++ modules/lang/r/package.nix | 40 ++ modules/lang/r/treefmt.nix | 44 ++ modules/lib.nix | 16 + modules/templates.nix | 31 ++ templates/base/.envrc | 3 - templates/base/.github/workflows/cron.yml | 24 - templates/base/.github/workflows/push.yml | 30 -- templates/base/.gitignore | 2 - templates/base/flake.nix | 29 -- templates/default/flake.nix | 7 + templates/quarto/flake.nix | 18 + templates/r/flake.nix | 15 + tests/fixtures/import-tree/_private/c.nix | 1 + tests/fixtures/import-tree/_skip.nix | 1 + tests/fixtures/import-tree/a.nix | 1 + tests/fixtures/import-tree/readme.txt | 1 + tests/fixtures/import-tree/sub/b.nix | 1 + tests/fixtures/md/README.md | 3 + tests/fixtures/md/flake.nix | 7 + tests/fixtures/quarto/_quarto.yml | 4 + tests/fixtures/quarto/flake.nix | 16 + tests/fixtures/quarto/index.qmd | 9 + tests/fixtures/r/DESCRIPTION | 11 + tests/fixtures/r/NAMESPACE | 3 + tests/fixtures/r/R/hello.R | 8 + tests/fixtures/r/flake.nix | 15 + tests/fixtures/r/man/hello.Rd | 17 + tests/fixtures/r/tests/testthat.R | 4 + tests/fixtures/r/tests/testthat/test-hello.R | 3 + 76 files changed, 1829 insertions(+), 874 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/maintenance.yml delete mode 100644 Makefile create mode 100644 dev/flake.lock create mode 100644 dev/flake.nix create mode 100644 lib/import-tree.nix create mode 100644 lib/r.nix delete mode 100644 make/help.mk delete mode 100644 make/main.mk delete mode 100644 make/nix.mk delete mode 100644 make/quarto.mk create mode 100644 modules/core/devshell.nix create mode 100644 modules/core/direnv.nix create mode 100644 modules/core/editor.nix create mode 100644 modules/core/files.nix create mode 100644 modules/core/github.nix create mode 100644 modules/core/gitignore.nix create mode 100644 modules/core/pkgs.nix create mode 100644 modules/core/site.nix create mode 100644 modules/core/tasks.nix create mode 100644 modules/core/treefmt.nix create mode 100644 modules/deploy/cloudflare-pages.nix create mode 100644 modules/dev/dogfood.nix create mode 100644 modules/dev/partition.nix create mode 100644 modules/dev/schemas.nix create mode 100644 modules/dev/tests.nix create mode 100644 modules/flake-parts.nix create mode 100644 modules/lang/quarto/editor.nix create mode 100644 modules/lang/quarto/gitignore.nix create mode 100644 modules/lang/quarto/package.nix create mode 100644 modules/lang/quarto/r.nix create mode 100644 modules/lang/quarto/site.nix create mode 100644 modules/lang/r/checks.nix create mode 100644 modules/lang/r/devshell.nix create mode 100644 modules/lang/r/editor.nix create mode 100644 modules/lang/r/gitignore.nix create mode 100644 modules/lang/r/options.nix create mode 100644 modules/lang/r/package.nix create mode 100644 modules/lang/r/treefmt.nix create mode 100644 modules/lib.nix create mode 100644 modules/templates.nix delete mode 100644 templates/base/.envrc delete mode 100644 templates/base/.github/workflows/cron.yml delete mode 100644 templates/base/.github/workflows/push.yml delete mode 100644 templates/base/.gitignore delete mode 100644 templates/base/flake.nix create mode 100644 templates/default/flake.nix create mode 100644 templates/quarto/flake.nix create mode 100644 templates/r/flake.nix create mode 100644 tests/fixtures/import-tree/_private/c.nix create mode 100644 tests/fixtures/import-tree/_skip.nix create mode 100644 tests/fixtures/import-tree/a.nix create mode 100644 tests/fixtures/import-tree/readme.txt create mode 100644 tests/fixtures/import-tree/sub/b.nix create mode 100644 tests/fixtures/md/README.md create mode 100644 tests/fixtures/md/flake.nix create mode 100644 tests/fixtures/quarto/_quarto.yml create mode 100644 tests/fixtures/quarto/flake.nix create mode 100644 tests/fixtures/quarto/index.qmd create mode 100644 tests/fixtures/r/DESCRIPTION create mode 100644 tests/fixtures/r/NAMESPACE create mode 100644 tests/fixtures/r/R/hello.R create mode 100644 tests/fixtures/r/flake.nix create mode 100644 tests/fixtures/r/man/hello.Rd create mode 100644 tests/fixtures/r/tests/testthat.R create mode 100644 tests/fixtures/r/tests/testthat/test-hello.R diff --git a/.envrc b/.envrc index 197c0d5..b55dec8 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,2 @@ -#!/bin/bash - +# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..41991ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +# Reusable CI of nullkomma projects, called by their generated push.yml. +name: nullkomma CI +on: + workflow_call: + inputs: + visibility: + description: FlakeHub visibility (public, unlisted or private); empty skips publishing. + type: string + default: "" + deploy: + description: Run `nix run .#deploy` on pushes to the default branch. + type: boolean + default: false + secrets: + CLOUDFLARE_ACCOUNT_ID: + required: false + CLOUDFLARE_API_TOKEN: + required: false +jobs: + DeterminateCI: + uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main + permissions: + contents: read + id-token: write + with: + visibility: ${{ inputs.visibility }} + fail-fast: false + runner-map: | + { + "aarch64-darwin": "macos-latest", + "aarch64-linux": "ubuntu-24.04-arm", + "x86_64-linux": "ubuntu-latest" + } + flake-checker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/flake-checker-action@v14 + with: + fail-mode: true + deploy: + if: inputs.deploy && github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + needs: DeterminateCI + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/determinate-nix-action@v3 + - uses: DeterminateSystems/flakehub-cache-action@v3 + - run: nix run .#deploy + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 759ebf6..8a66a71 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,24 +1,14 @@ +# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. name: Maintenance on: - workflow_dispatch: schedule: - - cron: "0 0 * * 0" # runs weekly on Sunday at 01:00 + - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC + workflow_dispatch: jobs: - nix-maintenance: - name: Nix Maintenance - runs-on: ubuntu-latest + nullkomma: + uses: ./.github/workflows/maintenance.yml permissions: - id-token: "write" - contents: "write" + contents: write + id-token: write issues: write pull-requests: write - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/determinate-nix-action@main - - uses: DeterminateSystems/flake-checker-action@main - - uses: DeterminateSystems/update-flake-lock@main - with: - pr-title: "Update flake.lock" - pr-labels: | - dependencies - automated diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000..3b4c114 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,22 @@ +# Reusable maintenance of nullkomma projects, called by their generated cron.yml. +name: nullkomma maintenance +on: + workflow_call: +jobs: + update-flake-lock: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + - uses: DeterminateSystems/determinate-nix-action@v3 + - uses: DeterminateSystems/flake-checker-action@v14 + - uses: DeterminateSystems/update-flake-lock@v29 + with: + pr-title: Update flake.lock + pr-labels: | + dependencies + automated diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 184cdb3..f1efb6b 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,31 +1,17 @@ +# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. name: CI on: pull_request: - workflow_dispatch: push: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - DeterminateCI: - uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main + nullkomma: + uses: ./.github/workflows/ci.yml permissions: - id-token: "write" - contents: "read" + contents: read + id-token: write with: - visibility: public - fail-fast: false - runner-map: | - { - "aarch64-darwin": "macos-latest", - "aarch64-linux": "ubuntu-24.04-arm", - "x86_64-linux": "ubuntu-latest" - } - flake-checker: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Check Nix flake Nixpkgs inputs - uses: DeterminateSystems/flake-checker-action@v12 - with: - fail-mode: true + visibility: "public" diff --git a/.gitignore b/.gitignore index b17e0aa..4611804 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,16 @@ +# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. +**/*.quarto_ipynb +/.quarto/ +/_book/ +/_site/ .direnv/ result -/.quarto/ -**/*.quarto_ipynb -_site/ +result-* .claude/ +# nullkomma-managed files +!/.envrc +!/.github/workflows/cron.yml +!/.github/workflows/push.yml +!/.gitignore +!/.vscode/extensions.json +!/.vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7c6bf15..7ab7367 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,8 @@ { - "recommendations": ["arrterian.nix-env-selector", "jnoortheen.nix-ide"] + "recommendations": [ + "arrterian.nix-env-selector", + "ibecker.treefmt-vscode", + "jnoortheen.nix-ide", + "quarto.quarto" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 3266dc8..8f1d7a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,22 +1,30 @@ { - "nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix", - "nixEnvSelector.useFlakes": true, - "nixEnvSelector.suggestion": false, + "editor.defaultFormatter": "ibecker.treefmt-vscode", "nix.enableLanguageServer": true, + "nix.formatterPath": [ + "treefmt", + "--stdin", + "{file}" + ], + "nix.hiddenLanguageServerErrors": [ + "textDocument/definition", + "textDocument/completion", + "textDocument/documentHighlight" + ], "nix.serverPath": "nixd", "nix.serverSettings": { "nixd": { "formatting": { - "command": ["treefmt", "--stdin", "{file}"] + "command": [ + "treefmt", + "--stdin", + "{file}" + ] } } }, - "nix.formatterPath": ["treefmt", "--stdin", "{file}"], - "nix.hiddenLanguageServerErrors": [ - "textDocument/definition", - "textDocument/completion", - "textDocument/documentHighlight" - ], - "editor.defaultFormatter": "ibecker.treefmt-vscode", + "nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix", + "nixEnvSelector.suggestion": false, + "nixEnvSelector.useFlakes": true, "notebook.defaultFormatter": "ibecker.treefmt-vscode" } diff --git a/Makefile b/Makefile deleted file mode 100644 index e96aa59..0000000 --- a/Makefile +++ /dev/null @@ -1 +0,0 @@ -include make/*.mk diff --git a/README.md b/README.md index d14b055..b9edaf6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ extra-[DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) 🤌 ## Scope -nullkomma is **per-project** Nix scaffolding: flakes, templates, devshells, formatters/checks, Makefile helpers, and the CI patterns those templates ship. +nullkomma is **per-project** Nix scaffolding: devshells, formatters, linters, checks, tasks, docs, deploys, CI and editor settings, packaged as composable [flake-parts](https://flake.parts) modules. It is **not**: @@ -32,10 +32,6 @@ When making changes, adhere to [aoshima's charter](https://github.com/dataheld/a ## Installing -> [!NOTE] -> This is not another flake or development framework. -> It is much less; just the set of composable nix flakes and modules that I use for my projects. - > [!TIP] > The steps below are **system / user** prerequisites. > Project-specific software is handled by the flake once you are inside the repo. @@ -43,15 +39,22 @@ When making changes, adhere to [aoshima's charter](https://github.com/dataheld/a 1. Install Nix (the package manager). The [Determinate Nix Installer](https://github.com/DeterminateSystems/nix-installer) is recommended. -For automatic flake-shell activation, provide [direnv](https://direnv.net) and [nix-direnv](https://github.com/nix-community/nix-direnv) in your user environment (managed via [lap](https://github.com/dataheld/lap) or installed manually). +1. For automatic flake-shell activation, provide [direnv](https://direnv.net) and [nix-direnv](https://github.com/nix-community/nix-direnv) in your user environment (managed via [lap](https://github.com/dataheld/lap) or installed manually). -1. Add the necessary boilerplate files. +1. In your project, add a `flake.nix` from one of the templates (`default`, `r` or `quarto`): ```sh nix flake init --template "https://flakehub.com/f/dataheld/nullkomma/0.1.*#default" ``` -1. (one-time only) Inside the repo, run `direnv allow` +1. Write the generated files (`.gitignore`, `.envrc`, CI workflows, editor settings): + + ```sh + git add flake.nix + nix run .#write-files + ``` + +1. (one-time only) Inside the repo, run `direnv allow`. > [!TIP] > Windows is not supported by Nix, @@ -62,14 +65,83 @@ From now on, whenever you change into the directory of your project, all the necessary dependencies etc. will be ready. The first time you enter the directory, this might take some time. -## Building +## Using + +A project's entire nullkomma footprint is its `flake.nix` (and `flake.lock`): + +```nix +{ + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + + outputs = + inputs: + inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = [ inputs.nullkomma.flakeModules.r ]; + }; +} +``` + +`nullkomma.lib.mkFlake` is [`flake-parts.lib.mkFlake`](https://flake.parts/getting-started) with `flakeModules.default` already imported, +so everything flake-parts can do, a nullkomma project can do, too. +The project needs no other inputs: Nixpkgs, flake-parts and treefmt-nix come pinned with nullkomma. -All build-targets are self-documented: +All tasks are self-documented: ```sh -make help +nix run ``` +Then, as usual: `nix develop`, `nix fmt`, `nix flake check`, `nix flake show`. + +### Aspects + +Each aspect is a flake-parts module in `flakeModules`. +Import only the ones a project needs; an aspect never adds anything to a project that does not import it. + +| Aspect | Adds | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default` | devshell, [treefmt](https://github.com/numtide/treefmt-nix) (Nix, Markdown, YAML, JSON, TOML, shell, GitHub Actions), tasks (`check`, `update`, `flake-checker`, `write-files`), generated `.gitignore`, `.envrc`, CI workflows and VS Code settings, `checks.files` | +| `r` | R with all packages from `DESCRIPTION`, [air](https://posit-dev.github.io/air/) (and optionally [jarl](https://jarl.etiennebacher.com)), `checks.r-cmd-check`, VS Code extensions | +| `quarto` | Quarto, `packages.site` (if there is a `_quarto.yml`), tasks `render` and `preview`; with `r`, R chunks run with the project's R environment | +| `cloudflare-pages` | task `deploy` of `packages.site` to [Cloudflare Pages](https://pages.cloudflare.com), run by CI on pushes to the default branch | + +### Knobs + +Options live under `nullkomma.*` (see the `options.nix` and other files in [`modules/`](modules)), for example: + +```nix +inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = with inputs.nullkomma.flakeModules; [ r quarto cloudflare-pages ]; + nullkomma = { + r.extraPackages = [ "devtools" ]; + cloudflare-pages.project = "my-site"; + github.ci.inputs.visibility = "public"; # publish to FlakeHub + }; + perSystem = + { pkgs, ... }: + { + nullkomma.devshell.packages = [ pkgs.hello ]; + treefmt.programs.typos.enable = true; + }; +} +``` + +To pin R and CRAN to a date, point nullkomma's `nixpkgs-r` input elsewhere +(this adds one node to `flake.lock`): + +```nix +inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; +``` + +### Generated files + +Some files must live in the repo, so nullkomma generates them: +`.gitignore`, `.envrc`, `.github/workflows/{push,cron}.yml` and `.vscode/*.json`. +Configure them in `flake.nix` (e.g. `nullkomma.gitignore`, `nullkomma.editor.vscode`), +then run `nix run .#write-files`; `checks.files` fails if they are out of date. +Commit them: flakes only see git-tracked files, so the generated `.gitignore` re-includes them, overriding a global `core.excludesFile`. +The workflows are thin stubs that call nullkomma's reusable [`ci.yml`](.github/workflows/ci.yml) and [`maintenance.yml`](.github/workflows/maintenance.yml). + ## Updating ### Nix @@ -77,21 +149,22 @@ make help There are two separate aspects to updating the nix dependencies. 1. There may be newer versions available _given_ the pinning in `flake.nix`. - This can be accomplished by running `make update-flake` locally and may change the `flake.lock`. + This can be accomplished by running `nix run .#update` locally and may change the `flake.lock`. However such updates may break a project. It is therefore recommended **to only run this in CI**, - using the periodically scheduled `nix_maintenance.yml` job. + using the periodically scheduled `cron.yml` workflow. It will automatically open pull requests if there are updates available. Users can then inspect whether the updated project still passes all tests. + If a new nullkomma generates different files, `checks.files` fails until you run `nix run .#write-files`. 1. The versions pinned in `flake.nix` (and the resulting `flake.lock`) itself may be out of date. The [DeterminateSystems/flake-checker](https://github.com/DeterminateSystems/flake-checker) will fail if this is the case. It runs on every push as well as periodically. - You can also run this locally using `make check-flake-checker`. + You can also run this locally using `nix run .#flake-checker`. ### Development Shell To bring the shell you are working in up to date with the _source_ -(`nix.flake`, etc.) +(`flake.nix`, etc.) of your repository: ```sh @@ -104,6 +177,19 @@ Or if you have `nix-direnv` installed,: nix-direnv-reload ``` +## Developing + +nullkomma follows the [dendritic pattern](https://github.com/mightyiam/dendritic): +every `.nix` file under [`modules/`](modules) is a flake-parts module, imported automatically +(files and directories starting with `_` are skipped). +Files add to the exported aspects by defining `flake.flakeModules.`, so one aspect can span many files +and one file can extend several aspects; [`modules/lang/quarto/r.nix`](modules/lang/quarto/r.nix), for example, wires R into Quarto only if both are imported. + +nullkomma uses itself: +its devshell, formatter, checks and this website come from the `dev` [partition](https://flake.parts/options/flake-parts-partitions.html), +whose extra inputs live in [`dev/flake.nix`](dev/flake.nix) and never end up in a project's `flake.lock`. +The fixtures in [`tests/fixtures`](tests/fixtures) are evaluated like projects using nullkomma, and their checks, devshells and sites are part of `nix flake check`. + ## Issues `nix-`/`direnv` can be a bit chatty on launch. diff --git a/_quarto.yml b/_quarto.yml index 1206970..ac74db9 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -1,5 +1,8 @@ project: type: website + # only the README; tests/fixtures holds other Quarto projects + render: + - index.qmd website: title: "nullkomma" description: "Opinionated 🤓, batteries-included 🔋, extra-DRY 🤌 Nix boilerplate." diff --git a/dev/flake.lock b/dev/flake.lock new file mode 100644 index 0000000..88457e6 --- /dev/null +++ b/dev/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "flake-schemas": { + "locked": { + "lastModified": 1780327564, + "narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=", + "rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6", + "revCount": 149, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.%2A" + } + }, + "root": { + "inputs": { + "flake-schemas": "flake-schemas" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/dev/flake.nix b/dev/flake.nix new file mode 100644 index 0000000..0999ef0 --- /dev/null +++ b/dev/flake.nix @@ -0,0 +1,7 @@ +{ + description = "Dev-only inputs of nullkomma; never land in consumers' flake.lock"; + + inputs.flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.*"; + + outputs = _: { }; +} diff --git a/flake.lock b/flake.lock index 1c315c0..e59a66a 100644 --- a/flake.lock +++ b/flake.lock @@ -1,227 +1,5 @@ { "nodes": { - "crane": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "revCount": 764, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.21.1/01997e40-19a9-7bc6-9dba-0585d6ed9a98/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/ipetkov/crane/0" - } - }, - "crane_2": { - "locked": { - "lastModified": 1779041105, - "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", - "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", - "revCount": 858, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.23.4/019e3726-d9ea-7820-aece-59009301cab1/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/ipetkov/crane/0" - } - }, - "crane_3": { - "locked": { - "lastModified": 1745454774, - "narHash": "sha256-oLvmxOnsEKGtwczxp/CwhrfmQUG2ym24OMWowcoRhH8=", - "rev": "efd36682371678e2b6da3f108fdb5c613b3ec598", - "revCount": 729, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.20.3/01966538-0f80-7363-a573-2ba9fd154399/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/ipetkov/crane/0" - } - }, - "crane_4": { - "locked": { - "lastModified": 1745454774, - "narHash": "sha256-oLvmxOnsEKGtwczxp/CwhrfmQUG2ym24OMWowcoRhH8=", - "rev": "efd36682371678e2b6da3f108fdb5c613b3ec598", - "revCount": 729, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/ipetkov/crane/0.20.3/01966538-0f80-7363-a573-2ba9fd154399/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/ipetkov/crane/0.20" - } - }, - "easy-template": { - "inputs": { - "crane": "crane_3", - "fenix": "fenix_2", - "nixpkgs": [ - "flake-checker", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1750267549, - "narHash": "sha256-febr39vSeQ1H54mIhyIB+c2p/2xdntabf4grEQIj60M=", - "rev": "6fb441f657e98f31024f19ca68b691c5b0c3fdd6", - "revCount": 5, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/easy-template/0.1.5%2Brev-6fb441f657e98f31024f19ca68b691c5b0c3fdd6/01978415-c996-7747-a25c-77ca3a4c5ed9/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/easy-template/0" - } - }, - "fenix": { - "inputs": { - "nixpkgs": [ - "fh", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1756709111, - "narHash": "sha256-xv2u5dnQpdWkrIy5TBSomr055odWtRSoECSGBzNpp3w=", - "rev": "113eba389317407992ea219d5aaced44bf6407f9", - "revCount": 2375, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2375%2Brev-113eba389317407992ea219d5aaced44bf6407f9/0199045f-8452-723d-b201-deb22d41c75e/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/nix-community/fenix/%3D0.1.2375" - } - }, - "fenix_2": { - "inputs": { - "nixpkgs": [ - "flake-checker", - "easy-template", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_2" - }, - "locked": { - "lastModified": 1740810935, - "narHash": "sha256-6RzWfxENGlO73jQb3uQNgOvubUFwvveeIg+PZxhAu6s=", - "rev": "f44d7c3596ff028ad9f7fcc31d1941ed585f11b3", - "revCount": 2184, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2184%2Brev-f44d7c3596ff028ad9f7fcc31d1941ed585f11b3/019550c8-7792-7766-8dd2-80fad5595f70/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/nix-community/fenix/0.1" - } - }, - "fenix_3": { - "inputs": { - "nixpkgs": [ - "flake-checker", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_3" - }, - "locked": { - "lastModified": 1782900513, - "narHash": "sha256-GHrsl1+ysDFgmDQtQSqWS7TiYD2Q58VlwLvnf1+crJM=", - "rev": "16810aa8f4ad89ca480b1513774e8b6f485fe368", - "revCount": 2708, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2708%2Brev-16810aa8f4ad89ca480b1513774e8b6f485fe368/019f1d68-8b55-7783-bed1-d5926f21ae52/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/nix-community/fenix/0.1" - } - }, - "fenix_4": { - "inputs": { - "nixpkgs": [ - "flake-iter", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_4" - }, - "locked": { - "lastModified": 1761979010, - "narHash": "sha256-isqMvjTk3jdTHN6KA/BWQvOSVe7O35OQKAZNtLK76OY=", - "rev": "3107255abfe4f2d1c3eee7a3e2f5a5eb6f2200fe", - "revCount": 2439, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2439%2Brev-3107255abfe4f2d1c3eee7a3e2f5a5eb6f2200fe/019a3e7f-523d-7e82-b791-0e92cf55a0a9/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/nix-community/fenix/0.1.1885" - } - }, - "fh": { - "inputs": { - "crane": "crane", - "fenix": "fenix", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1761780713, - "narHash": "sha256-EUCV7/J9wJRroCGW5JqonFJIqcvJEBAwB7l3eWYxiSk=", - "rev": "4f001f2e1de4776f01cf22d1de815f1016a4c4c9", - "revCount": 786, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/fh/0.1.27/019a355b-4c74-769d-a9ad-232df447089d/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/fh/0.1.%2A" - } - }, - "flake-checker": { - "inputs": { - "crane": "crane_2", - "easy-template": "easy-template", - "fenix": "fenix_3", - "nixpkgs": "nixpkgs_2" - }, - "locked": { - "lastModified": 1790188256, - "narHash": "sha256-J0RAJJdpKYgMeV8+aojCRKVkXWa4PzdaAJHLjZInB4E=", - "rev": "cddc8afc9733e3ff695df50f2be3d8d85345c9eb", - "revCount": 740, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-checker/0.2.15/01a0cf8b-8383-7b53-a0bf-5aa9bee1994a/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/flake-checker/0.2.%2A" - } - }, - "flake-iter": { - "inputs": { - "crane": "crane_4", - "fenix": "fenix_4", - "flake-schemas": "flake-schemas", - "nixpkgs": "nixpkgs_3" - }, - "locked": { - "lastModified": 1764128805, - "narHash": "sha256-Ufi2j3Pd87IG8Agd/B4fy0MfYVZPqGa1c82id85gajE=", - "rev": "e4188aaef2c61df96c5fa2d88c764adecf69e700", - "revCount": 134, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-iter/0.1.134%2Brev-e4188aaef2c61df96c5fa2d88c764adecf69e700/019abe49-7872-7f0c-9805-2d163605f92b/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/flake-iter/0.1.%2A" - } - }, "flake-parts": { "inputs": { "nixpkgs-lib": [ @@ -241,262 +19,43 @@ "url": "https://flakehub.com/f/hercules-ci/flake-parts/0.1.%2A" } }, - "flake-schemas": { - "locked": { - "lastModified": 1761577921, - "narHash": "sha256-eK3/xbUOrxp9fFlei09XNjqcdiHXxndzrTXp7jFpOk8=", - "rev": "47849c7625e223d36766968cc6dc23ba0e135922", - "revCount": 107, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.2.0/019a4a84-544d-7c59-b26d-e334e320c932/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%2A" - } - }, - "flake-schemas_2": { - "locked": { - "lastModified": 1780327564, - "narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=", - "rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6", - "revCount": 149, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.%2A" - } - }, - "nix-github-actions": { - "inputs": { - "nixpkgs": [ - "nix-unit", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1737420293, - "narHash": "sha256-F1G5ifvqTpJq7fdkT34e/Jy9VCyzd5XfJ9TO8fHhJWE=", - "owner": "nix-community", - "repo": "nix-github-actions", - "rev": "f4158fa080ef4503c8f4c820967d946c2af31ec9", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-github-actions", - "type": "github" - } - }, - "nix-unit": { - "inputs": { - "nix-github-actions": "nix-github-actions", - "nixpkgs": [ - "nixpkgs" - ], - "treefmt-nix": "treefmt-nix" - }, - "locked": { - "lastModified": 1784020394, - "narHash": "sha256-eG+ETC+lvHDLhlKgQB6/lGfpqr56Lt3+j1pHOeb4tK4=", - "owner": "nix-community", - "repo": "nix-unit", - "rev": "3a484e17e4911ed97d56e37506f22e294c7c0ccd", - "type": "github" - }, - "original": { - "owner": "nix-community", - "ref": "v2.34.2", - "repo": "nix-unit", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1761468971, - "narHash": "sha256-vY2OLVg5ZTobdroQKQQSipSIkHlxOTrIF1fsMzPh8w8=", - "rev": "78e34d1667d32d8a0ffc3eba4591ff256e80576e", - "revCount": 811770, + "lastModified": 1791353474, + "narHash": "sha256-v72qr4LsSz61tQki/41nHKZKPY6NSeZavtGlAInOCng=", + "rev": "2efa67fd26b6df417c33e4603185c701f260dd83", + "revCount": 1015818, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2505.811770%2Brev-78e34d1667d32d8a0ffc3eba4591ff256e80576e/019a23fb-89f0-7302-a573-f6bf7dde9cf5/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/NixOS/nixpkgs/0" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1789286504, - "narHash": "sha256-eiEK7cKZORNEvX0GeF3RtNEF/JXhgf2RqSp3230q13E=", - "rev": "ef34387ddd751e1ab8857adf4676492d32eb24ec", - "revCount": 1073009, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.1073009%2Brev-ef34387ddd751e1ab8857adf4676492d32eb24ec/01a09f6e-d65c-7c23-9b52-4dcddb9c94b7/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" - } - }, - "nixpkgs_3": { - "locked": { - "lastModified": 1763948260, - "narHash": "sha256-dY9qLD0H0zOUgU3vWacPY6Qc421BeQAfm8kBuBtPVE0=", - "rev": "1c8ba8d3f7634acac4a2094eef7c32ad9106532c", - "revCount": 813095, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2505.813095%2Brev-1c8ba8d3f7634acac4a2094eef7c32ad9106532c/019ab6d8-0005-7317-844d-5d868444249f/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/NixOS/nixpkgs/0" - } - }, - "nixpkgs_4": { - "locked": { - "lastModified": 1790920529, - "narHash": "sha256-nQyFkMR78WP6PiXKkDW2SjqzLf/spQqjRuxRGSbcV8k=", - "rev": "774debe7a0d1b496e35677ad955a1011c6ff74f3", - "revCount": 1015336, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1015336%2Brev-774debe7a0d1b496e35677ad955a1011c6ff74f3/01a0fdb4-e493-7812-bab4-ef8e5029af88/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1015818%2Brev-2efa67fd26b6df417c33e4603185c701f260dd83/01a11777-646c-7ac3-9a38-941658c52e27/source.tar.gz" }, "original": { "type": "tarball", "url": "https://flakehub.com/f/NixOS/nixpkgs/0.2605.%2A" } }, - "nixpkgs_5": { - "locked": { - "lastModified": 1770107345, - "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { - "fh": "fh", - "flake-checker": "flake-checker", - "flake-iter": "flake-iter", "flake-parts": "flake-parts", - "flake-schemas": "flake-schemas_2", - "nix-unit": "nix-unit", - "nixpkgs": "nixpkgs_4", - "treefmt-nix": "treefmt-nix_2" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1756597274, - "narHash": "sha256-wfaKRKsEVQDB7pQtAt04vRgFphkVscGRpSx3wG1l50E=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "21614ed2d3279a9aa1f15c88d293e65a98991b30", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_2": { - "flake": false, - "locked": { - "lastModified": 1740737930, - "narHash": "sha256-2AW/FJQI/i6bbRB/8HR9l9SjxjuiukJpHdMPgwApPKA=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "fe8444616679f8e50ff9696f4750df1f10e7433d", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_3": { - "flake": false, - "locked": { - "lastModified": 1782864348, - "narHash": "sha256-NVYhLbaefeIUftPlo3kS6qr0xd8eFJRodEiaHrvFKR4=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "0d381ca097a8e0375a19387874d952c0a230ac4f", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_4": { - "flake": false, - "locked": { - "lastModified": 1761894503, - "narHash": "sha256-SreGV62DEv7kLJEcOBrw2V6Kup0siT4wS3Ye8PlFTdE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "2e2e3ebec91215078de9b754363fc9a7b0fdef13", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" + "nixpkgs": "nixpkgs", + "nixpkgs-r": [ + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" } }, "treefmt-nix": { "inputs": { "nixpkgs": [ - "nix-unit", "nixpkgs" ] }, "locked": { - "lastModified": 1780220602, - "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", - "owner": "numtide", - "repo": "treefmt-nix", - "rev": "db947814a175b7ca6ded66e21383d938df01c227", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "treefmt-nix", - "type": "github" - } - }, - "treefmt-nix_2": { - "inputs": { - "nixpkgs": "nixpkgs_5" - }, - "locked": { - "lastModified": 1786901030, - "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", - "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", - "revCount": 557, + "lastModified": 1791203292, + "narHash": "sha256-vuzVwvjeucSnwKwMBpyP8r5IzdrMKCHEdwTrRFwUN7I=", + "rev": "03d8ee1bcbc8f9638907bb79edb9673151d0e22b", + "revCount": 593, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/numtide/treefmt-nix/0.1.557%2Brev-27b3b12a8e6375f28ebe122f07d230ca5459bbfa/01a00ba7-ea6f-7822-ab05-cbad971fc7ca/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/numtide/treefmt-nix/0.1.593%2Brev-03d8ee1bcbc8f9638907bb79edb9673151d0e22b/01a10d28-fe85-7ba0-9d91-820ac13e70a3/source.tar.gz" }, "original": { "type": "tarball", diff --git a/flake.nix b/flake.nix index 31f2a27..e8f95b9 100644 --- a/flake.nix +++ b/flake.nix @@ -1,158 +1,27 @@ { - description = "nullkomma"; + description = "Opinionated, batteries-included, extra-DRY Nix boilerplate"; + # Every input lands in every consumer's flake.lock, so keep this list short. + # Tools come from nixpkgs; dev-only inputs live in ./dev/flake.nix. inputs = { nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2605.*"; - # keep-sorted start - fh.url = "https://flakehub.com/f/DeterminateSystems/fh/0.1.*"; - flake-checker.url = "https://flakehub.com/f/DeterminateSystems/flake-checker/0.2.*"; - flake-iter.url = "https://flakehub.com/f/DeterminateSystems/flake-iter/0.1.*"; - # keep-sorted end + # Knob: R version and CRAN snapshot. Free unless a consumer overrides it, e.g. + # inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; + nixpkgs-r.follows = "nixpkgs"; flake-parts = { - inputs.nixpkgs-lib.follows = "nixpkgs"; url = "https://flakehub.com/f/hercules-ci/flake-parts/0.1.*"; + inputs.nixpkgs-lib.follows = "nixpkgs"; }; - flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.*"; - nix-unit = { - # Pin to 2.34.x: nixpkgs 0.2605 has nixComponents_2_34, not _2_35 - url = "github:nix-community/nix-unit/v2.34.2"; - inputs = { - nixpkgs.follows = "nixpkgs"; - }; + treefmt-nix = { + url = "https://flakehub.com/f/numtide/treefmt-nix/0.1.*"; + inputs.nixpkgs.follows = "nixpkgs"; }; - treefmt-nix.url = "https://flakehub.com/f/numtide/treefmt-nix/0.1.*"; }; + # Dendritic: every file under ./modules is a flake-parts module. outputs = - { flake-parts, self, ... }@inputs: - flake-parts.lib.mkFlake { inherit inputs; } { - imports = [ - # keep-sorted start - inputs.nix-unit.modules.flake.default - inputs.treefmt-nix.flakeModule - # keep-sorted end - ]; - systems = [ - # keep-sorted start - "aarch64-darwin" - "aarch64-linux" - "x86_64-linux" - # keep-sorted end - ]; - perSystem = - { - inputs', - pkgs, - self', - ... - }: - let - # workaround for https://github.com/NixOS/nixpkgs/issues/519484 - # quarto 1.9.37 emits "syntax-highlighting" but pandoc 3.7+ expects "highlight-style" - quartoMinimal = pkgs.quartoMinimal.overrideAttrs (oldAttrs: { - postPatch = (oldAttrs.postPatch or "") + '' - substituteInPlace bin/quarto.js \ - --replace-fail "syntax-highlighting" "highlight-style" - ''; - }); - in - { - devShells.default = pkgs.mkShell { - packages = [ - # keep-sorted start - inputs'.fh.packages.default - inputs'.flake-checker.packages.default - inputs'.flake-iter.packages.default - pkgs.git - pkgs.gnumake - pkgs.nixd - quartoMinimal - self'.formatter - # keep-sorted end - ]; - }; - packages = { - docs = pkgs.stdenv.mkDerivation { - name = "nullkomma-docs"; - src = ./.; - nativeBuildInputs = [ - pkgs.writableTmpDirAsHomeHook - ]; - buildInputs = [ - quartoMinimal - pkgs.which - ]; - buildPhase = '' - make render - ''; - installPhase = '' - mkdir $out - mv _site $out - ''; - }; - }; - treefmt = { - programs = { - # keep-sorted start - actionlint.enable = true; - beautysh.enable = true; - black.enable = true; - cmake-format.enable = true; - deadnix.enable = true; - hclfmt.enable = true; - isort.enable = true; - jsonfmt.enable = true; - keep-sorted.enable = true; - mdformat.enable = true; - nixfmt.enable = true; - prettier.enable = true; - shellcheck.enable = true; - shfmt.enable = true; - sqlfluff.enable = true; - toml-sort.enable = true; - yamlfmt.enable = true; - # keep-sorted end - }; - }; - nix-unit = { - inputs = { - inherit (inputs) - nixpkgs - flake-parts - nix-unit - treefmt-nix - ; - }; - tests = { - "test example system-specific" = { - expr = "1"; - expected = "1"; - }; - }; - }; - }; - flake = { - schemas = inputs.flake-schemas.schemas; - templates = rec { - base = { - description = "nullkomma template"; - path = ./templates/base; - welcomeText = '' - Welcome to the nullkomma template - ''; - }; - default = base; - }; - tests = { - "test example system-agnostic" = { - expr = "2"; - expected = "2"; - }; - "test template welcome text" = { - expr = self.templates.default.description; - expected = "nullkomma template"; - }; - }; - }; + inputs: + inputs.flake-parts.lib.mkFlake { inherit inputs; } { + imports = import ./lib/import-tree.nix inputs.nixpkgs.lib ./modules; }; } diff --git a/lib/import-tree.nix b/lib/import-tree.nix new file mode 100644 index 0000000..c4ec407 --- /dev/null +++ b/lib/import-tree.nix @@ -0,0 +1,9 @@ +# Dependency-free take on vic/import-tree: list every `.nix` file below `root`, +# skipping anything whose path (relative to `root`) contains a `/_` segment. +lib: root: +let + relative = path: lib.removePrefix (toString root) (toString path); +in +builtins.filter (path: lib.hasSuffix ".nix" (toString path) && !lib.hasInfix "/_" (relative path)) ( + lib.filesystem.listFilesRecursive root +) diff --git a/lib/r.nix b/lib/r.nix new file mode 100644 index 0000000..a8d41e9 --- /dev/null +++ b/lib/r.nix @@ -0,0 +1,73 @@ +# Pure helpers for R projects; no packages, no IFD. +{ lib }: +rec { + # Packages that ship with R itself and are therefore not in `rPackages`. + basePackages = [ + "R" + "base" + "compiler" + "datasets" + "grDevices" + "graphics" + "grid" + "methods" + "parallel" + "splines" + "stats" + "stats4" + "tcltk" + "tools" + "utils" + ]; + + # Minimal Debian Control File (DCF) parser for R DESCRIPTION files. + parseDCF = + text: + let + lines = lib.filter (l: lib.trim l != "") (lib.splitString "\n" text); + folded = lib.foldl' ( + acc: l: + if acc != [ ] && builtins.match "[ \t].*" l != null then + lib.init acc ++ [ (lib.last acc + " " + lib.trim l) ] + else + acc ++ [ l ] + ) [ ] lines; + field = + l: + let + m = builtins.match "([^: \t]+):[ \t]*(.*)" l; + in + if m == null then + throw "nullkomma: cannot parse DCF line `${l}`" + else + lib.nameValuePair (lib.elemAt m 0) (lib.trim (lib.elemAt m 1)); + in + lib.listToAttrs (map field folded); + + # "foo (>= 1.0), bar,\n baz" -> [ "foo" "bar" "baz" ], minus base packages. + depNames = + s: + lib.filter (n: n != "" && !lib.elem n basePackages) ( + map (d: lib.trim (lib.head (lib.splitString "(" d))) (lib.splitString "," s) + ); + + defaultFields = [ + "Depends" + "Imports" + "LinkingTo" + "Suggests" + ]; + + depsFromDescription = + { + path, + fields ? defaultFields, + }: + let + d = parseDCF (builtins.readFile path); + in + lib.unique (lib.concatMap (f: depNames (d.${f} or "")) fields); + + # nixpkgs mangles CRAN names: data.table -> data_table + attrName = lib.replaceStrings [ "." ] [ "_" ]; +} diff --git a/make/help.mk b/make/help.mk deleted file mode 100644 index ac228b2..0000000 --- a/make/help.mk +++ /dev/null @@ -1,55 +0,0 @@ -# from https://gist.github.com/klmr/575726c7e05d8780505a -# Inspired by -# sed script explained: -# /^##/: -# * save line in hold space -# * purge line -# * Loop: -# * append newline + line to hold space -# * go to next line -# * if line starts with doc comment, strip comment character off and loop -# * remove target prerequisites -# * append hold space (+ newline) to line -# * replace newline plus comments by `---` -# * print line -# Separate expressions are necessary because labels cannot be delimited by -# semicolon; see -.PHONY: help -help: - @echo "$$(tput bold)Available rules:$$(tput sgr0)" - @echo - @sed -n -e "/^## / { \ - h; \ - s/.*//; \ - :doc" \ - -e "H; \ - n; \ - s/^## //; \ - t doc" \ - -e "s/:.*//; \ - G; \ - s/\\n## /---/; \ - s/\\n/ /g; \ - p; \ - }" ${MAKEFILE_LIST} \ - | LC_ALL='C' sort --ignore-case \ - | awk -F '---' \ - -v ncol=$$(tput cols) \ - -v indent=19 \ - -v col_on="$$(tput setaf 6)" \ - -v col_off="$$(tput sgr0)" \ - '{ \ - printf "%s%*s%s ", col_on, -indent, $$1, col_off; \ - n = split($$2, words, " "); \ - line_length = ncol - indent; \ - for (i = 1; i <= n; i++) { \ - line_length -= length(words[i]) + 1; \ - if (line_length <= 0) { \ - line_length = ncol - indent - length(words[i]) - 1; \ - printf "\n%*s ", -indent, " "; \ - } \ - printf "%s ", words[i]; \ - } \ - printf "\n"; \ - }' \ - | more $(shell test $(shell uname) == Darwin && echo '--no-init --raw-control-chars') diff --git a/make/main.mk b/make/main.mk deleted file mode 100644 index c759e13..0000000 --- a/make/main.mk +++ /dev/null @@ -1,21 +0,0 @@ -.DELETE_ON_ERROR: - -.PHONY: all help - -.DEFAULT_GOAL := help - -## Run all targets -all: check-flake render - -all-check: - check-nix - -.PHONY: gha -## Run approximately what is run on GitHub Actions -gha: gha-push - -.PHONY: gha-push -gha-push: build-nix check-flake-checker - -.PHONY: gha-cron -gha-cron: check-flake-checker update-flake diff --git a/make/nix.mk b/make/nix.mk deleted file mode 100644 index 4dc1f03..0000000 --- a/make/nix.mk +++ /dev/null @@ -1,34 +0,0 @@ -NIX_FILES := $(wildcard *.nix) - -.PHONY: build-nix -## Build all flake outputs with derivations -build-nix: - flake-iter build - -.PHONY: check-nix -## Check all nix stuff -check-nix: check-flake check-flake-checker - -.PHONY: check-flake -# Check nix flake for bad syntax etc. -check-flake: - nix flake check --all-systems - -.PHONY: check-flake-checker -# Check nix flake for outdated dependencies, etc. -check-flake-checker: flake.lock - flake-checker - -.PHONY: update-flake -# Update flake.lock file -update-flake: - nix flake update - -# Complete flake.lock file -flake.lock: $(NIX_FILES) - nix flake lock - -.PHONY: format -# Format all files - format: - treefmt diff --git a/make/quarto.mk b/make/quarto.mk deleted file mode 100644 index e81d401..0000000 --- a/make/quarto.mk +++ /dev/null @@ -1,4 +0,0 @@ -.PHONY: render - -render: - quarto render diff --git a/modules/core/devshell.nix b/modules/core/devshell.nix new file mode 100644 index 0000000..0a19041 --- /dev/null +++ b/modules/core/devshell.nix @@ -0,0 +1,43 @@ +{ + flake.flakeModules.default = + { lib, ... }: + { + perSystem = + { config, pkgs, ... }: + let + cfg = config.nullkomma.devshell; + in + { + options.nullkomma.devshell = { + packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Packages on `PATH` in `devShells.default`; every aspect appends here."; + }; + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables set in `devShells.default`."; + }; + shellHook = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Shell code run when entering `devShells.default`."; + }; + }; + config = { + nullkomma.devshell.packages = [ + config.treefmt.build.wrapper + pkgs.flake-checker + pkgs.git + pkgs.nixd + ]; + devShells.default = lib.mkDefault ( + pkgs.mkShell { + inherit (cfg) packages env shellHook; + } + ); + }; + }; + }; +} diff --git a/modules/core/direnv.nix b/modules/core/direnv.nix new file mode 100644 index 0000000..f5a7c1e --- /dev/null +++ b/modules/core/direnv.nix @@ -0,0 +1,7 @@ +{ + flake.flakeModules.default = { + perSystem.nullkomma.files.".envrc".text = '' + use flake + ''; + }; +} diff --git a/modules/core/editor.nix b/modules/core/editor.nix new file mode 100644 index 0000000..49b62ed --- /dev/null +++ b/modules/core/editor.nix @@ -0,0 +1,72 @@ +{ + flake.flakeModules.default = + { config, lib, ... }: + let + cfg = config.nullkomma.editor.vscode; + in + { + options.nullkomma.editor.vscode = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to generate `.vscode/settings.json` and `.vscode/extensions.json`."; + }; + extensions = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Recommended VS Code extensions; every aspect appends here."; + }; + settings = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = "VS Code workspace settings; every aspect merges into these."; + }; + }; + config = { + nullkomma.editor.vscode = { + extensions = [ + "arrterian.nix-env-selector" + "ibecker.treefmt-vscode" + "jnoortheen.nix-ide" + ]; + settings = lib.mapAttrs (_: lib.mkDefault) { + "nixEnvSelector.nixFile" = "\${workspaceFolder}/flake.nix"; + "nixEnvSelector.useFlakes" = true; + "nixEnvSelector.suggestion" = false; + "nix.enableLanguageServer" = true; + "nix.serverPath" = "nixd"; + "nix.serverSettings".nixd.formatting.command = [ + "treefmt" + "--stdin" + "{file}" + ]; + "nix.formatterPath" = [ + "treefmt" + "--stdin" + "{file}" + ]; + "nix.hiddenLanguageServerErrors" = [ + "textDocument/definition" + "textDocument/completion" + "textDocument/documentHighlight" + ]; + "editor.defaultFormatter" = "ibecker.treefmt-vscode"; + "notebook.defaultFormatter" = "ibecker.treefmt-vscode"; + }; + }; + perSystem = + { pkgs, ... }: + let + json = pkgs.formats.json { }; + in + { + nullkomma.files = lib.mkIf cfg.enable { + ".vscode/extensions.json".source = json.generate "vscode-extensions.json" { + recommendations = lib.sort lib.lessThan (lib.unique cfg.extensions); + }; + ".vscode/settings.json".source = json.generate "vscode-settings.json" cfg.settings; + }; + }; + }; + }; +} diff --git a/modules/core/files.nix b/modules/core/files.nix new file mode 100644 index 0000000..9c8b1b8 --- /dev/null +++ b/modules/core/files.nix @@ -0,0 +1,99 @@ +# Files that must live in the repo (.gitignore, .envrc, CI stubs, editor config) +# are owned by nullkomma: `nix run .#write-files` writes them, `checks.files` fails on drift. +{ + flake.flakeModules.default = + { lib, self, ... }: + { + perSystem = + { config, pkgs, ... }: + let + files = lib.filterAttrs (_: f: f.enable) config.nullkomma.files; + banner = "Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`."; + fileModule = + { name, config, ... }: + { + options = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to manage `${name}`."; + }; + text = lib.mkOption { + type = lib.types.nullOr lib.types.lines; + default = null; + description = "File content; sets `source`."; + }; + comment = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "#"; + description = "Line comment token for the generated-file banner prepended to `text`; null disables it."; + }; + source = lib.mkOption { + type = lib.types.path; + description = "Store path whose content is written to `${name}`."; + }; + }; + config.source = lib.mkIf (config.text != null) ( + pkgs.writeText "nullkomma-${lib.replaceStrings [ "/" ] [ "-" ] name}" ( + lib.optionalString (config.comment != null) "${config.comment} ${banner}\n" + config.text + ) + ); + }; + in + { + options.nullkomma.files = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule fileModule); + default = { }; + description = "Files, keyed by path relative to the repo root, that nullkomma writes and checks."; + }; + config = { + nullkomma.tasks.write-files = { + description = "Write nullkomma-managed files (.gitignore, .envrc, CI stubs, …) into the repo"; + runtimeInputs = [ + pkgs.coreutils + pkgs.git + ]; + command = '' + root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + '' + + lib.concatStrings ( + lib.mapAttrsToList (path: f: '' + install -D -m 644 ${f.source} "$root"/${lib.escapeShellArg path} + echo "wrote ${path}" + '') files + ); + }; + checks = lib.mkIf (files != { }) { + files = pkgs.runCommandLocal "nullkomma-files" { nativeBuildInputs = [ pkgs.diffutils ]; } ( + '' + status=0 + '' + + lib.concatStrings ( + lib.mapAttrsToList ( + path: f: + let + repoFile = lib.escapeShellArg "${self}/${path}"; + in + '' + if [ -e ${repoFile} ]; then + diff -u ${repoFile} ${f.source} || status=1 + else + echo "nullkomma: ${path} is missing from the flake source (not written or not git-added?)" >&2 + status=1 + fi + '' + ) files + ) + + '' + if [ "$status" -ne 0 ]; then + echo "nullkomma: generated files are out of date; run: nix run .#write-files" >&2 + exit 1 + fi + touch $out + '' + ); + }; + }; + }; + }; +} diff --git a/modules/core/github.nix b/modules/core/github.nix new file mode 100644 index 0000000..cf61e3e --- /dev/null +++ b/modules/core/github.nix @@ -0,0 +1,99 @@ +# Thin GitHub Actions stubs that call nullkomma's reusable workflows, so CI logic +# is upgraded centrally (.github/workflows/{ci,maintenance}.yml in this repo). +{ + flake.flakeModules.default = + { config, lib, ... }: + let + cfg = config.nullkomma.github; + uses = + name: + "${cfg.workflows.source}/${name}.yml" + + lib.optionalString (cfg.workflows.ref != null) "@${cfg.workflows.ref}"; + scalar = v: if lib.isBool v then lib.boolToString v else builtins.toJSON v; + ci = '' + name: CI + on: + pull_request: + push: + workflow_dispatch: + concurrency: + group: ''${{ github.workflow }}-''${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + nullkomma: + uses: ${uses "ci"} + permissions: + contents: read + id-token: write + '' + + lib.optionalString (cfg.ci.secrets != [ ]) ( + " secrets:\n" + + lib.concatMapStrings (s: " ${s}: \${{ secrets.${s} }}\n") (lib.unique cfg.ci.secrets) + ) + + lib.optionalString (cfg.ci.inputs != { }) ( + " with:\n" + + lib.concatStrings (lib.mapAttrsToList (k: v: " ${k}: ${scalar v}\n") cfg.ci.inputs) + ); + maintenance = '' + name: Maintenance + on: + schedule: + - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC + workflow_dispatch: + jobs: + nullkomma: + uses: ${uses "maintenance"} + permissions: + contents: write + id-token: write + issues: write + pull-requests: write + ''; + in + { + options.nullkomma.github = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to generate `.github/workflows/{push,cron}.yml`."; + }; + workflows = { + source = lib.mkOption { + type = lib.types.str; + default = "dataheld/nullkomma/.github/workflows"; + description = "Where the reusable workflows live (`./.github/workflows` for local ones)."; + }; + ref = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "main"; + description = "Git ref of the reusable workflows; null for local workflows."; + }; + }; + ci = { + inputs = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.oneOf [ + lib.types.bool + lib.types.int + lib.types.str + ] + ); + default = { }; + example = { + visibility = "public"; + }; + description = "Inputs of the reusable `ci.yml` (`visibility` publishes to FlakeHub, `deploy` runs `nix run .#deploy`)."; + }; + secrets = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Repository secrets passed to the reusable `ci.yml`."; + }; + }; + }; + config.perSystem.nullkomma.files = lib.mkIf cfg.enable { + ".github/workflows/push.yml".text = ci; + ".github/workflows/cron.yml".text = maintenance; + }; + }; +} diff --git a/modules/core/gitignore.nix b/modules/core/gitignore.nix new file mode 100644 index 0000000..e9eb232 --- /dev/null +++ b/modules/core/gitignore.nix @@ -0,0 +1,32 @@ +{ + flake.flakeModules.default = + { config, lib, ... }: + let + ignores = lib.unique config.nullkomma.gitignore; + in + { + options.nullkomma.gitignore = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Lines of the generated `.gitignore`; every aspect appends here."; + }; + config = { + nullkomma.gitignore = [ + ".direnv/" + "result" + "result-*" + ]; + # Managed files are re-included last, so a global excludesFile (e.g. one ignoring + # `.vscode/settings.json` or `.envrc`) can't keep them out of the flake source. + perSystem = + { config, ... }: + { + nullkomma.files.".gitignore".text = lib.concatLines ( + ignores + ++ [ "# nullkomma-managed files" ] + ++ map (path: "!/${path}") (lib.attrNames (lib.filterAttrs (_: f: f.enable) config.nullkomma.files)) + ); + }; + }; + }; +} diff --git a/modules/core/pkgs.nix b/modules/core/pkgs.nix new file mode 100644 index 0000000..2977801 --- /dev/null +++ b/modules/core/pkgs.nix @@ -0,0 +1,18 @@ +# Consumers need no nixpkgs input: nullkomma's curated pin is the default `pkgs`. +{ inputs, ... }: +{ + flake.flakeModules.default = + { lib, ... }: + { + systems = lib.mkDefault [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + perSystem = + { system, ... }: + { + _module.args.pkgs = lib.mkDefault inputs.nixpkgs.legacyPackages.${system}; + }; + }; +} diff --git a/modules/core/site.nix b/modules/core/site.nix new file mode 100644 index 0000000..ca37c88 --- /dev/null +++ b/modules/core/site.nix @@ -0,0 +1,19 @@ +# Extension point: a docs aspect (quarto, pkgdown, …) sets the site, deploy aspects read it. +{ + flake.flakeModules.default = + { lib, ... }: + { + perSystem = + { config, ... }: + { + options.nullkomma.site = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = "Static site (a directory with `index.html`); exposed as `packages.site`."; + }; + config.packages = lib.mkIf (config.nullkomma.site != null) { + site = config.nullkomma.site; + }; + }; + }; +} diff --git a/modules/core/tasks.nix b/modules/core/tasks.nix new file mode 100644 index 0000000..4bf91a5 --- /dev/null +++ b/modules/core/tasks.nix @@ -0,0 +1,84 @@ +# Named tasks become `apps.` (`nix run .#`); `nix run` lists them. +{ + flake.flakeModules.default = + { lib, ... }: + { + perSystem = + { config, pkgs, ... }: + let + cfg = config.nullkomma.tasks; + taskModule = { + options = { + description = lib.mkOption { + type = lib.types.str; + description = "One-line description, shown by `nix run`."; + }; + command = lib.mkOption { + type = lib.types.lines; + description = "Bash script (checked by shellcheck); arguments are in `$@`."; + }; + runtimeInputs = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Packages on `PATH` while the task runs."; + }; + }; + }; + toApp = name: task: { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + inherit name; + inherit (task) runtimeInputs; + text = task.command; + } + ); + meta.description = task.description; + }; + width = lib.foldl' lib.max 0 (map lib.stringLength (lib.attrNames cfg)) + 2; + pad = s: s + lib.concatStrings (lib.genList (_: " ") (width - lib.stringLength s)); + help = pkgs.writeShellApplication { + name = "nullkomma-help"; + text = '' + cat <<'EOF' + Tasks (nix run .#): + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: t: " ${pad n}${t.description}") cfg)} + + Also: nix develop, nix fmt, nix flake check, nix flake show + EOF + ''; + }; + in + { + options.nullkomma.tasks = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule taskModule); + default = { }; + description = "Project tasks (the Makefile replacement), exposed as `apps.`."; + }; + config = { + nullkomma.tasks = { + check = { + description = "Evaluate and build all checks (nix flake check)"; + command = ''exec nix flake check "$@"''; + }; + flake-checker = { + description = "Check flake.lock for outdated or non-upstream Nixpkgs"; + runtimeInputs = [ pkgs.flake-checker ]; + command = ''exec flake-checker "$@"''; + }; + update = { + description = "Update flake.lock (prefer the scheduled maintenance workflow)"; + command = ''exec nix flake update "$@"''; + }; + }; + apps = lib.mapAttrs toApp cfg // { + default = lib.mkDefault { + type = "app"; + program = lib.getExe help; + meta.description = "List nullkomma tasks"; + }; + }; + }; + }; + }; +} diff --git a/modules/core/treefmt.nix b/modules/core/treefmt.nix new file mode 100644 index 0000000..817f189 --- /dev/null +++ b/modules/core/treefmt.nix @@ -0,0 +1,38 @@ +{ inputs, ... }: +{ + flake.flakeModules.default = + { lib, ... }: + { + imports = [ inputs.treefmt-nix.flakeModule ]; + perSystem = + { config, ... }: + { + treefmt = { + programs = { + # keep-sorted start block=yes + actionlint.enable = lib.mkDefault true; + deadnix.enable = lib.mkDefault true; + jsonfmt.enable = lib.mkDefault true; + keep-sorted.enable = lib.mkDefault true; + mdformat = { + enable = lib.mkDefault true; + plugins = lib.mkDefault (ps: [ + ps.mdformat-footnote + ps.mdformat-frontmatter + ps.mdformat-gfm + ps.mdformat-gfm-alerts + ]); + }; + nixfmt.enable = lib.mkDefault true; + shellcheck.enable = lib.mkDefault true; + shfmt.enable = lib.mkDefault true; + toml-sort.enable = lib.mkDefault true; + yamlfmt.enable = lib.mkDefault true; + # keep-sorted end + }; + # Generated files are formatted by their generator, not by treefmt. + settings.excludes = lib.attrNames (lib.filterAttrs (_: f: f.enable) config.nullkomma.files); + }; + }; + }; +} diff --git a/modules/deploy/cloudflare-pages.nix b/modules/deploy/cloudflare-pages.nix new file mode 100644 index 0000000..87b69aa --- /dev/null +++ b/modules/deploy/cloudflare-pages.nix @@ -0,0 +1,61 @@ +# Deploys `nullkomma.site` to Cloudflare Pages: `nix run .#deploy` locally, or +# from CI on pushes to the default branch (needs the two secrets below). +{ inputs, ... }: +{ + flake.flakeModules.cloudflare-pages = + { config, lib, ... }: + let + cfg = config.nullkomma.cloudflare-pages; + in + { + imports = [ inputs.self.flakeModules.default ]; + options.nullkomma.cloudflare-pages = { + project = lib.mkOption { + type = lib.types.str; + example = "my-docs"; + description = "Cloudflare Pages project name."; + }; + branch = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Cloudflare Pages branch; null uses the project's production branch."; + }; + }; + config = { + nullkomma.github.ci = { + inputs.deploy = true; + secrets = [ + "CLOUDFLARE_ACCOUNT_ID" + "CLOUDFLARE_API_TOKEN" + ]; + }; + perSystem = + { config, pkgs, ... }: + { + nullkomma.tasks.deploy = { + description = "Deploy the site to Cloudflare Pages (needs CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID)"; + runtimeInputs = [ pkgs.wrangler ]; + command = '' + exec wrangler pages deploy ${ + if config.nullkomma.site == null then + throw "nullkomma.cloudflare-pages: no `nullkomma.site`; import a docs aspect such as flakeModules.quarto" + else + config.nullkomma.site + } ${ + lib.escapeShellArgs ( + [ + "--project-name" + cfg.project + ] + ++ lib.optionals (cfg.branch != null) [ + "--branch" + cfg.branch + ] + ) + } "$@" + ''; + }; + }; + }; + }; +} diff --git a/modules/dev/dogfood.nix b/modules/dev/dogfood.nix new file mode 100644 index 0000000..cf2a8d6 --- /dev/null +++ b/modules/dev/dogfood.nix @@ -0,0 +1,26 @@ +# nullkomma uses nullkomma: this repo is a Quarto site (README as index.qmd). +{ + partitions.dev.module = + { inputs, ... }: + { + imports = [ + inputs.self.flakeModules.default + inputs.self.flakeModules.quarto + ]; + nullkomma = { + github = { + workflows = { + source = "./.github/workflows"; + ref = null; + }; + ci.inputs.visibility = "public"; + }; + gitignore = [ ".claude/" ]; + }; + perSystem = + { pkgs, ... }: + { + nullkomma.devshell.packages = [ pkgs.fh ]; + }; + }; +} diff --git a/modules/dev/partition.nix b/modules/dev/partition.nix new file mode 100644 index 0000000..3cf3f1d --- /dev/null +++ b/modules/dev/partition.nix @@ -0,0 +1,16 @@ +# Everything nullkomma needs for its own development (dogfooding, schemas, tests) +# is evaluated in the `dev` partition, with extra inputs from ./dev/flake.nix that +# consumers never fetch nor lock. +{ inputs, lib, ... }: +{ + imports = [ inputs.flake-parts.flakeModules.partitions ]; + partitions.dev.extraInputsFlake = ../../dev; + partitionedAttrs = lib.genAttrs [ + "apps" + "checks" + "devShells" + "formatter" + "packages" + "schemas" + ] (_: "dev"); +} diff --git a/modules/dev/schemas.nix b/modules/dev/schemas.nix new file mode 100644 index 0000000..9d8096e --- /dev/null +++ b/modules/dev/schemas.nix @@ -0,0 +1,50 @@ +# Flake schemas (DeterminateSystems/flake-schemas) make `nix flake show` and +# `nix flake check` understand nullkomma's non-standard outputs. +{ + partitions.dev.module = + { inputs, ... }: + let + isModule = m: builtins.isAttrs m || builtins.isFunction m; + children = what: output: { + children = builtins.mapAttrs (_: _: { inherit what; }) output; + }; + in + { + flake.schemas = inputs.flake-schemas.schemas // { + flakeModules = { + version = 1; + doc = "Aspects as [flake-parts](https://flake.parts) modules; import them into `nullkomma.lib.mkFlake`."; + inventory = output: { + children = builtins.mapAttrs (_: module: { + what = "flake-parts module"; + evalChecks.isModule = isModule module; + }) output; + }; + }; + flakeModule = { + version = 1; + doc = "Alias of `flakeModules.default`."; + inventory = module: { + what = "flake-parts module"; + evalChecks.isModule = isModule module; + }; + }; + modules = { + version = 1; + doc = "Modules by class (`modules..`), as defined by flake-parts."; + inventory = output: { + children = builtins.mapAttrs (class: children "${class} module") output; + }; + }; + lib = { + version = 1; + doc = "Nix library: `mkFlake`, `importTree` and pure helpers per language."; + inventory = output: { + children = builtins.mapAttrs (_: v: { + what = if builtins.isFunction v then "function" else "library"; + }) output; + }; + }; + }; + }; +} diff --git a/modules/dev/tests.nix b/modules/dev/tests.nix new file mode 100644 index 0000000..fd0fb06 --- /dev/null +++ b/modules/dev/tests.nix @@ -0,0 +1,219 @@ +# Contract tests: unit tests of ./lib, plus fixtures and templates evaluated as +# consumers would (nullkomma as their only input), whose checks, devshells and +# sites become checks of nullkomma itself. +{ + partitions.dev.module = + { inputs, lib, ... }: + let + nullkomma = inputs.self; + # Like `builtins.getFlake`, but pure and with `nullkomma` pointing at this + # checkout; flake-parts' `debug` exposes the configuration to the tests. + callFlake = + src: + let + flake = import (src + "/flake.nix"); + debugged = nullkomma // { + lib = nullkomma.lib // { + mkFlake = + args: module: + nullkomma.lib.mkFlake args { + imports = [ module ]; + debug = true; + }; + }; + }; + flakeInputs = { + self = self'; + nullkomma = debugged; + }; + outputs = flake.outputs flakeInputs; + self' = outputs // { + _type = "flake"; + outPath = src; + sourceInfo.outPath = src; + inputs = flakeInputs; + inherit outputs; + }; + in + self'; + fixtures = lib.genAttrs [ "md" "r" "quarto" ] (name: callFlake ../../tests/fixtures/${name}); + templates = lib.mapAttrs (_: t: callFlake t.path) nullkomma.templates; + in + { + perSystem = + { pkgs, system, ... }: + let + perSystem = flake: flake.allSystems.${system}; + has = attrs: name: attrs.${system} ? ${name}; + importTreeRoot = ../../tests/fixtures/import-tree; + tests = { + # keep-sorted start block=yes + testGitignoreReincludesManagedFiles = { + expr = + let + lines = lib.splitString "\n" (perSystem fixtures.r).nullkomma.files.".gitignore".text; + in + { + envrc = lib.elem "!/.envrc" lines; + vscode = lib.elem "!/.vscode/settings.json" lines; + last = lib.last (lib.init lines) == "!/.vscode/settings.json"; + }; + expected = { + envrc = true; + vscode = true; + last = true; + }; + }; + testImportTreeSkipsUnderscoresAndNonNix = { + expr = map (lib.removePrefix (toString importTreeRoot)) ( + map toString (nullkomma.lib.importTree importTreeRoot) + ); + expected = [ + "/a.nix" + "/sub/b.nix" + ]; + }; + testMdHasNoR = { + expr = { + air = (perSystem fixtures.md).treefmt.programs.air.enable; + options = fixtures.md.debug.options.nullkomma ? r; + check = has fixtures.md.checks "r-cmd-check"; + }; + expected = { + air = false; + options = false; + check = false; + }; + }; + testQuartoComposesWithR = { + expr = { + engines = map (p: p.name) (perSystem fixtures.quarto).nullkomma.quarto.engines; + knitr = lib.elem "knitr" fixtures.quarto.debug.nullkomma.r.extraPackages; + env = (perSystem fixtures.quarto).nullkomma.devshell.env ? QUARTO_R; + check = has fixtures.quarto.checks "r-cmd-check"; + }; + expected = { + engines = [ (perSystem fixtures.quarto).nullkomma.r.package.name ]; + knitr = true; + env = true; + check = false; + }; + }; + testQuartoDeploys = { + expr = { + site = has fixtures.quarto.packages "site"; + deploy = has fixtures.quarto.apps "deploy"; + ci = + lib.hasInfix "CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}" + (perSystem fixtures.quarto).nullkomma.files.".github/workflows/push.yml".text; + }; + expected = { + site = true; + deploy = true; + ci = true; + }; + }; + testQuartoTemplateWithoutProjectHasNoSite = { + expr = { + site = has templates.quarto.packages "site"; + tasks = lib.attrNames ( + lib.filterAttrs ( + n: _: + lib.elem n [ + "preview" + "render" + ] + ) templates.quarto.apps.${system} + ); + }; + expected = { + site = false; + tasks = [ + "preview" + "render" + ]; + }; + }; + testRDependsOnDescription = { + expr = nullkomma.lib.r.depsFromDescription { path = ../../tests/fixtures/r/DESCRIPTION; }; + expected = [ "testthat" ]; + }; + testRHasAirAndCheck = { + expr = { + air = (perSystem fixtures.r).treefmt.programs.air.enable; + check = has fixtures.r.checks "r-cmd-check"; + gitignore = lib.elem "*.Rcheck/" fixtures.r.debug.nullkomma.gitignore; + }; + expected = { + air = true; + check = true; + gitignore = true; + }; + }; + testRLibAttrName = { + expr = nullkomma.lib.r.attrName "data.table"; + expected = "data_table"; + }; + testRLibDepNames = { + expr = nullkomma.lib.r.depNames "R (>= 4.1), data.table (>= 1.0),\n rlang, utils"; + expected = [ + "data.table" + "rlang" + ]; + }; + testRLibParseDCF = { + expr = nullkomma.lib.r.parseDCF '' + Package: foo + Imports: + bar, + baz (>= 1.0) + ''; + expected = { + Package = "foo"; + Imports = "bar, baz (>= 1.0)"; + }; + }; + testTemplatesAreFixtures = { + expr = map builtins.readFile [ + ../../templates/default/flake.nix + ../../templates/r/flake.nix + ]; + expected = map builtins.readFile [ + ../../tests/fixtures/md/flake.nix + ../../tests/fixtures/r/flake.nix + ]; + }; + # keep-sorted end + }; + failures = lib.debug.runTests tests; + # A consumer's `files` check compares against files in its repo, which fixtures don't have. + fixtureChecks = lib.concatMapAttrs ( + name: flake: + lib.mapAttrs' (check: lib.nameValuePair "fixture-${name}-${check}") ( + removeAttrs flake.checks.${system} [ "files" ] + // { + devshell = flake.devShells.${system}.default; + } + // lib.optionalAttrs (has flake.packages "site") { + site = flake.packages.${system}.site; + } + ) + ) fixtures; + in + { + checks = fixtureChecks // { + unit = pkgs.runCommandLocal "nullkomma-unit-tests" { } ( + if failures == [ ] then + "touch $out" + else + '' + cat >&2 <<'EOF' + ${lib.generators.toPretty { } failures} + EOF + exit 1 + '' + ); + }; + }; + }; +} diff --git a/modules/flake-parts.nix b/modules/flake-parts.nix new file mode 100644 index 0000000..77d4f00 --- /dev/null +++ b/modules/flake-parts.nix @@ -0,0 +1,6 @@ +# Makes `flake.flakeModules.` mergeable, so several files can each +# contribute to the same exported module (and adds den-ready `flake.modules.flake`). +{ inputs, ... }: +{ + imports = [ inputs.flake-parts.flakeModules.flakeModules ]; +} diff --git a/modules/lang/quarto/editor.nix b/modules/lang/quarto/editor.nix new file mode 100644 index 0000000..9fd4330 --- /dev/null +++ b/modules/lang/quarto/editor.nix @@ -0,0 +1,3 @@ +{ + flake.flakeModules.quarto.nullkomma.editor.vscode.extensions = [ "quarto.quarto" ]; +} diff --git a/modules/lang/quarto/gitignore.nix b/modules/lang/quarto/gitignore.nix new file mode 100644 index 0000000..6a24d8f --- /dev/null +++ b/modules/lang/quarto/gitignore.nix @@ -0,0 +1,8 @@ +{ + flake.flakeModules.quarto.nullkomma.gitignore = [ + "**/*.quarto_ipynb" + "/.quarto/" + "/_book/" + "/_site/" + ]; +} diff --git a/modules/lang/quarto/package.nix b/modules/lang/quarto/package.nix new file mode 100644 index 0000000..029b83f --- /dev/null +++ b/modules/lang/quarto/package.nix @@ -0,0 +1,28 @@ +{ inputs, ... }: +{ + flake.flakeModules.quarto = + { lib, ... }: + { + imports = [ inputs.self.flakeModules.default ]; + perSystem = + { pkgs, ... }: + { + options.nullkomma.quarto.package = lib.mkOption { + type = lib.types.package; + default = pkgs.quartoMinimal.overrideAttrs (old: { + # nixpkgs#519484: quarto 1.9.37 emits `syntax-highlighting`, pandoc 3.7+ expects `highlight-style` + postPatch = (old.postPatch or "") + '' + substituteInPlace bin/quarto.js \ + --replace-fail "syntax-highlighting" "highlight-style" + ''; + # nixpkgs#393246: quarto shells out to `which` + postFixup = (old.postFixup or "") + '' + wrapProgram $out/bin/quarto --prefix PATH : ${lib.makeBinPath [ pkgs.which ]} + ''; + }); + defaultText = lib.literalMD "`pkgs.quartoMinimal` with nullkomma's workarounds"; + description = "Quarto, without bundled R/Python; engines come from the R/Python aspects."; + }; + }; + }; +} diff --git a/modules/lang/quarto/r.nix b/modules/lang/quarto/r.nix new file mode 100644 index 0000000..983599f --- /dev/null +++ b/modules/lang/quarto/r.nix @@ -0,0 +1,21 @@ +# Aspects composing aspects: with flakeModules.r imported too, Quarto renders +# R chunks with the project's R environment (plus knitr and rmarkdown). +{ + flake.flakeModules.quarto = + { lib, options, ... }: + { + # `config = optionalAttrs …`, not `mkIf`: undeclared options must not even be defined. + config = lib.optionalAttrs (options.nullkomma ? r) { + nullkomma.r.extraPackages = [ + "knitr" + "rmarkdown" + ]; + perSystem = + { config, ... }: + { + nullkomma.quarto.engines = [ config.nullkomma.r.package ]; + nullkomma.devshell.env.QUARTO_R = "${config.nullkomma.r.package}/bin/R"; + }; + }; + }; +} diff --git a/modules/lang/quarto/site.nix b/modules/lang/quarto/site.nix new file mode 100644 index 0000000..1d7c5ef --- /dev/null +++ b/modules/lang/quarto/site.nix @@ -0,0 +1,78 @@ +{ + flake.flakeModules.quarto = + { + config, + lib, + self, + ... + }: + let + cfg = config.nullkomma.quarto; + in + { + options.nullkomma.quarto = { + root = lib.mkOption { + type = lib.types.path; + default = self; + defaultText = lib.literalExpression "self"; + description = "Quarto project directory (containing `_quarto.yml`)."; + }; + site.enable = lib.mkOption { + type = lib.types.bool; + default = builtins.pathExists (cfg.root + "/_quarto.yml"); + defaultText = lib.literalMD "whether `root` has a `_quarto.yml`"; + description = "Whether `quarto render` of `root` becomes `nullkomma.site`."; + }; + }; + config.perSystem = + { config, pkgs, ... }: + let + quarto = config.nullkomma.quarto.package; + inputs = [ quarto ] ++ config.nullkomma.quarto.engines; + in + { + options.nullkomma.quarto.engines = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Engine environments (R, Python) on `PATH` for rendering; set by the language aspects."; + }; + config = { + nullkomma = { + devshell.packages = [ quarto ]; + site = lib.mkIf cfg.site.enable ( + lib.mkDefault ( + pkgs.stdenvNoCC.mkDerivation { + name = "site"; + src = cfg.root; + nativeBuildInputs = inputs; + buildPhase = '' + runHook preBuild + export HOME="$TMPDIR" + quarto render --output-dir "$TMPDIR/site" + runHook postBuild + ''; + installPhase = '' + runHook preInstall + cp -r "$TMPDIR/site" $out + runHook postInstall + ''; + } + ) + ); + tasks = { + preview = { + description = "Live-preview the Quarto project (quarto preview)"; + runtimeInputs = inputs; + command = ''exec quarto preview "$@"''; + }; + render = { + description = "Render the Quarto project (quarto render)"; + runtimeInputs = inputs; + command = ''exec quarto render "$@"''; + }; + }; + }; + }; + }; + }; +} diff --git a/modules/lang/r/checks.nix b/modules/lang/r/checks.nix new file mode 100644 index 0000000..486247d --- /dev/null +++ b/modules/lang/r/checks.nix @@ -0,0 +1,84 @@ +{ + flake.flakeModules.r = + { config, lib, ... }: + let + cfg = config.nullkomma.r; + rlib = import ../../../lib/r.nix { inherit lib; }; + isPackage = + cfg.description != null && (rlib.parseDCF (builtins.readFile cfg.description)) ? Package; + in + { + options.nullkomma.r.check = { + enable = lib.mkOption { + type = lib.types.bool; + default = isPackage; + defaultText = lib.literalMD "whether DESCRIPTION has a `Package` field"; + description = "Whether to add `checks.r-cmd-check` (`R CMD build` + `R CMD check`)."; + }; + args = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "--no-manual" + "--ignore-vignettes" + ]; + description = "Arguments to `R CMD check`."; + }; + errorOn = lib.mkOption { + type = lib.types.enum [ + "error" + "warning" + "note" + ]; + default = "warning"; + description = "Least severe `R CMD check` finding that fails the check."; + }; + }; + config.perSystem = + { config, pkgs, ... }: + let + # nullkomma's own files are not part of the R package + strip = [ + "flake.nix" + "flake.lock" + ] + ++ lib.attrNames config.nullkomma.files; + failOn = + { + error = null; + warning = "WARNING"; + note = "WARNING|NOTE"; + } + .${cfg.check.errorOn}; + in + { + # mkIf on `checks`, not on the lazy attribute, so the attribute disappears + checks = lib.mkIf cfg.check.enable { + r-cmd-check = + pkgs.runCommand "r-cmd-check" + { + src = cfg.root; + nativeBuildInputs = [ config.nullkomma.r.package ]; + _R_CHECK_CRAN_INCOMING_ = "false"; + _R_CHECK_FORCE_SUGGESTS_ = "false"; + _R_CHECK_SYSTEM_CLOCK_ = "false"; + } + '' + export HOME="$TMPDIR" + cp -r "$src" pkg + chmod -R u+w pkg + (cd pkg && rm -rf ${lib.escapeShellArgs strip}) + R CMD build --no-build-vignettes --no-manual pkg + R CMD check ${lib.escapeShellArgs cfg.check.args} ./*.tar.gz + ${lib.optionalString (failOn != null) '' + if grep -Eq '^Status: .*(${failOn})' ./*.Rcheck/00check.log; then + echo "nullkomma: R CMD check found issues at or above errorOn = ${cfg.check.errorOn}" >&2 + exit 1 + fi + ''} + mkdir -p $out + cp -r ./*.Rcheck $out/ + ''; + }; + }; + }; +} diff --git a/modules/lang/r/devshell.nix b/modules/lang/r/devshell.nix new file mode 100644 index 0000000..fb5d81f --- /dev/null +++ b/modules/lang/r/devshell.nix @@ -0,0 +1,7 @@ +{ + flake.flakeModules.r.perSystem = + { config, ... }: + { + nullkomma.devshell.packages = [ config.nullkomma.r.package ]; + }; +} diff --git a/modules/lang/r/editor.nix b/modules/lang/r/editor.nix new file mode 100644 index 0000000..e6b13fd --- /dev/null +++ b/modules/lang/r/editor.nix @@ -0,0 +1,6 @@ +{ + flake.flakeModules.r.nullkomma.editor.vscode.extensions = [ + "Posit.air-vscode" + "REditorSupport.r" + ]; +} diff --git a/modules/lang/r/gitignore.nix b/modules/lang/r/gitignore.nix new file mode 100644 index 0000000..5061b9c --- /dev/null +++ b/modules/lang/r/gitignore.nix @@ -0,0 +1,9 @@ +{ + flake.flakeModules.r.nullkomma.gitignore = [ + "*.Rcheck/" + ".RData" + ".Rhistory" + ".Rproj.user/" + ".Ruserdata" + ]; +} diff --git a/modules/lang/r/options.nix b/modules/lang/r/options.nix new file mode 100644 index 0000000..490c32e --- /dev/null +++ b/modules/lang/r/options.nix @@ -0,0 +1,43 @@ +{ inputs, ... }: +{ + flake.flakeModules.r = + { + config, + lib, + self, + ... + }: + let + rlib = import ../../../lib/r.nix { inherit lib; }; + description = config.nullkomma.r.root + "/DESCRIPTION"; + in + { + imports = [ inputs.self.flakeModules.default ]; + options.nullkomma.r = { + root = lib.mkOption { + type = lib.types.path; + default = self; + defaultText = lib.literalExpression "self"; + example = lib.literalExpression "./pkg"; + description = "R project directory (containing `DESCRIPTION`); a path literal if not the repo root."; + }; + description = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = if builtins.pathExists description then description else null; + defaultText = lib.literalExpression ''root + "/DESCRIPTION" (if it exists)''; + description = "R DESCRIPTION file: the single source of truth for R dependencies."; + }; + fields = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = rlib.defaultFields; + description = "DESCRIPTION fields whose packages are added to the R environment."; + }; + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "devtools" ]; + description = "Extra CRAN/Bioconductor package names (e.g. dev tooling not in DESCRIPTION)."; + }; + }; + }; +} diff --git a/modules/lang/r/package.nix b/modules/lang/r/package.nix new file mode 100644 index 0000000..11390c5 --- /dev/null +++ b/modules/lang/r/package.nix @@ -0,0 +1,40 @@ +# R and its packages come from `nixpkgs-r`, which follows nullkomma's nixpkgs +# unless a consumer repoints it (e.g. at an rstats-on-nix date). +{ inputs, ... }: +{ + flake.flakeModules.r = + { config, lib, ... }: + let + cfg = config.nullkomma.r; + rlib = import ../../../lib/r.nix { inherit lib; }; + names = lib.unique ( + lib.optionals (cfg.description != null) ( + rlib.depsFromDescription { + path = cfg.description; + inherit (cfg) fields; + } + ) + ++ cfg.extraPackages + ++ [ "languageserver" ] + ); + in + { + perSystem = + { system, ... }: + let + pkgsR = inputs.nixpkgs-r.legacyPackages.${system}; + lookup = + name: + pkgsR.rPackages.${rlib.attrName name} + or (throw "nullkomma.r: R package `${name}` is not in `rPackages` of nixpkgs-r; pin a newer nixpkgs-r or drop it from DESCRIPTION/extraPackages."); + in + { + options.nullkomma.r.package = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = "R with every package from DESCRIPTION and `nullkomma.r.extraPackages`."; + }; + config.nullkomma.r.package = pkgsR.rWrapper.override { packages = map lookup names; }; + }; + }; +} diff --git a/modules/lang/r/treefmt.nix b/modules/lang/r/treefmt.nix new file mode 100644 index 0000000..0a20ea2 --- /dev/null +++ b/modules/lang/r/treefmt.nix @@ -0,0 +1,44 @@ +{ + flake.flakeModules.r = + { config, lib, ... }: + let + cfg = config.nullkomma.r.jarl; + in + { + options.nullkomma.r.jarl.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Lint and auto-fix R code with jarl as a treefmt formatter. + Off by default: nixpkgs has no aarch64-darwin binary (upstream tests fail there), + so it is compiled from source with tests disabled on macOS. + ''; + }; + config.perSystem = + { pkgs, ... }: + { + treefmt.programs.air.enable = lib.mkDefault true; + treefmt.settings.formatter.jarl = lib.mkIf cfg.enable { + command = lib.getExe ( + if pkgs.stdenv.hostPlatform.isDarwin then + pkgs.jarl.overrideAttrs { doCheck = false; } + else + pkgs.jarl + ); + options = [ + "check" + "--fix" + "--allow-dirty" + "--allow-no-vcs" + "--output-format=concise" + ]; + includes = [ + "*.R" + "*.r" + ]; + # fix lints first, then let air format the result + priority = -1; + }; + }; + }; +} diff --git a/modules/lib.nix b/modules/lib.nix new file mode 100644 index 0000000..6eec2c3 --- /dev/null +++ b/modules/lib.nix @@ -0,0 +1,16 @@ +{ inputs, lib, ... }: +{ + flake.lib = { + importTree = import ../lib/import-tree.nix lib; + r = import ../lib/r.nix { inherit lib; }; + # `flake-parts.lib.mkFlake`, with `flakeModules.default` already imported. + mkFlake = + args: module: + inputs.flake-parts.lib.mkFlake args { + imports = [ + inputs.self.flakeModules.default + module + ]; + }; + }; +} diff --git a/modules/templates.nix b/modules/templates.nix new file mode 100644 index 0000000..5fb9aa4 --- /dev/null +++ b/modules/templates.nix @@ -0,0 +1,31 @@ +let + welcomeText = '' + # Welcome to nullkomma + + Next steps: + + 1. `git add flake.nix` (flakes only see tracked files) + 2. `nix run .#write-files` to generate `.gitignore`, `.envrc`, CI stubs, … + 3. `direnv allow` (or `nix develop`) + 4. `nix run` to list all tasks + ''; +in +{ + flake.templates = { + default = { + path = ../templates/default; + description = "nullkomma project: devshell, treefmt, checks, CI"; + inherit welcomeText; + }; + r = { + path = ../templates/r; + description = "nullkomma R project: dependencies from DESCRIPTION, air, R CMD check"; + inherit welcomeText; + }; + quarto = { + path = ../templates/quarto; + description = "nullkomma Quarto project: site build, render and preview tasks"; + inherit welcomeText; + }; + }; +} diff --git a/templates/base/.envrc b/templates/base/.envrc deleted file mode 100644 index 197c0d5..0000000 --- a/templates/base/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -use flake diff --git a/templates/base/.github/workflows/cron.yml b/templates/base/.github/workflows/cron.yml deleted file mode 100644 index 759ebf6..0000000 --- a/templates/base/.github/workflows/cron.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Maintenance -on: - workflow_dispatch: - schedule: - - cron: "0 0 * * 0" # runs weekly on Sunday at 01:00 -jobs: - nix-maintenance: - name: Nix Maintenance - runs-on: ubuntu-latest - permissions: - id-token: "write" - contents: "write" - issues: write - pull-requests: write - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/determinate-nix-action@main - - uses: DeterminateSystems/flake-checker-action@main - - uses: DeterminateSystems/update-flake-lock@main - with: - pr-title: "Update flake.lock" - pr-labels: | - dependencies - automated diff --git a/templates/base/.github/workflows/push.yml b/templates/base/.github/workflows/push.yml deleted file mode 100644 index ecbdeca..0000000 --- a/templates/base/.github/workflows/push.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: CI -on: - pull_request: - workflow_dispatch: - push: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true -jobs: - DeterminateCI: - uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main - permissions: - id-token: "write" - contents: "read" - with: - fail-fast: false - runner-map: | - { - "aarch64-darwin": "macos-latest", - "aarch64-linux": "ubuntu-24.04-arm", - "x86_64-linux": "ubuntu-latest" - } - flake-checker: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Check Nix flake Nixpkgs inputs - uses: DeterminateSystems/flake-checker-action@v12 - with: - fail-mode: true diff --git a/templates/base/.gitignore b/templates/base/.gitignore deleted file mode 100644 index d53e06f..0000000 --- a/templates/base/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.direnv/ -result diff --git a/templates/base/flake.nix b/templates/base/flake.nix deleted file mode 100644 index cc80efd..0000000 --- a/templates/base/flake.nix +++ /dev/null @@ -1,29 +0,0 @@ -# flake.nix -{ - inputs.treefmt-nix.url = "github:numtide/treefmt-nix"; - - outputs = - { - self, - nixpkgs, - systems, - treefmt-nix, - }: - let - # Small tool to iterate over each systems - eachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); - - # Eval the treefmt modules from ./treefmt.nix - treefmtEval = eachSystem ( - pkgs: treefmt-nix.lib.evalModule pkgs { programs.mdformat.enable = true; } - ); - in - { - # for `nix fmt` - formatter = eachSystem (pkgs: treefmtEval.${pkgs.system}.config.build.wrapper); - # for `nix flake check` - checks = eachSystem (pkgs: { - formatting = treefmtEval.${pkgs.system}.config.build.check self; - }); - }; -} diff --git a/templates/default/flake.nix b/templates/default/flake.nix new file mode 100644 index 0000000..272a0f1 --- /dev/null +++ b/templates/default/flake.nix @@ -0,0 +1,7 @@ +{ + description = "A project with batteries included by nullkomma"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + + outputs = inputs: inputs.nullkomma.lib.mkFlake { inherit inputs; } { }; +} diff --git a/templates/quarto/flake.nix b/templates/quarto/flake.nix new file mode 100644 index 0000000..cfbf96c --- /dev/null +++ b/templates/quarto/flake.nix @@ -0,0 +1,18 @@ +{ + description = "A Quarto project with batteries included by nullkomma"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + + outputs = + inputs: + inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.nullkomma.flakeModules.quarto + # R chunks? Add the R aspect, too: + # inputs.nullkomma.flakeModules.r + # Deploy the site from CI: + # inputs.nullkomma.flakeModules.cloudflare-pages + ]; + # nullkomma.cloudflare-pages.project = "my-site"; + }; +} diff --git a/templates/r/flake.nix b/templates/r/flake.nix new file mode 100644 index 0000000..0ba6371 --- /dev/null +++ b/templates/r/flake.nix @@ -0,0 +1,15 @@ +{ + description = "An R project with batteries included by nullkomma"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + # Pin R and CRAN to a date (adds one input to flake.lock): + # inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; + + outputs = + inputs: + inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = [ inputs.nullkomma.flakeModules.r ]; + # R packages come from DESCRIPTION; add dev-only ones here: + # nullkomma.r.extraPackages = [ "devtools" ]; + }; +} diff --git a/tests/fixtures/import-tree/_private/c.nix b/tests/fixtures/import-tree/_private/c.nix new file mode 100644 index 0000000..ffcd441 --- /dev/null +++ b/tests/fixtures/import-tree/_private/c.nix @@ -0,0 +1 @@ +{ } diff --git a/tests/fixtures/import-tree/_skip.nix b/tests/fixtures/import-tree/_skip.nix new file mode 100644 index 0000000..ffcd441 --- /dev/null +++ b/tests/fixtures/import-tree/_skip.nix @@ -0,0 +1 @@ +{ } diff --git a/tests/fixtures/import-tree/a.nix b/tests/fixtures/import-tree/a.nix new file mode 100644 index 0000000..ffcd441 --- /dev/null +++ b/tests/fixtures/import-tree/a.nix @@ -0,0 +1 @@ +{ } diff --git a/tests/fixtures/import-tree/readme.txt b/tests/fixtures/import-tree/readme.txt new file mode 100644 index 0000000..eff7872 --- /dev/null +++ b/tests/fixtures/import-tree/readme.txt @@ -0,0 +1 @@ +not nix diff --git a/tests/fixtures/import-tree/sub/b.nix b/tests/fixtures/import-tree/sub/b.nix new file mode 100644 index 0000000..ffcd441 --- /dev/null +++ b/tests/fixtures/import-tree/sub/b.nix @@ -0,0 +1 @@ +{ } diff --git a/tests/fixtures/md/README.md b/tests/fixtures/md/README.md new file mode 100644 index 0000000..ae1dac8 --- /dev/null +++ b/tests/fixtures/md/README.md @@ -0,0 +1,3 @@ +# Fixture: a project without any language aspect + +Formatting and linting only. diff --git a/tests/fixtures/md/flake.nix b/tests/fixtures/md/flake.nix new file mode 100644 index 0000000..272a0f1 --- /dev/null +++ b/tests/fixtures/md/flake.nix @@ -0,0 +1,7 @@ +{ + description = "A project with batteries included by nullkomma"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + + outputs = inputs: inputs.nullkomma.lib.mkFlake { inherit inputs; } { }; +} diff --git a/tests/fixtures/quarto/_quarto.yml b/tests/fixtures/quarto/_quarto.yml new file mode 100644 index 0000000..2f17cbc --- /dev/null +++ b/tests/fixtures/quarto/_quarto.yml @@ -0,0 +1,4 @@ +project: + type: website +website: + title: nullkomma fixture diff --git a/tests/fixtures/quarto/flake.nix b/tests/fixtures/quarto/flake.nix new file mode 100644 index 0000000..f5387a9 --- /dev/null +++ b/tests/fixtures/quarto/flake.nix @@ -0,0 +1,16 @@ +{ + description = "Fixture: Quarto site with R chunks, deployed to Cloudflare Pages"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + + outputs = + inputs: + inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = with inputs.nullkomma.flakeModules; [ + cloudflare-pages + quarto + r + ]; + nullkomma.cloudflare-pages.project = "nullkomma-fixture"; + }; +} diff --git a/tests/fixtures/quarto/index.qmd b/tests/fixtures/quarto/index.qmd new file mode 100644 index 0000000..0a18b66 --- /dev/null +++ b/tests/fixtures/quarto/index.qmd @@ -0,0 +1,9 @@ +--- +title: nullkomma fixture +--- + +R chunks run with the R aspect's environment: + +```{r} +1 + 1 +``` diff --git a/tests/fixtures/r/DESCRIPTION b/tests/fixtures/r/DESCRIPTION new file mode 100644 index 0000000..662332e --- /dev/null +++ b/tests/fixtures/r/DESCRIPTION @@ -0,0 +1,11 @@ +Package: nullkommafixture +Title: Fixture for the Nullkomma R Aspect +Version: 0.0.1 +Authors@R: person("nullkomma", email = "nullkomma@example.com", role = c("aut", "cre")) +Description: Exercises the R aspect of nullkomma: dependencies from the + DESCRIPTION file, formatting with air and R CMD check. +License: CC0 +Encoding: UTF-8 +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 diff --git a/tests/fixtures/r/NAMESPACE b/tests/fixtures/r/NAMESPACE new file mode 100644 index 0000000..01843dc --- /dev/null +++ b/tests/fixtures/r/NAMESPACE @@ -0,0 +1,3 @@ +# Generated by roxygen2: do not edit by hand + +export(hello) diff --git a/tests/fixtures/r/R/hello.R b/tests/fixtures/r/R/hello.R new file mode 100644 index 0000000..3e1c375 --- /dev/null +++ b/tests/fixtures/r/R/hello.R @@ -0,0 +1,8 @@ +#' Say hello +#' +#' @param name Who to greet. +#' @return A greeting. +#' @export +hello <- function(name = "world") { + paste0("Hello, ", name, "!") +} diff --git a/tests/fixtures/r/flake.nix b/tests/fixtures/r/flake.nix new file mode 100644 index 0000000..0ba6371 --- /dev/null +++ b/tests/fixtures/r/flake.nix @@ -0,0 +1,15 @@ +{ + description = "An R project with batteries included by nullkomma"; + + inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; + # Pin R and CRAN to a date (adds one input to flake.lock): + # inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; + + outputs = + inputs: + inputs.nullkomma.lib.mkFlake { inherit inputs; } { + imports = [ inputs.nullkomma.flakeModules.r ]; + # R packages come from DESCRIPTION; add dev-only ones here: + # nullkomma.r.extraPackages = [ "devtools" ]; + }; +} diff --git a/tests/fixtures/r/man/hello.Rd b/tests/fixtures/r/man/hello.Rd new file mode 100644 index 0000000..a10626c --- /dev/null +++ b/tests/fixtures/r/man/hello.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/hello.R +\name{hello} +\alias{hello} +\title{Say hello} +\usage{ +hello(name = "world") +} +\arguments{ +\item{name}{Who to greet.} +} +\value{ +A greeting. +} +\description{ +Say hello +} diff --git a/tests/fixtures/r/tests/testthat.R b/tests/fixtures/r/tests/testthat.R new file mode 100644 index 0000000..fe11a80 --- /dev/null +++ b/tests/fixtures/r/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(nullkommafixture) + +test_check("nullkommafixture") diff --git a/tests/fixtures/r/tests/testthat/test-hello.R b/tests/fixtures/r/tests/testthat/test-hello.R new file mode 100644 index 0000000..41ac753 --- /dev/null +++ b/tests/fixtures/r/tests/testthat/test-hello.R @@ -0,0 +1,3 @@ +test_that("hello greets", { + expect_equal(hello("you"), "Hello, you!") +}) From 873aad8e584da373ec4cba1d6e696d1a2f87c24f Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 19:37:51 +0200 Subject: [PATCH 2/6] Initialize user-owned files without drift checks or downstream cron Remove generated-file banners, preserve existing files when initializing, and keep scheduled maintenance only in nullkomma. Test downstream initialization and preservation of user edits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .envrc | 1 - .github/workflows/ci.yml | 2 +- .github/workflows/cron.yml | 1 - .github/workflows/maintenance.yml | 2 +- .github/workflows/push.yml | 1 - .gitignore | 4 +- README.md | 38 +++++++++---------- modules/core/files.nix | 62 ++++++++----------------------- modules/core/github.nix | 18 +-------- modules/core/gitignore.nix | 6 +-- modules/dev/tests.nix | 34 ++++++++++++++++- modules/templates.nix | 2 +- 12 files changed, 74 insertions(+), 97 deletions(-) diff --git a/.envrc b/.envrc index b55dec8..3550a30 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1 @@ -# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41991ce..1e28bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# Reusable CI of nullkomma projects, called by their generated push.yml. +# Reusable CI of nullkomma projects, called by their push.yml. name: nullkomma CI on: workflow_call: diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 8a66a71..0407e22 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,4 +1,3 @@ -# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. name: Maintenance on: schedule: diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 3b4c114..e900e92 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -1,4 +1,4 @@ -# Reusable maintenance of nullkomma projects, called by their generated cron.yml. +# Maintenance workflow called by nullkomma's own cron.yml. name: nullkomma maintenance on: workflow_call: diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index f1efb6b..298edfe 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,4 +1,3 @@ -# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. name: CI on: pull_request: diff --git a/.gitignore b/.gitignore index 4611804..983575a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`. **/*.quarto_ipynb /.quarto/ /_book/ @@ -7,9 +6,8 @@ result result-* .claude/ -# nullkomma-managed files +# Initialized files !/.envrc -!/.github/workflows/cron.yml !/.github/workflows/push.yml !/.gitignore !/.vscode/extensions.json diff --git a/README.md b/README.md index b9edaf6..9a8068a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ When making changes, adhere to [aoshima's charter](https://github.com/dataheld/a nix flake init --template "https://flakehub.com/f/dataheld/nullkomma/0.1.*#default" ``` -1. Write the generated files (`.gitignore`, `.envrc`, CI workflows, editor settings): +1. Initialize missing files (`.gitignore`, `.envrc`, CI workflows, editor settings): ```sh git add flake.nix @@ -98,12 +98,12 @@ Then, as usual: `nix develop`, `nix fmt`, `nix flake check`, `nix flake show`. Each aspect is a flake-parts module in `flakeModules`. Import only the ones a project needs; an aspect never adds anything to a project that does not import it. -| Aspect | Adds | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `default` | devshell, [treefmt](https://github.com/numtide/treefmt-nix) (Nix, Markdown, YAML, JSON, TOML, shell, GitHub Actions), tasks (`check`, `update`, `flake-checker`, `write-files`), generated `.gitignore`, `.envrc`, CI workflows and VS Code settings, `checks.files` | -| `r` | R with all packages from `DESCRIPTION`, [air](https://posit-dev.github.io/air/) (and optionally [jarl](https://jarl.etiennebacher.com)), `checks.r-cmd-check`, VS Code extensions | -| `quarto` | Quarto, `packages.site` (if there is a `_quarto.yml`), tasks `render` and `preview`; with `r`, R chunks run with the project's R environment | -| `cloudflare-pages` | task `deploy` of `packages.site` to [Cloudflare Pages](https://pages.cloudflare.com), run by CI on pushes to the default branch | +| Aspect | Adds | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default` | devshell, [treefmt](https://github.com/numtide/treefmt-nix) (Nix, Markdown, YAML, JSON, TOML, shell, GitHub Actions), tasks (`check`, `update`, `flake-checker`, `write-files`), initial `.gitignore`, `.envrc`, CI workflow and VS Code settings | +| `r` | R with all packages from `DESCRIPTION`, [air](https://posit-dev.github.io/air/) (and optionally [jarl](https://jarl.etiennebacher.com)), `checks.r-cmd-check`, VS Code extensions | +| `quarto` | Quarto, `packages.site` (if there is a `_quarto.yml`), tasks `render` and `preview`; with `r`, R chunks run with the project's R environment | +| `cloudflare-pages` | task `deploy` of `packages.site` to [Cloudflare Pages](https://pages.cloudflare.com), run by CI on pushes to the default branch | ### Knobs @@ -133,14 +133,16 @@ To pin R and CRAN to a date, point nullkomma's `nixpkgs-r` input elsewhere inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; ``` -### Generated files +### Initialized files -Some files must live in the repo, so nullkomma generates them: -`.gitignore`, `.envrc`, `.github/workflows/{push,cron}.yml` and `.vscode/*.json`. -Configure them in `flake.nix` (e.g. `nullkomma.gitignore`, `nullkomma.editor.vscode`), -then run `nix run .#write-files`; `checks.files` fails if they are out of date. -Commit them: flakes only see git-tracked files, so the generated `.gitignore` re-includes them, overriding a global `core.excludesFile`. -The workflows are thin stubs that call nullkomma's reusable [`ci.yml`](.github/workflows/ci.yml) and [`maintenance.yml`](.github/workflows/maintenance.yml). +Some files must live in the repo, so `nix run .#write-files` initializes missing +`.gitignore`, `.envrc`, `.github/workflows/push.yml` and `.vscode/*.json` files. +Configure their initial contents in `flake.nix` (e.g. `nullkomma.gitignore`, `nullkomma.editor.vscode`). +After initialization they belong to you: edit them directly. Re-running `write-files` +preserves existing files, and there is no drift check or automatic overwrite on upgrades. +Commit them; the initial `.gitignore` re-includes these paths to override a global `core.excludesFile`. +The CI stub calls nullkomma's reusable [`ci.yml`](.github/workflows/ci.yml). +The scheduled `cron.yml` maintenance workflow exists only in nullkomma itself, not in consumers. ## Updating @@ -151,11 +153,9 @@ There are two separate aspects to updating the nix dependencies. 1. There may be newer versions available _given_ the pinning in `flake.nix`. This can be accomplished by running `nix run .#update` locally and may change the `flake.lock`. However such updates may break a project. - It is therefore recommended **to only run this in CI**, - using the periodically scheduled `cron.yml` workflow. - It will automatically open pull requests if there are updates available. - Users can then inspect whether the updated project still passes all tests. - If a new nullkomma generates different files, `checks.files` fails until you run `nix run .#write-files`. + Review updates and run checks before merging. Nullkomma itself uses a scheduled + `cron.yml` workflow to open update pull requests; downstream repos do not receive this workflow. + Updates never overwrite your initialized repository files. 1. The versions pinned in `flake.nix` (and the resulting `flake.lock`) itself may be out of date. The [DeterminateSystems/flake-checker](https://github.com/DeterminateSystems/flake-checker) will fail if this is the case. It runs on every push as well as periodically. diff --git a/modules/core/files.nix b/modules/core/files.nix index 9c8b1b8..54aa552 100644 --- a/modules/core/files.nix +++ b/modules/core/files.nix @@ -1,14 +1,12 @@ -# Files that must live in the repo (.gitignore, .envrc, CI stubs, editor config) -# are owned by nullkomma: `nix run .#write-files` writes them, `checks.files` fails on drift. +# Initialize repository files once; existing files belong to the user. { flake.flakeModules.default = - { lib, self, ... }: + { lib, ... }: { perSystem = { config, pkgs, ... }: let files = lib.filterAttrs (_: f: f.enable) config.nullkomma.files; - banner = "Generated by nullkomma; edit flake.nix, then run `nix run .#write-files`."; fileModule = { name, config, ... }: { @@ -16,27 +14,20 @@ enable = lib.mkOption { type = lib.types.bool; default = true; - description = "Whether to manage `${name}`."; + description = "Whether to initialize `${name}` if absent."; }; text = lib.mkOption { type = lib.types.nullOr lib.types.lines; default = null; description = "File content; sets `source`."; }; - comment = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = "#"; - description = "Line comment token for the generated-file banner prepended to `text`; null disables it."; - }; source = lib.mkOption { type = lib.types.path; description = "Store path whose content is written to `${name}`."; }; }; config.source = lib.mkIf (config.text != null) ( - pkgs.writeText "nullkomma-${lib.replaceStrings [ "/" ] [ "-" ] name}" ( - lib.optionalString (config.comment != null) "${config.comment} ${banner}\n" + config.text - ) + pkgs.writeText "nullkomma-${lib.replaceStrings [ "/" ] [ "-" ] name}" config.text ); }; in @@ -44,11 +35,11 @@ options.nullkomma.files = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule fileModule); default = { }; - description = "Files, keyed by path relative to the repo root, that nullkomma writes and checks."; + description = "Files, keyed by path relative to the repo root, that nullkomma initializes if absent. Existing files are never overwritten or checked for drift."; }; config = { nullkomma.tasks.write-files = { - description = "Write nullkomma-managed files (.gitignore, .envrc, CI stubs, …) into the repo"; + description = "Initialize missing repository files (.gitignore, .envrc, CI stubs, …), preserving existing files"; runtimeInputs = [ pkgs.coreutils pkgs.git @@ -58,39 +49,16 @@ '' + lib.concatStrings ( lib.mapAttrsToList (path: f: '' - install -D -m 644 ${f.source} "$root"/${lib.escapeShellArg path} - echo "wrote ${path}" - '') files - ); - }; - checks = lib.mkIf (files != { }) { - files = pkgs.runCommandLocal "nullkomma-files" { nativeBuildInputs = [ pkgs.diffutils ]; } ( - '' - status=0 - '' - + lib.concatStrings ( - lib.mapAttrsToList ( - path: f: - let - repoFile = lib.escapeShellArg "${self}/${path}"; - in - '' - if [ -e ${repoFile} ]; then - diff -u ${repoFile} ${f.source} || status=1 - else - echo "nullkomma: ${path} is missing from the flake source (not written or not git-added?)" >&2 - status=1 - fi - '' - ) files - ) - + '' - if [ "$status" -ne 0 ]; then - echo "nullkomma: generated files are out of date; run: nix run .#write-files" >&2 - exit 1 + target="$root"/${lib.escapeShellArg path} + if [ -e "$target" ] || [ -L "$target" ]; then + echo "kept ${path}" + else + mkdir -p "$(dirname "$target")" + cp --no-clobber ${f.source} "$target" + chmod u+w "$target" + echo "initialized ${path}" fi - touch $out - '' + '') files ); }; }; diff --git a/modules/core/github.nix b/modules/core/github.nix index cf61e3e..4d3f86b 100644 --- a/modules/core/github.nix +++ b/modules/core/github.nix @@ -34,28 +34,13 @@ " with:\n" + lib.concatStrings (lib.mapAttrsToList (k: v: " ${k}: ${scalar v}\n") cfg.ci.inputs) ); - maintenance = '' - name: Maintenance - on: - schedule: - - cron: "0 0 * * 0" # weekly, Sunday 00:00 UTC - workflow_dispatch: - jobs: - nullkomma: - uses: ${uses "maintenance"} - permissions: - contents: write - id-token: write - issues: write - pull-requests: write - ''; in { options.nullkomma.github = { enable = lib.mkOption { type = lib.types.bool; default = true; - description = "Whether to generate `.github/workflows/{push,cron}.yml`."; + description = "Whether to initialize `.github/workflows/push.yml`."; }; workflows = { source = lib.mkOption { @@ -93,7 +78,6 @@ }; config.perSystem.nullkomma.files = lib.mkIf cfg.enable { ".github/workflows/push.yml".text = ci; - ".github/workflows/cron.yml".text = maintenance; }; }; } diff --git a/modules/core/gitignore.nix b/modules/core/gitignore.nix index e9eb232..4d44d8c 100644 --- a/modules/core/gitignore.nix +++ b/modules/core/gitignore.nix @@ -8,7 +8,7 @@ options.nullkomma.gitignore = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; - description = "Lines of the generated `.gitignore`; every aspect appends here."; + description = "Lines of the initial `.gitignore`; every aspect appends here."; }; config = { nullkomma.gitignore = [ @@ -16,14 +16,14 @@ "result" "result-*" ]; - # Managed files are re-included last, so a global excludesFile (e.g. one ignoring + # Initialized files are re-included last, so a global excludesFile (e.g. one ignoring # `.vscode/settings.json` or `.envrc`) can't keep them out of the flake source. perSystem = { config, ... }: { nullkomma.files.".gitignore".text = lib.concatLines ( ignores - ++ [ "# nullkomma-managed files" ] + ++ [ "# Initialized files" ] ++ map (path: "!/${path}") (lib.attrNames (lib.filterAttrs (_: f: f.enable) config.nullkomma.files)) ); }; diff --git a/modules/dev/tests.nix b/modules/dev/tests.nix index fd0fb06..b7c67f3 100644 --- a/modules/dev/tests.nix +++ b/modules/dev/tests.nix @@ -73,6 +73,20 @@ "/sub/b.nix" ]; }; + testInitializedFilesAreUserOwned = { + expr = { + cron = (perSystem fixtures.r).nullkomma.files ? ".github/workflows/cron.yml"; + driftCheck = has fixtures.r.checks "files"; + banner = + lib.hasInfix "Generated by nullkomma" + (perSystem fixtures.r).nullkomma.files.".github/workflows/push.yml".text; + }; + expected = { + cron = false; + driftCheck = false; + banner = false; + }; + }; testMdHasNoR = { expr = { air = (perSystem fixtures.md).treefmt.programs.air.enable; @@ -186,11 +200,10 @@ # keep-sorted end }; failures = lib.debug.runTests tests; - # A consumer's `files` check compares against files in its repo, which fixtures don't have. fixtureChecks = lib.concatMapAttrs ( name: flake: lib.mapAttrs' (check: lib.nameValuePair "fixture-${name}-${check}") ( - removeAttrs flake.checks.${system} [ "files" ] + flake.checks.${system} // { devshell = flake.devShells.${system}.default; } @@ -202,6 +215,23 @@ in { checks = fixtureChecks // { + file-initialization = pkgs.runCommandLocal "nullkomma-file-initialization" { } '' + mkdir project + cd project + ${fixtures.r.apps.${system}.write-files.program} + test -f .github/workflows/push.yml + test ! -e .github/workflows/cron.yml + test -w .github/workflows/push.yml + if grep -q 'Generated by nullkomma' .github/workflows/push.yml; then + exit 1 + fi + printf '# My workflow\n' > .github/workflows/push.yml + ${fixtures.r.apps.${system}.write-files.program} + printf '# My workflow\n' > expected + cmp expected .github/workflows/push.yml + test ! -e .github/workflows/cron.yml + touch $out + ''; unit = pkgs.runCommandLocal "nullkomma-unit-tests" { } ( if failures == [ ] then "touch $out" diff --git a/modules/templates.nix b/modules/templates.nix index 5fb9aa4..8f062c9 100644 --- a/modules/templates.nix +++ b/modules/templates.nix @@ -5,7 +5,7 @@ let Next steps: 1. `git add flake.nix` (flakes only see tracked files) - 2. `nix run .#write-files` to generate `.gitignore`, `.envrc`, CI stubs, … + 2. `nix run .#write-files` to initialize `.gitignore`, `.envrc`, CI stubs, … 3. `direnv allow` (or `nix develop`) 4. `nix run` to list all tasks ''; From 636e737ffec46d3bdc3399976256cdabb084d8a7 Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 19:41:13 +0200 Subject: [PATCH 3/6] Export shared output schemas to nullkomma consumers Move flake-schemas into shared inputs and the default aspect. Test schema inventories for fixtures and templates, retain dev partitioning, and remove the now-empty dev input flake. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 7 ++++- dev/flake.lock | 25 ------------------ dev/flake.nix | 7 ----- flake.lock | 17 +++++++++++- flake.nix | 3 ++- modules/{dev => core}/schemas.nix | 8 +++--- modules/dev/partition.nix | 6 ++--- modules/dev/tests.nix | 44 +++++++++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 43 deletions(-) delete mode 100644 dev/flake.lock delete mode 100644 dev/flake.nix rename modules/{dev => core}/schemas.nix (88%) diff --git a/README.md b/README.md index 9a8068a..94c9158 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ The first time you enter the directory, this might take some time. ## Using +The default aspect also exports [flake schemas](https://github.com/DeterminateSystems/flake-schemas) +for standard outputs (`packages`, `checks`, `devShells`, `apps`, etc.) and module/library outputs. +Schema-aware Nix tooling can inventory and validate these outputs; ordinary Nix can still consume the flake. +The shared `flake-schemas` input adds one lightweight node to consumer locks, with no language toolchain dependencies. + A project's entire nullkomma footprint is its `flake.nix` (and `flake.lock`): ```nix @@ -187,7 +192,7 @@ and one file can extend several aspects; [`modules/lang/quarto/r.nix`](modules/l nullkomma uses itself: its devshell, formatter, checks and this website come from the `dev` [partition](https://flake.parts/options/flake-parts-partitions.html), -whose extra inputs live in [`dev/flake.nix`](dev/flake.nix) and never end up in a project's `flake.lock`. +so development-only configuration is not evaluated by consumers. The fixtures in [`tests/fixtures`](tests/fixtures) are evaluated like projects using nullkomma, and their checks, devshells and sites are part of `nix flake check`. ## Issues diff --git a/dev/flake.lock b/dev/flake.lock deleted file mode 100644 index 88457e6..0000000 --- a/dev/flake.lock +++ /dev/null @@ -1,25 +0,0 @@ -{ - "nodes": { - "flake-schemas": { - "locked": { - "lastModified": 1780327564, - "narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=", - "rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6", - "revCount": 149, - "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.%2A" - } - }, - "root": { - "inputs": { - "flake-schemas": "flake-schemas" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/dev/flake.nix b/dev/flake.nix deleted file mode 100644 index 0999ef0..0000000 --- a/dev/flake.nix +++ /dev/null @@ -1,7 +0,0 @@ -{ - description = "Dev-only inputs of nullkomma; never land in consumers' flake.lock"; - - inputs.flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.*"; - - outputs = _: { }; -} diff --git a/flake.lock b/flake.lock index e59a66a..82aced4 100644 --- a/flake.lock +++ b/flake.lock @@ -40,7 +40,8 @@ "nixpkgs-r": [ "nixpkgs" ], - "treefmt-nix": "treefmt-nix" + "treefmt-nix": "treefmt-nix", + "flake-schemas": "flake-schemas" } }, "treefmt-nix": { @@ -61,6 +62,20 @@ "type": "tarball", "url": "https://flakehub.com/f/numtide/treefmt-nix/0.1.%2A" } + }, + "flake-schemas": { + "locked": { + "lastModified": 1780327564, + "narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=", + "rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6", + "revCount": 149, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.%2A" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e8f95b9..3057a85 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Opinionated, batteries-included, extra-DRY Nix boilerplate"; # Every input lands in every consumer's flake.lock, so keep this list short. - # Tools come from nixpkgs; dev-only inputs live in ./dev/flake.nix. + # Tools come from nixpkgs; schemas describe both our and consumers' outputs. inputs = { nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2605.*"; # Knob: R version and CRAN snapshot. Free unless a consumer overrides it, e.g. @@ -12,6 +12,7 @@ url = "https://flakehub.com/f/hercules-ci/flake-parts/0.1.*"; inputs.nixpkgs-lib.follows = "nixpkgs"; }; + flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/0.5.*"; treefmt-nix = { url = "https://flakehub.com/f/numtide/treefmt-nix/0.1.*"; inputs.nixpkgs.follows = "nixpkgs"; diff --git a/modules/dev/schemas.nix b/modules/core/schemas.nix similarity index 88% rename from modules/dev/schemas.nix rename to modules/core/schemas.nix index 9d8096e..c1591c5 100644 --- a/modules/dev/schemas.nix +++ b/modules/core/schemas.nix @@ -1,8 +1,8 @@ -# Flake schemas (DeterminateSystems/flake-schemas) make `nix flake show` and -# `nix flake check` understand nullkomma's non-standard outputs. +# Consumers inherit output schemas without declaring another input. +{ inputs, ... }: { - partitions.dev.module = - { inputs, ... }: + flake.flakeModules.default = + { ... }: let isModule = m: builtins.isAttrs m || builtins.isFunction m; children = what: output: { diff --git a/modules/dev/partition.nix b/modules/dev/partition.nix index 3cf3f1d..4f9a52f 100644 --- a/modules/dev/partition.nix +++ b/modules/dev/partition.nix @@ -1,10 +1,8 @@ -# Everything nullkomma needs for its own development (dogfooding, schemas, tests) -# is evaluated in the `dev` partition, with extra inputs from ./dev/flake.nix that -# consumers never fetch nor lock. +# Dogfooding and tests are evaluated only in the `dev` partition. +# Schemas come from the shared default aspect imported by dogfooding. { inputs, lib, ... }: { imports = [ inputs.flake-parts.flakeModules.partitions ]; - partitions.dev.extraInputsFlake = ../../dev; partitionedAttrs = lib.genAttrs [ "apps" "checks" diff --git a/modules/dev/tests.nix b/modules/dev/tests.nix index b7c67f3..7f1b8a5 100644 --- a/modules/dev/tests.nix +++ b/modules/dev/tests.nix @@ -48,6 +48,50 @@ importTreeRoot = ../../tests/fixtures/import-tree; tests = { # keep-sorted start block=yes + testConsumersExportSchemas = { + expr = lib.mapAttrs (_: flake: { + versions = map (name: flake.schemas.${name}.version) [ + "apps" + "checks" + "devShells" + "formatter" + "packages" + ]; + app = + (flake.schemas.apps.inventory flake.apps) + .children.${system}.children.write-files.evalChecks.isValidApp; + check = + (flake.schemas.checks.inventory flake.checks).children.${system}.children.treefmt.isFlakeCheck; + shell = (flake.schemas.devShells.inventory flake.devShells).children.${system}.children ? default; + module = + (flake.schemas.flakeModules.inventory { + default = nullkomma.flakeModules.default; + }).children.default.evalChecks.isModule; + }) (fixtures // lib.mapAttrs' (name: lib.nameValuePair "template-${name}") templates); + expected = + lib.genAttrs + [ + "md" + "quarto" + "r" + "template-default" + "template-quarto" + "template-r" + ] + (_: { + versions = [ + 1 + 1 + 1 + 1 + 1 + ]; + app = true; + check = true; + shell = true; + module = true; + }); + }; testGitignoreReincludesManagedFiles = { expr = let From b0588e6f179d10df85222d8fa7347af1cc0858b9 Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 20:01:00 +0200 Subject: [PATCH 4/6] Generate option reference for the exported flakeModules Per-system options are declared via mkPerSystemOption so they can be documented; the reference is rendered into the Quarto site. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- _quarto.yml | 7 ++- modules/core/devshell.nix | 40 +++++++++-------- modules/core/files.nix | 15 +++++-- modules/core/site.nix | 16 ++++--- modules/core/tasks.nix | 52 ++++++++++++---------- modules/dev/docs.nix | 77 +++++++++++++++++++++++++++++++++ modules/lang/quarto/package.nix | 7 +-- modules/lang/quarto/site.nix | 22 +++++++--- modules/lang/r/package.nix | 21 ++++++--- 10 files changed, 187 insertions(+), 72 deletions(-) create mode 100644 modules/dev/docs.nix diff --git a/README.md b/README.md index 94c9158..d73fff9 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Import only the ones a project needs; an aspect never adds anything to a project ### Knobs -Options live under `nullkomma.*` (see the `options.nix` and other files in [`modules/`](modules)), for example: +Options live under `nullkomma.*`, for example (the generated [option reference](reference.qmd) lists all of them, also under `perSystem`; build it with `nix build .#docs-reference`): ```nix inputs.nullkomma.lib.mkFlake { inherit inputs; } { diff --git a/_quarto.yml b/_quarto.yml index ac74db9..6ffb03d 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -1,8 +1,9 @@ project: type: website - # only the README; tests/fixtures holds other Quarto projects + # top-level pages only; tests/fixtures holds other Quarto projects render: - - index.qmd + - "*.qmd" + - "!tests/" website: title: "nullkomma" description: "Opinionated 🤓, batteries-included 🔋, extra-DRY 🤌 Nix boilerplate." @@ -10,6 +11,8 @@ website: left: - href: index.qmd text: Home + - href: reference.qmd + text: Reference format: html: theme: pulse diff --git a/modules/core/devshell.nix b/modules/core/devshell.nix index 0a19041..a7ad77d 100644 --- a/modules/core/devshell.nix +++ b/modules/core/devshell.nix @@ -1,30 +1,32 @@ { flake.flakeModules.default = - { lib, ... }: + { flake-parts-lib, lib, ... }: { - perSystem = + options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.devshell = { + packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Packages on `PATH` in `devShells.default`; every aspect appends here."; + }; + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables set in `devShells.default`."; + }; + shellHook = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Shell code run when entering `devShells.default`."; + }; + }; + }; + config.perSystem = { config, pkgs, ... }: let cfg = config.nullkomma.devshell; in { - options.nullkomma.devshell = { - packages = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - description = "Packages on `PATH` in `devShells.default`; every aspect appends here."; - }; - env = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = { }; - description = "Environment variables set in `devShells.default`."; - }; - shellHook = lib.mkOption { - type = lib.types.lines; - default = ""; - description = "Shell code run when entering `devShells.default`."; - }; - }; config = { nullkomma.devshell.packages = [ config.treefmt.build.wrapper diff --git a/modules/core/files.nix b/modules/core/files.nix index 54aa552..6e34bf8 100644 --- a/modules/core/files.nix +++ b/modules/core/files.nix @@ -1,12 +1,11 @@ # Initialize repository files once; existing files belong to the user. { flake.flakeModules.default = - { lib, ... }: + { flake-parts-lib, lib, ... }: { - perSystem = - { config, pkgs, ... }: + options.perSystem = flake-parts-lib.mkPerSystemOption ( + { pkgs, ... }: let - files = lib.filterAttrs (_: f: f.enable) config.nullkomma.files; fileModule = { name, config, ... }: { @@ -37,6 +36,14 @@ default = { }; description = "Files, keyed by path relative to the repo root, that nullkomma initializes if absent. Existing files are never overwritten or checked for drift."; }; + } + ); + config.perSystem = + { config, pkgs, ... }: + let + files = lib.filterAttrs (_: f: f.enable) config.nullkomma.files; + in + { config = { nullkomma.tasks.write-files = { description = "Initialize missing repository files (.gitignore, .envrc, CI stubs, …), preserving existing files"; diff --git a/modules/core/site.nix b/modules/core/site.nix index ca37c88..3b198c0 100644 --- a/modules/core/site.nix +++ b/modules/core/site.nix @@ -1,16 +1,18 @@ # Extension point: a docs aspect (quarto, pkgdown, …) sets the site, deploy aspects read it. { flake.flakeModules.default = - { lib, ... }: + { flake-parts-lib, lib, ... }: { - perSystem = + options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.site = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = "Static site (a directory with `index.html`); exposed as `packages.site`."; + }; + }; + config.perSystem = { config, ... }: { - options.nullkomma.site = lib.mkOption { - type = lib.types.nullOr lib.types.package; - default = null; - description = "Static site (a directory with `index.html`); exposed as `packages.site`."; - }; config.packages = lib.mkIf (config.nullkomma.site != null) { site = config.nullkomma.site; }; diff --git a/modules/core/tasks.nix b/modules/core/tasks.nix index 4bf91a5..107ed5d 100644 --- a/modules/core/tasks.nix +++ b/modules/core/tasks.nix @@ -1,29 +1,38 @@ # Named tasks become `apps.` (`nix run .#`); `nix run` lists them. { flake.flakeModules.default = - { lib, ... }: + { flake-parts-lib, lib, ... }: + let + taskModule = { + options = { + description = lib.mkOption { + type = lib.types.str; + description = "One-line description, shown by `nix run`."; + }; + command = lib.mkOption { + type = lib.types.lines; + description = "Bash script (checked by shellcheck); arguments are in `$@`."; + }; + runtimeInputs = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Packages on `PATH` while the task runs."; + }; + }; + }; + in { - perSystem = + options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.tasks = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule taskModule); + default = { }; + description = "Project tasks (the Makefile replacement), exposed as `apps.`."; + }; + }; + config.perSystem = { config, pkgs, ... }: let cfg = config.nullkomma.tasks; - taskModule = { - options = { - description = lib.mkOption { - type = lib.types.str; - description = "One-line description, shown by `nix run`."; - }; - command = lib.mkOption { - type = lib.types.lines; - description = "Bash script (checked by shellcheck); arguments are in `$@`."; - }; - runtimeInputs = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - description = "Packages on `PATH` while the task runs."; - }; - }; - }; toApp = name: task: { type = "app"; program = lib.getExe ( @@ -50,11 +59,6 @@ }; in { - options.nullkomma.tasks = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule taskModule); - default = { }; - description = "Project tasks (the Makefile replacement), exposed as `apps.`."; - }; config = { nullkomma.tasks = { check = { diff --git a/modules/dev/docs.nix b/modules/dev/docs.nix new file mode 100644 index 0000000..5e9fa16 --- /dev/null +++ b/modules/dev/docs.nix @@ -0,0 +1,77 @@ +# Option reference for the exported flakeModules, built as the flake-parts +# ecosystem does it (`nixosOptionsDoc` over an evaluated flake-parts module tree) +# and added to this repo's own Quarto site as `reference.qmd`. +{ + partitions.dev.module = + { + inputs, + lib, + ... + }: + { + perSystem = + { + pkgs, + system, + ... + }: + let + evaluated = inputs.flake-parts.lib.evalFlakeModule { inherit inputs; } { + imports = lib.attrValues (lib.removeAttrs inputs.self.flakeModules [ "flakeModules" ]); + systems = [ system ]; + perSystem._module.args.pkgs = pkgs; + }; + repo = "https://github.com/dataheld/nullkomma/blob/main/"; + relative = + declaration: + lib.head (lib.splitString "," (lib.removePrefix "${toString inputs.self}/" (toString declaration))); + optionsDoc = pkgs.nixosOptionsDoc { + # Only our own namespace: documenting foreign options would need their pkgs. + options = { + flake = evaluated.options.nullkomma; + perSystem = (evaluated.options.perSystem.type.getSubOptions [ "perSystem" ]).nullkomma; + }; + transformOptions = + opt: + opt + // { + declarations = map (d: { + name = "/${relative d}"; + url = repo + relative d; + }) opt.declarations; + }; + warningsAreErrors = false; + }; + reference = pkgs.runCommand "reference.qmd" { } '' + { + printf -- '---\ntitle: "Option reference"\n---\n\n' + printf 'Options of the exported `flakeModules`. Those under `perSystem` are set inside `perSystem = { ... }: { ... }`.\n\n' + cat ${optionsDoc.optionsCommonMark} + } > $out + ''; + in + { + packages.docs-reference = reference; + checks.docs-reference = pkgs.runCommandLocal "nullkomma-docs-reference" { } '' + for option in \ + nullkomma.github.enable \ + nullkomma.r.extraPackages \ + nullkomma.quarto.root \ + nullkomma.cloudflare-pages.project \ + perSystem.nullkomma.tasks \ + perSystem.nullkomma.files \ + perSystem.nullkomma.devshell.packages \ + perSystem.nullkomma.r.package \ + perSystem.nullkomma.quarto.extraFiles + do + grep -qF "## $(echo "$option" | sed 's/\./\\./g')" ${reference} || { + echo "reference is missing option $option" >&2 + exit 1 + } + done + touch $out + ''; + nullkomma.quarto.extraFiles."reference.qmd" = reference; + }; + }; +} diff --git a/modules/lang/quarto/package.nix b/modules/lang/quarto/package.nix index 029b83f..4b6df2c 100644 --- a/modules/lang/quarto/package.nix +++ b/modules/lang/quarto/package.nix @@ -1,10 +1,10 @@ { inputs, ... }: { flake.flakeModules.quarto = - { lib, ... }: + { flake-parts-lib, lib, ... }: { imports = [ inputs.self.flakeModules.default ]; - perSystem = + options.perSystem = flake-parts-lib.mkPerSystemOption ( { pkgs, ... }: { options.nullkomma.quarto.package = lib.mkOption { @@ -23,6 +23,7 @@ defaultText = lib.literalMD "`pkgs.quartoMinimal` with nullkomma's workarounds"; description = "Quarto, without bundled R/Python; engines come from the R/Python aspects."; }; - }; + } + ); }; } diff --git a/modules/lang/quarto/site.nix b/modules/lang/quarto/site.nix index 1d7c5ef..e3f30a9 100644 --- a/modules/lang/quarto/site.nix +++ b/modules/lang/quarto/site.nix @@ -2,6 +2,7 @@ flake.flakeModules.quarto = { config, + flake-parts-lib, lib, self, ... @@ -24,6 +25,18 @@ description = "Whether `quarto render` of `root` becomes `nullkomma.site`."; }; }; + options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.quarto.engines = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Engine environments (R, Python) on `PATH` for rendering; set by the language aspects."; + }; + options.nullkomma.quarto.extraFiles = lib.mkOption { + type = lib.types.attrsOf lib.types.path; + default = { }; + description = "Generated files (relative path to source) copied into the project before `nullkomma.site` is rendered."; + }; + }; config.perSystem = { config, pkgs, ... }: let @@ -31,11 +44,6 @@ inputs = [ quarto ] ++ config.nullkomma.quarto.engines; in { - options.nullkomma.quarto.engines = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - description = "Engine environments (R, Python) on `PATH` for rendering; set by the language aspects."; - }; config = { nullkomma = { devshell.packages = [ quarto ]; @@ -45,6 +53,10 @@ name = "site"; src = cfg.root; nativeBuildInputs = inputs; + preBuild = lib.concatMapAttrsStringSep "" (target: source: '' + mkdir -p "$(dirname ${lib.escapeShellArg target})" + cp ${source} ${lib.escapeShellArg target} + '') config.nullkomma.quarto.extraFiles; buildPhase = '' runHook preBuild export HOME="$TMPDIR" diff --git a/modules/lang/r/package.nix b/modules/lang/r/package.nix index 11390c5..1a5c5cc 100644 --- a/modules/lang/r/package.nix +++ b/modules/lang/r/package.nix @@ -3,7 +3,12 @@ { inputs, ... }: { flake.flakeModules.r = - { config, lib, ... }: + { + config, + flake-parts-lib, + lib, + ... + }: let cfg = config.nullkomma.r; rlib = import ../../../lib/r.nix { inherit lib; }; @@ -19,7 +24,14 @@ ); in { - perSystem = + options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.r.package = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = "R with every package from DESCRIPTION and `nullkomma.r.extraPackages`."; + }; + }; + config.perSystem = { system, ... }: let pkgsR = inputs.nixpkgs-r.legacyPackages.${system}; @@ -29,11 +41,6 @@ or (throw "nullkomma.r: R package `${name}` is not in `rPackages` of nixpkgs-r; pin a newer nixpkgs-r or drop it from DESCRIPTION/extraPackages."); in { - options.nullkomma.r.package = lib.mkOption { - type = lib.types.package; - readOnly = true; - description = "R with every package from DESCRIPTION and `nullkomma.r.extraPackages`."; - }; config.nullkomma.r.package = pkgsR.rWrapper.override { packages = map lookup names; }; }; }; From 6607ec222036482850f4712bbc9e7a721fd2e327 Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 20:07:01 +0200 Subject: [PATCH 5/6] Let R parse DESCRIPTION instead of a hand-written Nix parser Dependencies come from read.dcf and tools::package_dependencies in a small derivation; base packages are derived from the pinned R. Removes lib.r parseDCF/depNames/depsFromDescription/basePackages and exposes perSystem nullkomma.r.dependencies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 6 ++++ lib/description-deps.R | 10 ++++++ lib/r.nix | 64 +++--------------------------------- modules/dev/tests.nix | 25 +++----------- modules/lang/r/checks.nix | 3 +- modules/lang/r/package.nix | 39 +++++++++++++++------- tests/fixtures/r/DESCRIPTION | 4 +++ 7 files changed, 57 insertions(+), 94 deletions(-) create mode 100644 lib/description-deps.R diff --git a/README.md b/README.md index d73fff9..f8b3d02 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ To pin R and CRAN to a date, point nullkomma's `nixpkgs-r` input elsewhere inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; ``` +`nix flake update` then moves R and every R package together, and `flake.lock` records the exact revision. +nullkomma does not parse `DESCRIPTION` itself: R's own `read.dcf` and `tools::package_dependencies` do, +inside a small derivation that is evaluated on demand +(import from derivation, so the first evaluation builds R once). +Package versions come from the pinned package set; `DESCRIPTION` version constraints are not resolved. + ### Initialized files Some files must live in the repo, so `nix run .#write-files` initializes missing diff --git a/lib/description-deps.R b/lib/description-deps.R new file mode 100644 index 0000000..d6971c3 --- /dev/null +++ b/lib/description-deps.R @@ -0,0 +1,10 @@ +# Package names a DESCRIPTION depends on, one per line, parsed by R itself. +# Usage: Rscript description-deps.R DESCRIPTION FIELD... +args <- commandArgs(trailingOnly = TRUE) +fields <- args[-1] +db <- read.dcf(args[[1]], fields = c("Package", fields)) +db[, "Package"] <- "description" +deps <- tools::package_dependencies("description", db = db, which = fields)[["description"]] +# base packages ship with R itself +base <- rownames(installed.packages(lib.loc = .Library, priority = "base")) +writeLines(sort(setdiff(deps, c("R", base)))) diff --git a/lib/r.nix b/lib/r.nix index a8d41e9..8d7e7ca 100644 --- a/lib/r.nix +++ b/lib/r.nix @@ -1,56 +1,7 @@ -# Pure helpers for R projects; no packages, no IFD. +# Pure helpers for R projects. Parsing DESCRIPTION is left to R itself +# (see `description-deps.R`), never reimplemented here. { lib }: rec { - # Packages that ship with R itself and are therefore not in `rPackages`. - basePackages = [ - "R" - "base" - "compiler" - "datasets" - "grDevices" - "graphics" - "grid" - "methods" - "parallel" - "splines" - "stats" - "stats4" - "tcltk" - "tools" - "utils" - ]; - - # Minimal Debian Control File (DCF) parser for R DESCRIPTION files. - parseDCF = - text: - let - lines = lib.filter (l: lib.trim l != "") (lib.splitString "\n" text); - folded = lib.foldl' ( - acc: l: - if acc != [ ] && builtins.match "[ \t].*" l != null then - lib.init acc ++ [ (lib.last acc + " " + lib.trim l) ] - else - acc ++ [ l ] - ) [ ] lines; - field = - l: - let - m = builtins.match "([^: \t]+):[ \t]*(.*)" l; - in - if m == null then - throw "nullkomma: cannot parse DCF line `${l}`" - else - lib.nameValuePair (lib.elemAt m 0) (lib.trim (lib.elemAt m 1)); - in - lib.listToAttrs (map field folded); - - # "foo (>= 1.0), bar,\n baz" -> [ "foo" "bar" "baz" ], minus base packages. - depNames = - s: - lib.filter (n: n != "" && !lib.elem n basePackages) ( - map (d: lib.trim (lib.head (lib.splitString "(" d))) (lib.splitString "," s) - ); - defaultFields = [ "Depends" "Imports" @@ -58,15 +9,8 @@ rec { "Suggests" ]; - depsFromDescription = - { - path, - fields ? defaultFields, - }: - let - d = parseDCF (builtins.readFile path); - in - lib.unique (lib.concatMap (f: depNames (d.${f} or "")) fields); + # Only a cheap presence test: DCF field names start a line, continuations are indented. + hasPackageField = text: lib.any (l: lib.hasPrefix "Package:" l) (lib.splitString "\n" text); # nixpkgs mangles CRAN names: data.table -> data_table attrName = lib.replaceStrings [ "." ] [ "_" ]; diff --git a/modules/dev/tests.nix b/modules/dev/tests.nix index 7f1b8a5..d1d09b3 100644 --- a/modules/dev/tests.nix +++ b/modules/dev/tests.nix @@ -193,8 +193,10 @@ }; }; testRDependsOnDescription = { - expr = nullkomma.lib.r.depsFromDescription { path = ../../tests/fixtures/r/DESCRIPTION; }; - expected = [ "testthat" ]; + expr = (perSystem fixtures.r).nullkomma.r.dependencies; + expected = [ + "testthat" + ]; }; testRHasAirAndCheck = { expr = { @@ -212,25 +214,6 @@ expr = nullkomma.lib.r.attrName "data.table"; expected = "data_table"; }; - testRLibDepNames = { - expr = nullkomma.lib.r.depNames "R (>= 4.1), data.table (>= 1.0),\n rlang, utils"; - expected = [ - "data.table" - "rlang" - ]; - }; - testRLibParseDCF = { - expr = nullkomma.lib.r.parseDCF '' - Package: foo - Imports: - bar, - baz (>= 1.0) - ''; - expected = { - Package = "foo"; - Imports = "bar, baz (>= 1.0)"; - }; - }; testTemplatesAreFixtures = { expr = map builtins.readFile [ ../../templates/default/flake.nix diff --git a/modules/lang/r/checks.nix b/modules/lang/r/checks.nix index 486247d..8388a48 100644 --- a/modules/lang/r/checks.nix +++ b/modules/lang/r/checks.nix @@ -4,8 +4,7 @@ let cfg = config.nullkomma.r; rlib = import ../../../lib/r.nix { inherit lib; }; - isPackage = - cfg.description != null && (rlib.parseDCF (builtins.readFile cfg.description)) ? Package; + isPackage = cfg.description != null && rlib.hasPackageField (builtins.readFile cfg.description); in { options.nullkomma.r.check = { diff --git a/modules/lang/r/package.nix b/modules/lang/r/package.nix index 1a5c5cc..92b8d79 100644 --- a/modules/lang/r/package.nix +++ b/modules/lang/r/package.nix @@ -12,19 +12,14 @@ let cfg = config.nullkomma.r; rlib = import ../../../lib/r.nix { inherit lib; }; - names = lib.unique ( - lib.optionals (cfg.description != null) ( - rlib.depsFromDescription { - path = cfg.description; - inherit (cfg) fields; - } - ) - ++ cfg.extraPackages - ++ [ "languageserver" ] - ); in { options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.r.dependencies = lib.mkOption { + type = lib.types.listOf lib.types.str; + readOnly = true; + description = "Package names `nullkomma.r.description` depends on in `nullkomma.r.fields`, as parsed by R itself (base packages excluded)."; + }; options.nullkomma.r.package = lib.mkOption { type = lib.types.package; readOnly = true; @@ -35,13 +30,35 @@ { system, ... }: let pkgsR = inputs.nixpkgs-r.legacyPackages.${system}; + # Import from derivation: R's own DESCRIPTION parser, from the pinned R. + dependencies = lib.optionals (cfg.description != null) ( + lib.filter (n: n != "") ( + lib.splitString "\n" ( + builtins.readFile ( + pkgsR.runCommandLocal "r-description-deps" + { + nativeBuildInputs = [ pkgsR.R ]; + description = pkgsR.writeText "DESCRIPTION" (builtins.readFile cfg.description); + } + '' + export HOME="$TMPDIR" + Rscript ${../../../lib/description-deps.R} "$description" ${lib.escapeShellArgs cfg.fields} > $out + '' + ) + ) + ) + ); + names = lib.unique (dependencies ++ cfg.extraPackages ++ [ "languageserver" ]); lookup = name: pkgsR.rPackages.${rlib.attrName name} or (throw "nullkomma.r: R package `${name}` is not in `rPackages` of nixpkgs-r; pin a newer nixpkgs-r or drop it from DESCRIPTION/extraPackages."); in { - config.nullkomma.r.package = pkgsR.rWrapper.override { packages = map lookup names; }; + config.nullkomma.r = { + inherit dependencies; + package = pkgsR.rWrapper.override { packages = map lookup names; }; + }; }; }; } diff --git a/tests/fixtures/r/DESCRIPTION b/tests/fixtures/r/DESCRIPTION index 662332e..f64a794 100644 --- a/tests/fixtures/r/DESCRIPTION +++ b/tests/fixtures/r/DESCRIPTION @@ -6,6 +6,10 @@ Description: Exercises the R aspect of nullkomma: dependencies from the DESCRIPTION file, formatting with air and R CMD check. License: CC0 Encoding: UTF-8 +Depends: + R (>= 4.1.0) +Imports: + stats Suggests: testthat (>= 3.0.0) Config/testthat/edition: 3 From 0f04559058a880ef9bbedc0aa42255c9df4da9b5 Mon Sep 17 00:00:00 2001 From: Maximilian Held Date: Thu, 8 Oct 2026 21:25:18 +0200 Subject: [PATCH 6/6] Select R snapshots from project profiles and build pinned remotes Read DESCRIPTION constraints with R, reuse nixpkgs package recipes, and initialize a user-owned PPM profile. Append dated snapshot pins during maintenance and test the snapshot/remote consumer contract. Document the initial resolver limits and transitive locking model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/maintenance.yml | 2 + README.md | 67 +- lib/description-constraints.R | 22 + lib/description-meta.R | 4 + lib/description-remotes.R | 5 + lib/r-snapshots.json | 681 +++++++++++++++++++ lib/rprofile-snapshot.R | 14 + modules/dev/docs.nix | 3 + modules/dev/tests.nix | 24 + modules/lang/r/options.nix | 14 + modules/lang/r/package.nix | 231 ++++++- modules/lang/r/profile.nix | 28 + templates/r/flake.nix | 3 +- tests/fixtures/r/.Rprofile | 1 + tests/fixtures/r/DESCRIPTION | 4 +- tests/fixtures/r/flake.nix | 3 +- tests/fixtures/r/tests/testthat/test-hello.R | 1 + tools/update-r-snapshots.py | 19 + 18 files changed, 1088 insertions(+), 38 deletions(-) create mode 100644 lib/description-constraints.R create mode 100644 lib/description-meta.R create mode 100644 lib/description-remotes.R create mode 100644 lib/r-snapshots.json create mode 100644 lib/rprofile-snapshot.R create mode 100644 modules/lang/r/profile.nix create mode 100644 tests/fixtures/r/.Rprofile create mode 100644 tools/update-r-snapshots.py diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index e900e92..fa90210 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -14,6 +14,8 @@ jobs: - uses: actions/checkout@v7 - uses: DeterminateSystems/determinate-nix-action@v3 - uses: DeterminateSystems/flake-checker-action@v14 + - name: Append available R snapshots + run: python3 tools/update-r-snapshots.py - uses: DeterminateSystems/update-flake-lock@v29 with: pr-title: Update flake.lock diff --git a/README.md b/README.md index f8b3d02..0e28b1a 100644 --- a/README.md +++ b/README.md @@ -131,23 +131,70 @@ inputs.nullkomma.lib.mkFlake { inherit inputs; } { } ``` -To pin R and CRAN to a date, point nullkomma's `nixpkgs-r` input elsewhere -(this adds one node to `flake.lock`): +### R dependencies and snapshots -```nix -inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; +Keep dependencies in `DESCRIPTION`, including version constraints. Select the CRAN date +in the project's ordinary `.Rprofile`, so non-Nix users can use the same setting: + +```r +options(repos = c(CRAN = "https://packagemanager.posit.co/cran/2026-01-05")) +``` + +nullkomma reads that setting with R and selects a pinned +[rstats-on-nix/nixpkgs](https://github.com/rstats-on-nix/nixpkgs) revision from +[`lib/r-snapshots.json`](lib/r-snapshots.json). Only listed dates are supported. +This reuses nixpkgs' source hashes, dependency graph, R build recipes and curated +system libraries; nullkomma does **not** fetch and resolve arbitrary PPM packages. +The same calendar date is not a guarantee of identical PPM and nixpkgs package sets, +nor of consistency after overrides. R checks the project's version constraints +against selected versions, failing rather than silently ignoring mismatches. + +The locked nullkomma revision freezes the date-to-commit table; the snapshot is +**not an additional `flake.lock` node**. Updating nullkomma adds available dates, +but does not advance your `.Rprofile` date. Maintenance appends new dates without +changing existing pins (`python3 tools/update-r-snapshots.py`). Without a dated +profile, R follows the locked `nixpkgs-r` input (by default the main nixpkgs input). +An explicit `inputs.nullkomma.inputs.nixpkgs-r.url` remains an escape hatch when +there is no profile date. + +Pinned GitHub overrides are supported in `DESCRIPTION`, for example: + +```dcf +Imports: withr (>= 3.0.3.9000) +Remotes: r-lib/withr@d82e4bc2d69a34f044ad205210e26207bfb8f3e0 ``` -`nix flake update` then moves R and every R package together, and `flake.lock` records the exact revision. -nullkomma does not parse `DESCRIPTION` itself: R's own `read.dcf` and `tools::package_dependencies` do, -inside a small derivation that is evaluated on demand -(import from derivation, so the first evaluation builds R once). -Package versions come from the pinned package set; `DESCRIPTION` version constraints are not resolved. +`github::owner/repo@` and `package=owner/repo@` also work. +Existing CRAN recipes retain their system-library customizations; new packages +use `rPackages.buildRPackage`. Extra system libraries can be supplied with +`perSystem = { pkgs, ... }: { nullkomma.r.buildInputs.myPackage = [ pkgs.gdal ]; };`. +Remote runtime/build dependencies and their version constraints are read from +that source's DESCRIPTION. Native build hooks may still require a custom recipe. + +**Initial limits:** mutable refs are rejected, not automatically locked. Other +pak references (GitLab, URLs, subdirectories, local packages, `Config/Needs`), +transitive `Remotes`, dependency cycles and arbitrary constraint solving are not +supported. Full pak-compatible resolution and locking mutable refs remain open +work; this is not a universal DESCRIPTION resolver. GitHub source availability +is not guaranteed forever. Pure GitHub fetching was tested with Determinate Nix +3.21.9; older Nix versions may require additional source hashes. + +R's `read.dcf`, `tools::package_dependencies` and version parser run inside small +derivations (import from derivation). Evaluation needs a build-capable machine +for the target system and IFD enabled, and may build both bootstrap and snapshot R. +The profile executes in an isolated build directory, not the project directory: +keep its repository setting self-contained and do not depend on other files, +network access or installed packages. Ordinary R startup still loads it normally. +`nix develop` creates a writable `R_LIBS_USER` outside the store for interactive +`install.packages()`; these installations are **not Nix-reproducible** and may +shadow pinned packages. Nix checks do not use that user library. Non-Nix users +can use ordinary R/pak tooling without adopting nullkomma. ### Initialized files Some files must live in the repo, so `nix run .#write-files` initializes missing -`.gitignore`, `.envrc`, `.github/workflows/push.yml` and `.vscode/*.json` files. +`.gitignore`, `.envrc`, `.github/workflows/push.yml`, `.vscode/*.json`, and (for R) `.Rprofile` files. +The initial R profile selects the latest shipped snapshot date; existing profiles are preserved. Configure their initial contents in `flake.nix` (e.g. `nullkomma.gitignore`, `nullkomma.editor.vscode`). After initialization they belong to you: edit them directly. Re-running `write-files` preserves existing files, and there is no drift check or automatic overwrite on upgrades. diff --git a/lib/description-constraints.R b/lib/description-constraints.R new file mode 100644 index 0000000..715ddcf --- /dev/null +++ b/lib/description-constraints.R @@ -0,0 +1,22 @@ +# Validate DESCRIPTION constraints against the selected package set, using R's parser. +# Usage: Rscript --vanilla description-constraints.R DESCRIPTION VERSIONS FIELD... +args <- commandArgs(trailingOnly = TRUE) +db <- read.dcf(args[[1]], fields = args[-c(1, 2)]) +versions <- read.delim(args[[2]], header = FALSE, col.names = c("name", "version"), colClasses = "character") +selected <- setNames(versions$version, versions$name) +base <- installed.packages(lib.loc = .Library, priority = "base") +selected <- c(selected, setNames(base[, "Version"], base[, "Package"]), R = as.character(getRversion())) +for (field in colnames(db)) { + if (is.na(db[1, field])) next + for (dep in tools:::.split_dependencies(db[1, field])) { + if (is.null(dep$version)) next + actual <- selected[[dep$name]] + if (is.null(actual)) stop("nullkomma.r: no selected version for ", dep$name) + matches <- do.call(dep$op, list(package_version(actual), dep$version)) + if (!isTRUE(matches)) { + stop("nullkomma.r: DESCRIPTION requires ", dep$name, " (", dep$op, " ", + dep$version, "); selected version is ", actual, ". Choose another snapshot or pinned remote.") + } + } +} +cat("ok\n") diff --git a/lib/description-meta.R b/lib/description-meta.R new file mode 100644 index 0000000..3438924 --- /dev/null +++ b/lib/description-meta.R @@ -0,0 +1,4 @@ +# Package name and version of a DESCRIPTION, one per line. +# Usage: Rscript description-meta.R DESCRIPTION +db <- read.dcf(commandArgs(trailingOnly = TRUE)[[1]], fields = c("Package", "Version")) +writeLines(db[1, c("Package", "Version")]) diff --git a/lib/description-remotes.R b/lib/description-remotes.R new file mode 100644 index 0000000..d818cf6 --- /dev/null +++ b/lib/description-remotes.R @@ -0,0 +1,5 @@ +# `Remotes:` entries of a DESCRIPTION (pak/remotes syntax), one per line. +# Usage: Rscript description-remotes.R DESCRIPTION +db <- read.dcf(commandArgs(trailingOnly = TRUE)[[1]], fields = "Remotes") +remotes <- db[1, "Remotes"] +if (!is.na(remotes)) writeLines(trimws(strsplit(remotes, ",")[[1]])) diff --git a/lib/r-snapshots.json b/lib/r-snapshots.json new file mode 100644 index 0000000..9ae2d3d --- /dev/null +++ b/lib/r-snapshots.json @@ -0,0 +1,681 @@ +{ + "2019-03-14": "b082d27904cdff3e9d7027e6cc08f94b549ee15f", + "2019-05-05": "0cc5233098313ae1474b11d1d54e27097786a989", + "2019-05-06": "85c4d3f6c624abbef3d2f2ab126bda850467532e", + "2019-05-07": "7710cf3541174beac64c3a2c6c47984ded0bdd57", + "2019-07-22": "bcfa3c58624f02960d48181b2e5dd5bf5bc0b5f1", + "2019-12-19": "38f751a46df27f19cba826e3fc8c75edeadff2f0", + "2020-03-12": "75d4f52711abdd73ffb51722cd925c8f56952dc8", + "2020-04-27": "4c792b3812cf513f508db2f025eaf00fb4139e80", + "2020-06-07": "e6c43ddc719e448204bdf0402e9aebd74ebf3b49", + "2020-08-20": "758ac22327850ac17d24c34e044200422d428c0e", + "2020-10-30": "09637b49ec41a7e4e77b9207af1456b07e0ec9c8", + "2021-01-01": "1c9dc384f385e0e1f85aad777db857e56093a329", + "2021-02-26": "7c47254f9f10b20b18ecd516315f11cca3cfbe68", + "2021-04-01": "956f5c957922428a91c1152fe517abb4791ac28a", + "2021-05-29": "fc15ea73bceee6d3521c7efb4447321de8d688d3", + "2021-08-03": "62a4004611efed250894589ad20386a557a19f98", + "2021-10-28": "0327623aa2e237a511a7939f9ebb5e7833bb698c", + "2022-01-01": "8ba515641075bb8c9c1b8314d7c554f4c437493e", + "2022-01-16": "1210919efe86791fa4d03df9035e58e4e5dfeda2", + "2022-03-01": "1cd08a64b9016a9e117246767e7b5977f13fdb80", + "2022-04-19": "f6a6c9b078ad9e785cd16488b309a85cc6839dd5", + "2022-06-22": "064833c8f5c4a4b496e41ef5d4e8ea379a235b2f", + "2022-08-22": "fc7a39af123d87c0a69e36057f179690ce091c09", + "2022-10-20": "6df55dfda5a58c1a64170a2877f3d6b3d9436bf1", + "2022-12-20": "657db1a90f0e1ca416e0d44f70ac78d222fcc98e", + "2023-02-13": "59d5623e98797e3161c55b0bd2c1b3825bf92b70", + "2023-04-01": "a0a7258406c80099e4d8a962b95a189a379405c3", + "2023-06-15": "85a25be3fe1001ba16421eba7902aaa80090f4e6", + "2023-08-15": "20e7c4f5cecd85aace5cf1140124e769041b567d", + "2023-10-30": "794758a84ce37ce87c8679af0a871724aae5e6cf", + "2023-12-30": "512857768c81297d119c60003a7c8ee8c1ab3133", + "2024-02-29": "be5ffd8f5d8c146e150b6f8e204a75b088986b18", + "2024-04-29": "6f0e43799c20e56909cb07dcd0480a51269f13c2", + "2024-06-14": "2278316997755b298051fc245fde40f13caab9ad", + "2024-08-19": "c42455edc5cba8d30e36912ba7a20ff4b53bfe58", + "2024-10-01": "24bf799c8a845bcd82ec8179a8f2d744e63831bc", + "2024-12-14": "00294e15c7d8fe98c7b2183adccce34f969673fd", + "2024-12-15": "2b620f87f01e88abe532959654aaa8fcc5c2dac5", + "2024-12-16": "f99b21acdce727bb2dc6fa5e5ddbffac39b01c31", + "2024-12-17": "8af47b5017bdb1db6f36cc4bf2615ea8b918e50a", + "2024-12-18": "80bcd0554d2f0a69bb717df71f6c75194be03409", + "2024-12-19": "f0d39388f44fb18951dec1e18968dfd8834f17ce", + "2024-12-20": "3807d7468a171e74673229e5c09499d0f01bcbb0", + "2024-12-21": "3b82faf99f7ff61f638282ab8ce92c7ddc762d24", + "2024-12-22": "dee80e746c0ab193e31db53371c5de8d633cdc7b", + "2024-12-23": "5487fd20ccbebda1093e298ebd7cef410cab1511", + "2024-12-24": "eb639250c990c63a3a532cd6b89083d41ca53f4f", + "2024-12-25": "15cee297c2b1dcbd327f4b6ac55ffa61a4dba20d", + "2024-12-26": "86dbedd949548888021bbb2dddf49db8760559c0", + "2024-12-27": "be7dc432aab4cf82e6e1d4188aaa1650c8ba55ab", + "2024-12-28": "7ff559de2c3f5d592535af2858eab4b4018dcb9e", + "2024-12-29": "00418a317cdbbf0c812c0210d33797851144c872", + "2024-12-30": "2b8804186034ec3e08662e69da8de056c3807649", + "2024-12-31": "348a03d36a49ccde84b78da993e0ceec15dcd4d5", + "2025-01-01": "c6ff348af6b450e62b0baf3e2e0504f72c9faebe", + "2025-01-02": "c5f8ab9e7476ebc93880ee6b98a5d33ae9068db9", + "2025-01-03": "fcc02cda8b501f67b412efa69632e30515bcd91b", + "2025-01-04": "0a97f13be0a512ec44d7dde9098c6e66eb67aa0f", + "2025-01-05": "9041af49b2260bfc951fce2880ae9036df4ef8a0", + "2025-01-06": "6e9162cb4b97c97ae0fca3602bf02c026ffb9dd3", + "2025-01-07": "968bff4424ecb86bc84ff7f4c6c0ffcb066a9bd4", + "2025-01-08": "d398d28a868f809dfd07083066c9a541c5e6b812", + "2025-01-09": "d68e14ba9aa6554d86f4466a9545cc7de0c45b7c", + "2025-01-10": "2026f155fac30713da051be10d3915b01686b17f", + "2025-01-11": "e714150a101f3e4c76b7cd72891a646075eccfc8", + "2025-01-12": "efba521d5743f1da24d30d9b056c72746feb089a", + "2025-01-13": "7d3ed1c19ed6808847422c45374ee4d266238639", + "2025-01-14": "c2a805cef7cda476273266edeac5ce795b906a57", + "2025-01-15": "44c986c45fb00d560e910e52aa2a561e03a6c42d", + "2025-01-16": "9796f3e807c4a6d6c0a1b933d96e265620f472cd", + "2025-01-17": "360e351c481783dac521cfe8b338ffad17a74b30", + "2025-01-18": "97235c7fc2bfd131bb19be8c720b223b41ad3c19", + "2025-01-19": "14b266832ef0c3f656956880ee4aba0ca26aaf77", + "2025-01-20": "130ee6ef701834d10b7624a28824a5e477905546", + "2025-01-21": "881214c648d73392dc4f130f4373f7ecde23e6a0", + "2025-01-22": "8bae7adf8d994af2a994be66b2eb35fc8790269d", + "2025-01-23": "9af7e8f7f7b1d01226e238310a871dfa447bb141", + "2025-01-24": "37912d459fbbede08a12735176e62df140060ead", + "2025-01-25": "66f11255274365f0492f8f95765516601cd29e7d", + "2025-01-26": "ab077423109982fc991791769c048b0fc199f5f8", + "2025-01-27": "0d06e9fb38d7f58a5b58179e44083f0606eb5981", + "2025-01-28": "0b577ee399da1daf098bcaa8288a36c420864fd2", + "2025-01-29": "b27508d513efe874829ae4520c40090843af4cce", + "2025-01-30": "48a06f1fe66b2a5572c6de82a59f10ab91c4fd17", + "2025-01-31": "580f10d24408e42e0abeea104a38f41bc9042e18", + "2025-02-01": "c430ef24ef9a9a99cdd4534420005f838ff55876", + "2025-02-02": "e1f18bdaef13e202956427116226d35015736559", + "2025-02-03": "5b6cd7feea5316809d71348ed664c8b96c1892aa", + "2025-02-04": "a21354a186a59d8db8954da373ccdc07e248113c", + "2025-02-05": "48df87678166fcd720a732bd2f9486e661f60712", + "2025-02-06": "dc5ee1441c233c4166256f97f95a20e6e0759cc7", + "2025-02-07": "3d3bc381f381486e7be00aef1c161475749f97cc", + "2025-02-08": "82cde948b97bdd1d847e7b1a3c282cabebf1b8f7", + "2025-02-09": "919d70ccb38f545fd9674c9d3c32a70d21ce239a", + "2025-02-10": "4e08e099e4e722f368f5b601b9fa17ef85ed2937", + "2025-02-11": "008de9f701c879c5a2e9e5afcc5de84be99510b3", + "2025-02-12": "dd25a51d94624ac53f8d7a292cb18f9ed95d257f", + "2025-02-13": "da0c4c77d23771d40e173db4c737368fcc91dbec", + "2025-02-14": "fc86cf0fb5897b1bd2b6e8d550a0916e200a01e6", + "2025-02-15": "7009816b83e7a5182681ed9e0243577bf358b4dc", + "2025-02-16": "c523d845a0743ffe02426502d5de9162eb251c05", + "2025-02-17": "ac246d27046be64677d6a5b35a49aa98b84ee345", + "2025-02-18": "77092cc4cc4d1c1061032e87710a6dc2648c9fcb", + "2025-02-19": "2e83c70ad371b30dd73b7ed71ca6efa6ae7a1c62", + "2025-02-20": "87c18d4bf64fa97ef0654801da25f6d3407d2331", + "2025-02-21": "41a5e455703cba29e01ad2631b6d2f301ceded4d", + "2025-02-22": "595a81848c514f898c7390d4304fa9134a0f6dc1", + "2025-02-23": "0bb1559ba86b944a2b94e69bb51a1c481938e92d", + "2025-02-24": "cca4742831b57bf368e0a3fb72787ba92c2ef35b", + "2025-02-25": "702f9aafabdd2275678547e98dd029643bd26966", + "2025-02-26": "08a71f2ab78988611bfa607264dcfaeaf786fe8d", + "2025-02-27": "04a520a782f6730c05eed13b82b2ad7d17c215e8", + "2025-02-28": "a3cad29718ea3797d718ab5cdcf145210cff7de6", + "2025-03-01": "b3096129f2297b3a7940a4d3f9f542c8318e016a", + "2025-03-02": "7e0d0aa85ea6f80c2e188e939ce67b3758faca99", + "2025-03-03": "8eeec8e54e0c2360fec6c0d60037f1687380f643", + "2025-03-04": "b5bdbf4745c86cf8e672bbd5bc3fcf71713fb78b", + "2025-03-05": "0a511282f6e0421a5679837d6d6d910df0536112", + "2025-03-06": "f112188583cc304a5ce5223833c36927315c3663", + "2025-03-07": "789fea22b2d696a76cbdbb1d9ac7da468509e438", + "2025-03-08": "b05e8108eb75dde62330b814e549802bc7f7a74e", + "2025-03-09": "58214a5d4200750fe1fd28ff9b546967df705a45", + "2025-03-10": "2d8730eac76e8f91f2b2aa228b84f21d6ff52176", + "2025-03-11": "a2b29437766c215957d16688fafea494cdd45fde", + "2025-03-12": "75af11fedc61c290f261f846c51d8bc644142166", + "2025-03-13": "9f4b9e2c18c217354892e86c165b8c836abfbe9d", + "2025-03-14": "19b9099bc0b6fd31dbf8245e904979f403eb4db0", + "2025-03-15": "634aeb954f2b9545cba2b1fc411ddbaef28d7b42", + "2025-03-16": "4c029b477f2036bb8436dff04c63d161a8b077c2", + "2025-03-17": "1306cdfa1080f2c9bd3758890193e2e2fd61c959", + "2025-03-18": "1cacc8cc373daf0633cb4b4c4b9ed70d43394ec9", + "2025-03-19": "750c2265405d2a18a1811fa1ca5390d09ac9d858", + "2025-03-20": "13a715709e5f1b7f6a2a5558250686c80b27546d", + "2025-03-21": "bdba64efac650073879984b2b8aaac7acfd308ea", + "2025-03-22": "35baf49f34a268a0c73e57b746d991f9be1bc788", + "2025-03-23": "54aa069f2b705f9af92e77384764c599ef6b8b4b", + "2025-03-24": "1fbc36bc2f1a56362fde411c76845decb20c793d", + "2025-03-25": "2ea1404a283f5b760b917ab2b5a8d8c313806dda", + "2025-03-26": "f614ebe87dee7a1a7b9c5044307b4984e7b989ec", + "2025-03-27": "7d03e5d29afa7429a78f4f2d434c648bad185a21", + "2025-03-28": "5c829e4b03325efc25d79383986f93a1b5d0e6ee", + "2025-03-29": "518eb69296a115cafeed35ae48e63006bdaf84c4", + "2025-03-30": "41454fb5b4f76cf40a79792766a30fe5dea42a24", + "2025-03-31": "38a483f473fdfff1a16612c1b64434a4c7c1c930", + "2025-04-01": "a8ffe6150e15ee8ad13f4bca8fc96d671e17f720", + "2025-04-02": "260e347d1ed5c6fe7622744eaec181cee7466fdb", + "2025-04-03": "b360cd4ab9d5767cbfc909d231ba4bef6e105333", + "2025-04-04": "810f1eb0906365e63fd72e5ec21a0a1a4231460e", + "2025-04-05": "5fad8789624da362aa0c792598739b1ef59a2159", + "2025-04-06": "1d2d0dc201502b830fea0ab6f3d690eb6f94d791", + "2025-04-07": "d9a1cf5d71d46287f79c7a93e5160da391bab12d", + "2025-04-08": "5c070dc22d9bb4aaf8e2e07a65e4d8e06b83cec2", + "2025-04-10": "c84c9c3b239b1e2ca1f388dbb40e654eaf7e1014", + "2025-04-11": "b8d8fb3bc78d94a9c6bd1c565e89f212b363aa5b", + "2025-04-12": "dd3c5a3f0ab73278186f0dac3c7ace9047f4f9fa", + "2025-04-13": "329295bb2054fc3f0ffae0760f322fe26b4a9287", + "2025-04-14": "dfa274d8a386a7f2e74144028a55f0320353fd90", + "2025-04-15": "04500f22be72d2e0284dbce202ae8fc559afcedf", + "2025-04-16": "1a8663c24f1b53f14fa00f703758bfbcecffa1c4", + "2025-04-17": "60ec7a88e805a342eee13f377bf998fb0f2b8648", + "2025-04-18": "60387223dbc6639af2359c7dc166dcd296a74a85", + "2025-04-19": "a44e409abdc286f09b67cc1a528d6a32e0c20af6", + "2025-04-20": "c2002af1cbbd10a7b880b8aaa12ffa975a59db21", + "2025-04-21": "5c27900b47657c01292bfce11ab060718ae2dd8c", + "2025-04-22": "8110c8c501be33e6a974f858a756bd1120667669", + "2025-04-23": "3c736c8d2b64825ddf85da6a6def9fe485b81423", + "2025-04-24": "f6ed7e67fde4fe5fcdc2c2d05974cef9f4059acf", + "2025-04-25": "e1c783075ae10c18be3393f211c7723e571c9593", + "2025-04-26": "18f5d257470ec2ae96397395e8aca6f9e9506eef", + "2025-04-27": "2fb1ba8c89ce285c11229f670112ff9ec122181a", + "2025-04-28": "5a401d87c2994c0c27a7c0ee848f01f66342ccd0", + "2025-04-29": "332985c032bd22a87eb7906536c0ec3b49e25cc9", + "2025-04-30": "40b5ed374ce1b4f0fc0e77298ef3798d7b5cdb37", + "2025-05-01": "44f2af441c8a579e1d2aa00a896998fe1f993c4a", + "2025-05-02": "b53a84f37641b91483390c381a5ad6f20b5261d0", + "2025-05-03": "45209f498a598f06e401edac2bfc23622e9894c3", + "2025-05-04": "3a4a5f9239c42fae98d3e022704f45ff14139687", + "2025-05-05": "128f09e29e335c82c9d1a08cd9e1facac2914bd5", + "2025-05-06": "f53c90f865bff8852d2a728e682fa97b11dcb50b", + "2025-05-07": "592a2c4ce8566dca651459158cdb16a43601a6fb", + "2025-05-08": "a63254d3e06d1b251a2e9fbca142e6da4508d05c", + "2025-05-09": "8020721d6ee9f2719309ff8ce3cb89f9b50e33d5", + "2025-05-10": "36a3a8eceba0e3cee23d5f5ab470a7da00bbf068", + "2025-05-11": "cd9abd9cac1944fd1d8244db4122224f1540f3d5", + "2025-05-12": "1f3f779c47f0edb50dfea0deb481c835cf89e593", + "2025-05-13": "d0322bf9dcc48c386255c2396eb92e41c6ab0351", + "2025-05-14": "ad1910e516e44959362ed32defd257837975aa38", + "2025-05-15": "99d2d64ad21f88bf77fe679d8442344d26ad2bfd", + "2025-05-16": "8adac0bd319fa48270c757309ed059fcb8e33f6f", + "2025-05-17": "bcd937b6ea00800b2009a925c92d4324a8944b79", + "2025-05-18": "e6b4429720d723a294e45f94ab02e5d8e382d189", + "2025-05-19": "3ea7c68de3786f18ebcedd662afd2bb40487d065", + "2025-05-20": "19d2046a667fb9cefcdf5d55127cde4ac652421b", + "2025-05-21": "b3b48efee42e6eb69cfddbde5d32d9c18a580315", + "2025-05-22": "f88f03d1894ed8bc33543baf95d91140ca56f609", + "2025-05-23": "e6d614c81288cfb382aab507be1731109bf9d3a5", + "2025-05-24": "04bdf781743bc8d5527bebde126593a59633daba", + "2025-05-25": "e7b96e83dcd8c491ac6e29a6d7dff2c73ad35489", + "2025-05-26": "79cfbdf5ebfaff840cbf83a991b2ac591ffe7ff9", + "2025-05-27": "ddf69a834a45d3834f0c7d623f914c9f7dac5033", + "2025-05-28": "7a7b3da7047978cc626bf6aac88b92aa40cbc080", + "2025-05-29": "9c7b115f2a1d2a79cd228a242d431f2207eb72df", + "2025-05-30": "561695d4b2052b45d87e4688b4d8499866f2201d", + "2025-05-31": "e8da67b526b3a343300b18ecf052a8802647b746", + "2025-06-01": "2b671796c3d649168a036a4b34599261b566bbd3", + "2025-06-02": "308bf419a9abb48adace357a1d1b87e399b6d83c", + "2025-06-03": "af4a1921b3d06386f1194846e1f8a67ac0e7cdf0", + "2025-06-04": "4c388144942b94e9b87bf2c64b0df1e4549c3397", + "2025-06-05": "49d4f31488b4efbf5526867247f3d4e441757d56", + "2025-06-06": "cc41d60b7f0fbd62a6baf699c996ecb975597379", + "2025-06-07": "d3a57d41cb20271c3335eed116fd8df213cdf04b", + "2025-06-08": "9a043c58ecc74ae58759e4e502cee83f962d1129", + "2025-06-09": "0a8df52f2fd8f27599e88692ca9a61cf4e0ccf22", + "2025-06-10": "b1d821dd178c345c2a6d7585066ec0fc36eae594", + "2025-06-11": "b7a47b3710b52c7601c678389257c0756f57f60c", + "2025-06-12": "646b510f79360a95bfdc72305c95eb1c18b264a6", + "2025-06-13": "a84d8c4789a9af03b252bc0f87e258a0b9ebd2ae", + "2025-06-14": "263be8675361e6b4e9ad8b7251fb932897e1f712", + "2025-06-15": "21734711cb31b1e1eb585e26027e339b7dbe051c", + "2025-06-16": "aa5bb2b9225782bcf5b3e624aebf4d5798e6fb82", + "2025-06-17": "306422b4715f71036c48392958652d91c1d6c379", + "2025-06-18": "6b0766ac68738ca7cddd783baca4ec75e4333d3b", + "2025-06-19": "c08d4a64492aec916ead2c9e962208c3c9259a85", + "2025-06-20": "a28d222ec888542403e896a505a3e6ad1bf48447", + "2025-06-21": "d70f423c08ec41d667d71ef478b730bed9d0e925", + "2025-06-22": "82c1427ecb881549ef808058c48fe92c82dd9469", + "2025-06-23": "70f4b5a4ca9d6d3aad4d66e26a298d3fae7459aa", + "2025-06-24": "7aa8bdb6ebae5857e43b3235af3695e9dd0ad0fd", + "2025-06-25": "928041bd0c92328fffeefde959ee74c4170a3278", + "2025-06-26": "cc157b95cdb415314443148da1e6895148d24042", + "2025-06-27": "9a628a6a81c1ba4f293c9b20b5c7b923d9703e51", + "2025-06-28": "5dabd41a827aeb0f1d3bd8a93c377818614c818d", + "2025-06-29": "b32d209e69973369a4dfe85b612a5e6e3a52206c", + "2025-06-30": "f97dc8c4a815229388464b24ae84cd7839b891ac", + "2025-07-01": "65daf2ecd6e2149f2286dc7979d766750e105a90", + "2025-07-02": "b482cf98ca903bdaa4403808a8f7cf930645511a", + "2025-07-03": "8d0979b9639300420e0f148e6b6e9e3a1dd041da", + "2025-07-04": "37f8117c147adf0467d436675b5cecd4ae4620c5", + "2025-07-05": "4fac29e04f6f58d5d7f33b98494c2bdae3f87776", + "2025-07-06": "217c1b1d641b2128166e7f3e6c2fe2b9c3bf98a1", + "2025-07-07": "7008f582beb9a27f32718f547946638e8b4ad252", + "2025-07-08": "5cf8314e08354235fe6f59badd3afa729d0df1fe", + "2025-07-09": "cee3239a5c892ebad0bba67fb6ea64231c64cf1c", + "2025-07-10": "f89733877809763261347135df24d11687b6c847", + "2025-07-11": "8f8563fbb35479cf00033d120675c0a3e8fc7573", + "2025-07-12": "6412c72d9b4d3de45245c17d634c58a6860a8be4", + "2025-07-13": "9c732b66d145841adb3d331a1188ed3f1fe6f0a8", + "2025-07-14": "29041ec2137f49438056ea0a0114ceda07abdbd2", + "2025-07-15": "a1697baeb399571b0336e77ae85dd77d914554e2", + "2025-07-16": "8dc09ff10e2adfe2dec74bf7b23998c3abe8459c", + "2025-07-17": "f5fc9a94a3af24d07cdce61d0f93e56c822344e2", + "2025-07-18": "715796dd9cad1760a5a0b0aac9aaddfcaa8db63c", + "2025-07-19": "6aae63e9c7e4011954c6730851151dc79a3e14b8", + "2025-07-20": "2b3c4b314b132a3e72470ff4359937ced85be989", + "2025-07-21": "46ebd3b9d264a6b31cb416944285d0b097c8da5c", + "2025-07-22": "5e2df4c5150d77c2f5bf911d1ccdbfcb20d45073", + "2025-07-23": "5c65640dc13b47c8ac83bdea877a94066eed44b0", + "2025-07-24": "6506bb565270aa3f730ba429580e246a9dd267a5", + "2025-07-25": "f905e4b4f3fdc5d27d90c6d8871408b81b104990", + "2025-07-26": "6cf02de09fe5f3f8c98133720dfaa6a7f3657a2d", + "2025-07-27": "408f5cc3e379e4555e65356b8b079f668f32687d", + "2025-07-28": "1c405f2f075b004a2e5e5f0c37e66afd8accd8ad", + "2025-07-29": "240b3c0e8bff6f1be337d1a0f67218ed2eb7cbe7", + "2025-07-30": "06fc0ae474692dab80caf538e9fe77f533cf3c76", + "2025-07-31": "25a874bcb0fef801c03dea4f6388f3f307afbaf4", + "2025-08-01": "a75e918e0e38bf76d5ef584b40287fb1f1a1b641", + "2025-08-02": "6cc38572456557ac3832d1791ad819a8c8781efa", + "2025-08-03": "350fd25253ad9246924b8afd5cc77501c24ac385", + "2025-08-04": "3ab4b16e53952ecaa759672de240f12d3e2c8f6f", + "2025-08-05": "40eac87ece5d93062c3e6ded0f5e708faf36b4ff", + "2025-08-06": "4065a521f459a9d66d3c5f0203ae8b795a42556f", + "2025-08-07": "81e3d4e070d5839a3890cc2e51148664ad64f435", + "2025-08-08": "408c09a27169cef2228ff53a45538066e9b7b754", + "2025-08-09": "dca46495a556131700778f83b9a575f02a37120b", + "2025-08-10": "c5a4470afadff18782fd278c079f90a3433e9509", + "2025-08-11": "283886f4a1684f17606d57b20556b8cada39273d", + "2025-08-12": "77cf871de8b5a17ff6af5b674d4dec6ed988ed97", + "2025-08-13": "d8466114691a0f32e3f8185951f4895c62791c6b", + "2025-08-14": "08fcda9a7855e76c2ce565bd354372138ea2114c", + "2025-08-15": "8856a0ebe187dbbf20ac002f3f3fb6b920bf0efe", + "2025-08-16": "7e60844833daed0eacbad8cbff2847fef2e3b94d", + "2025-08-17": "6316ff10cb19d0dde9368be27e33f450b9b7050e", + "2025-08-18": "a232891eb48a2c5a577ee0ffc083de256c005289", + "2025-08-19": "a8f9dcf0108236faa4ca21c746e8c692004f6efa", + "2025-08-20": "e45a4965fb727779e8a257d49fe95d6e2632d7f8", + "2025-08-21": "4e573fe18525553d9e5ec3c0d31f71a76cf898e4", + "2025-08-22": "b89b0ae7f45d40e5352086b837ea025351b848ac", + "2025-08-23": "fd174c189dd1d16397260ab888c1e16017debb17", + "2025-08-24": "523edaee4f1c840450da29ca0313748f67c2e762", + "2025-08-25": "b6d5c79871ba2b1fcc7ed7d8a334dfdf8551a1bc", + "2025-08-26": "9e047312fab864da6d7702545a18508deec50d23", + "2025-08-27": "50d48a56a980ad3ecf3ad0aba43bc8e9c2520ba6", + "2025-08-28": "f5eb4b6b806af6399a2f6d191fd17a698698ba7f", + "2025-08-29": "1e37df983aba731b683e3a14e0a2c68a15b236cc", + "2025-08-30": "8d253770e3ef76f0d032961c89c49966174a0b0e", + "2025-08-31": "cb66249ddfd55226756cf3c80ee136ab5b7d539f", + "2025-09-01": "7aec61a157c33f3bc0907b161b20516232925186", + "2025-09-02": "9c8a9ba6e25e909d082332bee482c01f8df59655", + "2025-09-03": "1b9a11c354d9f4cd4fd70e142ab94430660a0753", + "2025-09-04": "e0bf6743c74318a6fc43e511ec3e7b6567e8b96c", + "2025-09-05": "7222d118ad001964b7ff2649970e5849747d4652", + "2025-09-06": "07e1139796ae5108316733a54bf9f39c9cec4d42", + "2025-09-07": "89d82d91aa7f47fc33d86a96d3377145c30408d7", + "2025-09-08": "8d97d5b8ea6cbd993bd15e593c3d46d98f53d9db", + "2025-09-09": "474f654d6856bf0cb86dbcf740d18e0e637aa30e", + "2025-09-10": "6312d00b706a74440e4222ebdb64690676e46c44", + "2025-09-11": "37a12a40ceec88b74d60c68b95feeedcb1975e7a", + "2025-09-12": "45347f2ef534d34afb8f4374eee9e614aafe1f16", + "2025-09-13": "8e2ab659dd7060a3f0a83c2732829b810f1f150f", + "2025-09-14": "f4a316c01d37707feba1e4fa72f56556b86a2009", + "2025-09-15": "a57d874ae88368e48982c5dc78482b0e0b7aff09", + "2025-09-16": "7d0db2d40303eb5c4d4aa1c5eb291e837bf420b9", + "2025-09-17": "94dc0110faab5a359badb5afbd64338932c9537a", + "2025-09-18": "af894607758e56a7c943833dc487df3a8ca5a356", + "2025-09-19": "00db8e985d427522dc0e3363fd8d844c5eb080a7", + "2025-09-20": "b3371238542f022cdeda17002f9275b87254a09b", + "2025-09-21": "e81eb3dd61f8122b6a7446ce13571b5203ce01c6", + "2025-09-22": "ca6ec973f278000cd777f77fce0b0a4e6995319e", + "2025-09-23": "eccaa635497210c65ffbdcba2586546118d200d0", + "2025-09-24": "b5c571475164b1af29aac34be36f7260dab49c25", + "2025-09-25": "749e16c4fb081287392d4a03b5d4a79af7a911dc", + "2025-09-26": "6c648ceea7ae35c8aaad6fc46084373b4f20ab0e", + "2025-09-27": "f5d495cb45443e4b1ce1cf2f3daa00deca18b5ce", + "2025-09-28": "1947f99bd98dfd870f2cbf2dc60cafc03458292a", + "2025-09-29": "8041c50d78f82ac417466898a139b7198d099bd0", + "2025-09-30": "feff8810dff177603218358dfdc127197c30be7a", + "2025-10-01": "cc531ef4142e669f831c69f1e19740650eb6425c", + "2025-10-02": "33c0a91522fab0587e87034adfa0ff9a5cab6952", + "2025-10-03": "adeb6c46fcd456f5e4cd8d49f8f244c75f157f7e", + "2025-10-04": "58e619f65c4a7b9e839c581c3ae1679bdbe8936e", + "2025-10-05": "5c252782614b133ceb46ed48c4a57c2c6ebccb5f", + "2025-10-06": "0a438ff608480a14e0af0695145c73195059cdcc", + "2025-10-07": "5750fcc375ba91257ee500ac2815d700f20d51be", + "2025-10-08": "2f7cb17eac27a698f74247e62a39027c3a864c0c", + "2025-10-09": "66ee504b50d07f224414ae088a0221d8791aa377", + "2025-10-10": "f3e59372db9e10816a2fbfe5dc360232b07e9853", + "2025-10-11": "5f2c5b896e8ebdffcf002531862f40a0b1b08878", + "2025-10-12": "517caaf739b318712ebc8f790ad24f25ac02975e", + "2025-10-13": "f6753a5c557bb3206d047d78216f5c701fc33368", + "2025-10-14": "543ccc99d2ecef7cfc86698d14959cd3f8da23f2", + "2025-10-15": "efff823ecdc869e6ea5682b9845de3c68c9cfe2f", + "2025-10-16": "592dd0d35ce70c4040095f8b0fe5f13bd6c3fce0", + "2025-10-17": "d42a99714eeea8ca28a0949324135b4e8b5d5136", + "2025-10-18": "637d5f338b56504fd729f9cd8254bb74dd244f06", + "2025-10-19": "299994ee33f11e90a2e4613e7c61d0a406d9ac4c", + "2025-10-20": "e56f723a8b453a46f8c61fb8dab648537200a017", + "2025-10-21": "e9fc88961575686645a0b3a5f0bbe4b9639511fd", + "2025-10-22": "103d21278c37888e9539df17c24aec6b969e4e68", + "2025-10-23": "f5febc0b62271a807227dc1a53f6d71ff9dbbc25", + "2025-10-24": "602fc96aa9922fcc0c8e649880c187e7ca8b5121", + "2025-10-25": "c81e5ecf132f4ae60d612897aad66bf54ee7addb", + "2025-10-26": "ed7074f451c6c70962b5673d46462e0108ce57e5", + "2025-10-27": "57c762477d5c5b1a896f08b0c883bffc26f30c56", + "2025-10-28": "c13ad7fd132995ba69bdc9103765e12c3666f54e", + "2025-10-29": "78e69633b0a78757b9365832f814a587f7dc0470", + "2025-10-30": "df58189e83470899d4795cc3e8e4b7f55b066224", + "2025-10-31": "53df7bf0f9f44029731aae5a03551fca1f539274", + "2025-11-01": "2fae29421b89d21796856b4694c36be095263d62", + "2025-11-02": "9db954b6e407ee541b304144bbd4fca2366a36cd", + "2025-11-03": "77d2d3aa8b3dea5cbe5fef8ad65f35ca2082ba73", + "2025-11-04": "7eebff29f01db641d4e6d7bb757f93fccf881161", + "2025-11-05": "5bf41d48830f51e6de971fd0dffb7c40b729923f", + "2025-11-06": "45b0a8ee20b711f7556af78ef9ca4e738659f9a0", + "2025-11-07": "d66b4999f11495984df8f46c6c5b530ff4a93a28", + "2025-11-08": "f39718ec55ad21431104a910427bbcb91bcadd71", + "2025-11-09": "83b68c3c29d60b761ca977a234c0a3c818d03263", + "2025-11-10": "b37b5950aa970ed7abe27144f2ca62e6029215e5", + "2025-11-11": "5222b9b3cece5812ff9add134db393bb36dd49b1", + "2025-11-12": "919e966e5cb990f178a62f8f96482226b26f3d60", + "2025-11-13": "12094bd9d905962a163754ab60d77fd60d87b906", + "2025-11-14": "5dc86ad37cb42056759b3f6536a82db16981547d", + "2025-11-15": "31a8a1bee22146868624bd1274836e86548e39a1", + "2025-11-16": "88d033cbc863e02516eadd3899552840fddd6b65", + "2025-11-17": "aa78f731cdd5fb5d91b45d75bf5d7bb4632f1176", + "2025-11-18": "6030b134b69457e53a1b8d7d24383d4200e168c6", + "2025-11-19": "d2a51801cbf90d367a1730adb9768fe1e7e0ef57", + "2025-11-20": "fd0bda839201bd818af9da155e996f9e66bf56f4", + "2025-11-21": "d8ea3a1796dbeb81103509215ee8386170bd0ed4", + "2025-11-22": "f349486d4aa137dcb113bbb3a87c14e7a49d372a", + "2025-11-23": "dba4f292d1fbed1987496f5699858c40f87b23ba", + "2025-11-24": "cea48d4d2606c3c6de205bf7d28d1db4f27e2473", + "2025-11-25": "66fdcdfde3a19384ce64d4dadf9cf736c3843ee1", + "2025-11-26": "559d95d878f72fc134587d7748a041f5600b95dc", + "2025-11-27": "3f6e5f579c416c19b937f722b9b17845490c8644", + "2025-11-28": "b327cd31b01155607bc1ef47fc866f488edba963", + "2025-11-29": "984c8d215a1212343b19283a35d222af9ee9d504", + "2025-11-30": "fb05a54f36e2b959af5164611f79c23cbc704acf", + "2025-12-01": "d54a7a875e10a5e2a9b9a4dcfd72a3554f3b93e2", + "2025-12-02": "d933e20fb637ea230cac4ed311ee418d2fe4ecf1", + "2025-12-03": "a8907b31efa48be65aff6291da96abdd37afc9a1", + "2025-12-04": "e7656a07d166f06c0452894cfff7336f6d1d7c1c", + "2025-12-05": "1f27989920ede5ce526d4f7a5d0b791a67b67907", + "2025-12-06": "e09cbe8895833616307f7b4c35c6661e10ab8f2d", + "2025-12-07": "0583b5b9adad75887e228a9fca14e59a3d924479", + "2025-12-08": "1cac54332c640987bfbeeb786e09e763820c18d8", + "2025-12-09": "050585da95f48b0e2c6ecf11bfeca7d43b6bdcbf", + "2025-12-10": "29198f857a3025a0bb40b71fe7ff2c5dbd0abdc1", + "2025-12-11": "6cc4e89d3fd1a484c6b77d4193c6fc3572097488", + "2025-12-13": "d76fdddde9eaf6c08c0fee4886312daa05033ccb", + "2025-12-14": "71b23a3342ac2bda17b024e05a5919f2bf913827", + "2025-12-15": "bdfc55ed37f7d1228a856be0f57876596b04477a", + "2025-12-16": "15e31916be967c884a0570781333c36b9b555490", + "2025-12-17": "991219adfbc30967aecbcfd5385c485287dd1aee", + "2025-12-18": "57cc732c800bbe02b8d37493f7b8b042486fa5f3", + "2025-12-19": "167a811e98c49fb38d03fd1e0b90c2b6f6aceeb9", + "2025-12-20": "f13e540beb3145a6d9cf5b068cf564fe6907e3a7", + "2025-12-21": "a98ffa694948c07910033323493aa117693eb53a", + "2025-12-22": "1e98a9162ff29e163b874d7b17351e3212443f8c", + "2025-12-23": "7e3195ae6bee329bd39091c80d54b16be4c19b2b", + "2025-12-24": "d618dbbc856fc320fdaae3ce9953b9f9857009a3", + "2025-12-25": "f70e31b2b968769bb839c01a697d9d4d671fb152", + "2025-12-26": "1cfe3b8664898ce0f531369ee11f89de47c8e80d", + "2025-12-27": "e29df945907785daec08a157656e38aa8bfea0d1", + "2025-12-28": "8b3fe13952a2c6ca34525732ae3b5bba8e0c0ce2", + "2025-12-29": "a96500e60c91f34666e297e76c223296d3f543b2", + "2025-12-30": "e984296baee4f42e2857f0588e3386e08e3beb84", + "2025-12-31": "ac90543a6d2f67a6b5f1cf5ecbd60859b0d185b9", + "2026-01-01": "90e7435f3684709eaab351e375ed5a2d311f2765", + "2026-01-02": "df1ab8b75d52314f337c2b2bc894bced0dbadff7", + "2026-01-03": "db1f039dbe79a734a68b240a7f828501f7f798a6", + "2026-01-04": "41186dc39dd7927baea5336ba7fffc818761386a", + "2026-01-05": "74bdafa8755c589a377669dd66b28b457e8b77ed", + "2026-01-06": "14562b0db4b37fac0d3b28d653fad5cf39ab454b", + "2026-01-07": "f46888438d868f474238aef8806c60f685e3f780", + "2026-01-08": "0b9b100324dfc4078b5bce49effb2c76e97c5bea", + "2026-01-09": "a74e3dafbcddd43d419267511cbdf3b918ef7b38", + "2026-01-10": "c62ae74503ff2a6c8d1c088e32f0edfa628c6236", + "2026-01-11": "65837719ee7530c9bf53aa1dabdf3438e16a03b0", + "2026-01-12": "1fc8f31c4620e8291dfd10fdb0ed89c207f87860", + "2026-01-13": "393aa1a91c7396249c9a750706da3a73bdc55dd0", + "2026-01-14": "858b128a3271165855d7211e878e6e48be886dfa", + "2026-01-15": "a40c71f2cdd4124f4d619ec09edfd71c734e783b", + "2026-01-16": "523ed4f67bd9ace62daea1ce7c45bed6fd09c7bd", + "2026-01-17": "fcfcc89c0e76d49624cc2d89f19d95d77c81b1d4", + "2026-01-18": "575569318261dfed4051e0046ce32de18e0bfc6e", + "2026-01-19": "927be4d4e6f643164305d6d30cf09d2f1e7d16ac", + "2026-01-20": "a35f1fa483e42ae10f212124901355e75f7c4a0c", + "2026-01-21": "f280c38eb37e07ddda18680d0c1cdff97de8df72", + "2026-01-22": "45f9775f8d416ea7ab3582e1e737c172c6a2f7a5", + "2026-01-23": "7ae4e40be933a00f6e46d93264a4b34d9edd7079", + "2026-01-24": "616e2e0fa8ec18def49d30acd94493550ddd4f36", + "2026-01-25": "9e8e5dccc512853bd42902959864ad446856ad9f", + "2026-01-26": "4cf98f12168c1974a040ea34756f8dec5cfc793e", + "2026-01-27": "908a42479e15b87661c159bf11cdd92d2d64bca4", + "2026-01-28": "fb98e286fbe4992858216e1de335f3abe4ca5337", + "2026-01-29": "2d7276c2b584aae7664a2d5d9d04c3cd2095607b", + "2026-01-30": "2a0dac9615227a0fe95434b8cecd44c09cdc0af7", + "2026-01-31": "b2cff8fe2d67f8e83f6594f15a5a362a29517353", + "2026-02-01": "d2a98c6368e16ab0999d36a3c96bf42001d200dc", + "2026-02-02": "0d3fe7afce51d2126fdccf0b717d8048b90e4781", + "2026-02-03": "897ec533e7ee144fdcd67ec02a52dc0c6af8afe4", + "2026-02-04": "45a171cbfbcab508ef8cd756eb98a528861e160b", + "2026-02-05": "b57cddcc2e8b65b124e38b743d4d358ac748a959", + "2026-02-06": "e3788efdabdbd215cb80b1797f819c0fe74a9e51", + "2026-02-07": "94e4a562e48bbb7a7ab95fc248a91c85d3742c00", + "2026-02-08": "f1117ede3aa76477e14f3397a16d10ca158b0762", + "2026-02-09": "bb780cc307273f90b0e1c0f406e1857b50c4dd72", + "2026-02-10": "a9aed2a032780438b9137cce312eb00951fbf4ce", + "2026-02-11": "c58b2746d7635d3d08bdae5154feb6b8ccffd8a0", + "2026-02-12": "c302b00ef2220d3fd6940ff41a84a6cea9f33175", + "2026-02-13": "13c02fc6503e0681bfabd7ec9f9cfc56f58a2f5c", + "2026-02-14": "0c7ac5aeffdce1c40192bdacb5c3671c049d6205", + "2026-02-15": "49e85915dfb92cd1fbef13bd8e9244af2f42492d", + "2026-02-16": "b56df64c453105d8e4ebaa0cefd7acd357685fb0", + "2026-02-17": "25061203a5762040b35f4187cffec87aa271f122", + "2026-02-18": "1c5947d544a660e811332aa45feb2138973f4a4e", + "2026-02-19": "37b49f9f0f41f7dd634782c44fa3f170bc5f6d19", + "2026-02-20": "d388f1cc13cadd37396ed379a6b15025a82868b1", + "2026-02-21": "5d48e3e2f194b2457f167f066a04d5f7d68396fa", + "2026-02-22": "189cbd2af2a8e4af6ec2ba7045a919a04e4c1666", + "2026-02-23": "75a1cd8c0ad6a48dd3f582f29079277067fc6bc4", + "2026-02-24": "580ff8b0feab4945d227f0bfa22b60373d6e79dd", + "2026-02-25": "73c2af1f4cdd6864b34b5c15d0f7651741aad9bc", + "2026-02-26": "c0e98d83114bafaa1258a384a4610885a829960a", + "2026-02-27": "6737e965da4ac2de4448896e75b5accfa3fd6ffd", + "2026-02-28": "c468eb7ee0c418f840db367df4c7f63661d89f66", + "2026-03-01": "501201aa8a1187d4589c95d7b733f0fcf6b1de6e", + "2026-03-02": "435f0b18b8ad2f7b303fda1c275383d548290a1a", + "2026-03-03": "adc83d930378c8b2f2148c26174d8e6b9919fe87", + "2026-03-04": "6213cd0308302f0b63c92e91cfc011af582999cc", + "2026-03-05": "318c351440917c104fca78bba92efb477a062ca8", + "2026-03-06": "528050327f07bd3b82c0b0a946009699b24a42d1", + "2026-03-07": "a71d50e2ac277ea06bb457c5e036877dc87d8548", + "2026-03-08": "d921bfb813a973af33bd77260fbb18d5f4d8770c", + "2026-03-09": "9287d92b1c55954bc451e4c4932635ea67f57099", + "2026-03-10": "834585d16818d7e6f1f0e86907b3a48637e15778", + "2026-03-11": "19801a2d602f870f9f4d7e54a4cfb4d3036e8d94", + "2026-03-12": "2d81d5ee029dab05497c5c249dbcd5ec1fdfdd10", + "2026-03-13": "7a6c8b79cafa1d53424ca54e93479797529c200c", + "2026-03-14": "a637f335c51bdda8758c46b7ae62c6d93d31fc5f", + "2026-03-15": "059f4e29a20263dbec0e6d256fa11acf9b786867", + "2026-03-16": "8b1d0797e62367c23b687572c02c5b5224a05651", + "2026-03-17": "2ff0e92c624f14c0d68bfb4482c3f23473c52de1", + "2026-03-18": "876fbc7dd7fb21c71026e820640648382ce887eb", + "2026-03-19": "14db294ca36c8fd2d19718ccb2b2e06ce57a8a8e", + "2026-03-20": "b6ef15429a6d9873d211919751047b3214243b44", + "2026-03-21": "46900ca7e3471f85b8ef297dc81ea8252b2c0d30", + "2026-03-22": "b4c40bc453da981b69d2604e992046d8183e209e", + "2026-03-23": "84e9247b3aa3729057090c8148e3759d9da7b9ee", + "2026-03-24": "d617b2fd6d8f71061e88007d546c4a578f0fc0f9", + "2026-03-25": "8fc4bba2737236bda073fe9cf242693d8099a425", + "2026-03-26": "e295f3110d7d453c1caa5414aabf0115e20da1d2", + "2026-03-27": "a2670573510935c4b17b49b13544cab66298f3d2", + "2026-03-28": "bf7b81d3a5e3cfe5f207dd30895cca6ea7a0f0cb", + "2026-03-29": "cf7ce30a4f9dd9e6002d8ba1ef4f58d18a6bbeba", + "2026-03-30": "d65bf3d951d474c2759ac31a1c0796e5af565f7c", + "2026-03-31": "18dc48f5b9272bb97165fbe09644b4b0cce1cae2", + "2026-04-01": "a56ae01bbee90ef177e08a78eb410ced714d1a27", + "2026-04-02": "a6762bd47f82b0e37c84cc1f3cca69d37019b15c", + "2026-04-03": "b8d3f39e7ee809289b401df04eb02ed42f8af1ba", + "2026-04-04": "e39b0ffcf35c3c1ed6006d400bad78353822a5b7", + "2026-04-05": "e38f94dbb95e209acdc50897ba99db8e9a87ef29", + "2026-04-06": "577e9cf0442e030ac9f891cb3e56af9a208c8e20", + "2026-04-07": "16897c98bb4bf0d299844f8df0915e0970908fca", + "2026-04-08": "afe5ac83d0f7dc46d01e9d71526b7a00e555ff45", + "2026-04-09": "9f360fe75af4e62a57efd435a7e6ea65424a29d1", + "2026-04-10": "ec982202a586a33aa95617ca2682c1ad54b4d974", + "2026-04-11": "ec2b6ec5f3aab5b7c56008542e720e112545d4ef", + "2026-04-12": "da69d19162bb66b4fe89132b87415c9318fdee0c", + "2026-04-13": "bc80420e1659634defd6773e07abc5af1f8daf9f", + "2026-04-14": "8e4a065f1fa8a031c59a10856ad47df82d7db5b2", + "2026-04-15": "a66bd8f34d456f107474fc4cbd8435145a067fb2", + "2026-04-22": "db90c554b7172188324b84deea35ed5975e20e44", + "2026-04-23": "41b0718ce2da05b54beff75b403fac65442bacd2", + "2026-04-24": "75fe78b6b583875264c8a328c5e3b5b0ab2f6823", + "2026-04-25": "8e5aaa3a5c5bf6b6fb1e2314bc7690071025dc4a", + "2026-04-26": "5d8ea32fd99a765421d805460642a1ba42e0abee", + "2026-04-27": "e039d2edf51bffc256dcb07f511e0cd44f925794", + "2026-04-28": "053d2f5a5590cdedae6f41a0a17c7b378e36642f", + "2026-04-29": "f26886796c62c7c11dd98d0cf8ed4179a56b8297", + "2026-04-30": "c13dcec8918c60dda3175936b6778ad2baa37d6e", + "2026-05-01": "ed938f0bb99d1f0b7afca240907e60858026d3ed", + "2026-05-02": "84ec57e2a479800c2229494dda31e3ef2a22e0cf", + "2026-05-03": "c77d89ab7a427ad55ae12a297de3df461d7d4d6a", + "2026-05-04": "00dfcf80e580d1250013b8ab7655d66363af2128", + "2026-05-05": "88b129961686e0c8324b6720c1270083675fd498", + "2026-05-06": "a7a4f06c569dcfe9be37c34e6371b80588bd72a2", + "2026-05-07": "2099b47fbd9be6960bae05e5634a76e30f1ee496", + "2026-05-08": "1407df0ee7bd30ee5c53dbd31e046af36d5e4da7", + "2026-05-09": "cde5abf8a0d43918ad82a8116e26daff9c818326", + "2026-05-10": "dc88cff597e530ee4eded9310512a5edaa08060e", + "2026-05-11": "d184c75f3a843e5f9965c298ffd4f6b0f8b8a620", + "2026-05-12": "62705e89539037ee3b6f4a42fcf24ee3772033b0", + "2026-05-13": "d49c784bd0339ff40ad7cc2b03ff8541874c598e", + "2026-05-14": "1c4604401adfcf20943532977b93f29aadf37aad", + "2026-05-15": "2c6c8b63a64713ad144163da2fb48259e6d00002", + "2026-05-16": "bce35a5a2c3a820adb55f64758cabc1edfdca657", + "2026-05-17": "38e5e7d9dfdab8c07d94a99c781ce6c1e704c932", + "2026-05-18": "f69fab53bdff2bdde0bd1f04a71954a215dd6e45", + "2026-05-19": "0d109ba1431f60f42ebe2c394ce35e53060d9de3", + "2026-05-20": "8ae9498f48b88bc60a6f9d43bff2e73d16faf262", + "2026-05-21": "d476c9484b452fac24d61a74092a1952c58dc782", + "2026-05-22": "bfe748385c26995a7c53783d10ba961089a9d253", + "2026-05-23": "0545e0ba984c6da2deec5045d27d3df2065a0d97", + "2026-05-24": "6a2bb857b0f33ada3d74e3e9029e17cbf8618a29", + "2026-05-25": "7869550ec41e7484deb68db2bab17e6139715f3e", + "2026-05-26": "31d5018c0f68984414241b52130b5c2c0555cdd7", + "2026-05-27": "256f7ec3af0389c435fb0bde1821b142673e7197", + "2026-05-28": "527d95529237fb30bbf49b639da9ac712a98c3e2", + "2026-05-29": "0b19099fc6b5540eb58d3e521bd75fed9bed0dc4", + "2026-05-30": "0a5bc1cc8b6513b10f6f6bcaa33420d4d1019924", + "2026-05-31": "6f6d05c5e1dec3e0c5fdc37d820765af4ed76b26", + "2026-06-01": "57b4973541195b177ccaed2ead36c59d399b6e2b", + "2026-06-02": "e3a21e45a9e7820e50f687eb6e9799e0b35c6590", + "2026-06-03": "a92ca28995ff8ab6ba7aa34c0fa2459ca2b94280", + "2026-06-04": "239bb232311c8d46025f9fe834607e4598677311", + "2026-06-05": "e6d7104944a20ff5f69f6452e9b1fa04a312dcd2", + "2026-06-06": "91398cb92c11454803624e7da7e224f4492643f1", + "2026-06-09": "b32caa23bf821aa58ac4edd9da52ac086b1fba7e", + "2026-06-10": "6db230008f3069bebf9fd01872ec910acad69660", + "2026-06-11": "bd73b7c7f072a13b3004074cc5940d6ee81168ff", + "2026-06-12": "362730c421a31ccf4881044c28bcd3a60f58b747", + "2026-06-13": "bed04c4e0d0253916b7b2f817df13b036c6c7267", + "2026-06-14": "71b1e3bbc250ac6cd1981a4be8410dafc9a8c056", + "2026-06-15": "3b6e838dddf8cc30d124aa7f633ef3972dffd2f6", + "2026-06-16": "02c0b7e13b2bcb5002a46c5056de9a95f1038156", + "2026-06-17": "f3bbbbb335ac3b4d4f47fdf01b8aa296a354890e", + "2026-06-18": "d8aced85be8f3fc4f1021d1cbc070f4ad8fd334e", + "2026-06-19": "53f61665141494d6ffe3a85840fb0819527b3b5c", + "2026-06-20": "22e490c1d0f1f4751b4fbdc4b41300a310c18c52", + "2026-06-21": "6fcccb3c4d8c7cd82859bf1c8021949897489010", + "2026-06-22": "f67ebce4fe712df62704669e1246de139e761824", + "2026-06-23": "15812ee6146e1486d4f47cfeee6b81e38d7f6c30", + "2026-06-24": "6b74a74ddaa3e2feebb1db82589ab96dd72dbc4b", + "2026-06-25": "d449d4e08f8efaa71d826b0389231921db8c4965", + "2026-06-26": "c3320698bcbef22f7f0efd2b1519631d46b819dd", + "2026-06-27": "1515a433764ee761a9b996b2f00f3ec608574db8", + "2026-06-28": "1bf4cf43e4e784b85273c6e59c74d33010e8e75d", + "2026-06-29": "b7d84ff8f34128212dfc6d12950b0d9f9126d5ff", + "2026-06-30": "6ad395b0875b44a12e77bbd2f2e4ace629f0d64a", + "2026-07-01": "5f1b61cc88f324887b56a356524090cdb5e642cb", + "2026-07-02": "1000ef52a09426af61ef7d660be96bcc35fd7dd0", + "2026-07-03": "88bafc1811cc5f25f38867565829f0a4a2a9dea8", + "2026-07-04": "be986b767dcf57c8a72fd68c238cbf054c3b7891", + "2026-07-05": "e69252591b9c420b57ff26e09888ad544c9219d6", + "2026-07-06": "4d1acb1406d06151cfe7423e0ebed77d07437712", + "2026-07-07": "933ec1605536aab76ab7529790f3cbcb4371482d", + "2026-07-08": "66a3edbcea94df3940e734468b8e9ce10c6f59ac", + "2026-07-09": "2da3bbb863b9e6a2ab341407ad4235bc6b8351bf", + "2026-07-10": "cd036274bf8eb86707a3f8433124c7607d1233c6", + "2026-07-11": "d0143905974223d191a7cc85f7d0f0f8467f88bc", + "2026-07-12": "f0ac6e0e0a4e2838c56133b7b1a60eb02c62f792", + "2026-07-13": "14fe283af4448ac9783f217a7de3984a9a07c63e", + "2026-07-14": "a4cc38833c6aa93ce55d881aea24b1e8f89653c9", + "2026-07-15": "477fbedbc91d989e5320b2802e6a7f3fbf3f6d54", + "2026-07-16": "6c5d0432f5d050243c8ab8b0978d77e07e5cd492", + "2026-07-27": "d74819ccdfbfb904e8b739f0e30bffdfe45269f0", + "2026-07-28": "0cf681ff6fb74f3c009c73716e61b0f1a719af03", + "2026-07-29": "6b4a041405c496d44972fcb282edb6b52a6ea0f0", + "2026-07-30": "03fde9be24bec86c2f1dcf756a2b0ffdebd2ab7c", + "2026-07-31": "2e7fc2068d05ab68e6866d9e832f6c324500554f", + "2026-08-01": "b2898ef53c5039499d6544ce11660529e00116fd", + "2026-08-02": "3522b6c1bbf27a263c2568a2388a49611e962e7e", + "2026-08-03": "c91cb4fb688a8e59ec69085527f20ca5c8583ea0", + "2026-08-04": "5af6b7d54e499d84cc0d71444daa46efceb8bd22", + "2026-08-05": "8992310bd33876ef1ca7dabe97b6402632b38e28", + "2026-08-07": "fa31c22e36072dcc6349d5914c750471d287e675", + "2026-08-08": "8fe2f9eb19577dcefdfb0cc26bb60b8ed54acaf5", + "2026-08-09": "082489e9cabe734b40eb98f36a4666e7cc8cffac", + "2026-08-10": "497750a92c29621a063e2304cf65ed094396cd0a", + "2026-08-11": "108ae0c511ff80265d29e55c99a29484534e8728", + "2026-08-12": "0449f9456aafc909c986441e103d1c7397349cd1", + "2026-08-13": "6d1d91fccc7c22b478947186dce6a54089b2777e", + "2026-08-14": "dcf7c582956b037fee4a7b4e90e03f02655c0413", + "2026-08-15": "e33ed299e61c2db1af75e30f4e1d717d76486ad2", + "2026-08-16": "5a726be4c3410ed149b28258e179bf89ee6adaaa", + "2026-08-17": "2a400ddaee18857f35cb061573033d943f77e12f", + "2026-08-18": "2bd956502fd821bdb381a6d330be0e5926a48ac9", + "2026-08-19": "3526d7a49db49d8721bde5288323e53310b4eebc", + "2026-08-20": "d29a1e608420b49f16ebd640657485811e57d99c", + "2026-08-21": "3fe1065c4f942bbf60f3e6ac9930834ebe056650", + "2026-08-22": "c78b97c4656a830ae4d49fc1d435223f47bfc239", + "2026-08-23": "62ac55629c7cebc6123b2746978f86565e74c360", + "2026-08-24": "ece897712d1575133395002d37f4a758409bc452", + "2026-08-25": "8d0db5809623c9801b5cc126aaa49ab163dc712c", + "2026-08-26": "2fee6a2add8909e01538f306edf1a264930f1470", + "2026-08-27": "a76b96c618d0dfd4471645b0f63cc0b2c27d3c7b", + "2026-08-28": "e23648a0219356d8fa9db2e6cc4b267e91aa0055", + "2026-08-29": "cb6bf41cbfa13ecfbec4c6791776bbc0ab31b4df", + "2026-08-30": "86bdf8b137e0b532755450cf484fa48fc3d807b4", + "2026-08-31": "50ca267d574237d942c130eaadd1ac3380f936ad", + "2026-09-01": "703d97f5d456295afa8105793e97c498bee6d0d1", + "2026-09-02": "dffec5f2f6526d1cf3597aa5d9667e61e1e86d6d", + "2026-09-03": "7d2b9bc1e1cef997489de4c8a14964ff96f47ce4", + "2026-09-04": "50ee41c3cb7d69c1ad064d95753e0eef1cf1d8cd", + "2026-09-05": "4e108cfac1b69d00916d78de0b694ce374f35516", + "2026-09-06": "71b17d357c564270dffd07eaca9d9d6bb20c1789", + "2026-09-07": "b5ac94066cb3ec552ea5f3f5e1c6870dce0ea46b", + "2026-09-08": "7f6b72f145ba488112d15a93c109cb3754adc86c", + "2026-09-09": "ac7d12fa9cdb3339ee058f77fcd77fce58658bdd", + "2026-09-10": "f5c14ab2e63e72a11744fc0a58ccd40820c96c2f", + "2026-09-11": "498e71d7aebb1208203bed0440501c4b03d2153e", + "2026-09-12": "4867209f283f3ac84367e363200c298108d6cee1", + "2026-09-13": "de5dbf04bf1c57568aa82cdad0b2428c5c1f7c55", + "2026-09-14": "1c27572586ec2f1b5d61a5b6aa9b6464186ce3d7", + "2026-09-15": "fe9834ea1cfa18496d55388fde3b6e33f70723bb", + "2026-09-16": "2ef961426d1fe7a8a0814121d60f0f0d54420d4e", + "2026-09-17": "8bd209d1da2fbe97f87294fa648ea1378dac325f", + "2026-09-18": "8045471215d77f62fc7796780657a0311a23a8b0", + "2026-09-19": "e97802a9d6800ccebfd4d3b99018d722a9cc715c", + "2026-09-20": "b81b20c6af9c06c21c8d7616fef8c96c3c522358", + "2026-09-21": "2745ebb1fd9e684c63187d472c4a28ec0d083974", + "2026-09-22": "aaf19c740cb1d1ff0dfd4bd290052f5aa8eafe58", + "2026-09-23": "b33a375849dd98854e7a28d604f7822aafe0aa38", + "2026-09-24": "d41aa455e6dd75b17b7878c7503cd958014d8643", + "2026-09-25": "767ea6cd2b53d2bb0de8a013fed8289b43772307", + "2026-09-26": "10e500ecdc57637cbeb4dcfb96885dfd15c3624e", + "2026-09-27": "7bcb4fe28ce521f2131753c34f7bab07f26ba3cb", + "2026-09-28": "4d174a68f6dfb7eed498e0d00c20b95c3a5289fb", + "2026-09-29": "1f04f2f07f58df7aa65c1490416f8ece6ff06ea6", + "2026-09-30": "3701aa0d3387bd9701626c7b9c6c540fca9ad4d2", + "2026-10-01": "f15434b7fb50ba504cb48338db19b21692e68a5f", + "2026-10-02": "bb5d4ba25d855322f480c85b93cd7aee2b515f61", + "2026-10-03": "1c12f31565b89513ee5e8d9cf1257987f729711a", + "2026-10-04": "893ab0d4ccc70544360d16f99e9e4d17fa4776c7", + "2026-10-05": "317840d6ef310ba04b2dead3371977b0f7cb4ce3", + "2026-10-06": "526d2daf02b1b7d1f0447ec80ab73cb07fbb2447", + "2026-10-07": "e98ef8d7beb2e3e71c1d35a9b34732986287477d", + "2026-10-08": "3c9c0fbe377d28798a2f2a1ba11578e50df9cb0b" +} diff --git a/lib/rprofile-snapshot.R b/lib/rprofile-snapshot.R new file mode 100644 index 0000000..c67f71f --- /dev/null +++ b/lib/rprofile-snapshot.R @@ -0,0 +1,14 @@ +# The CRAN snapshot date set by a project's .Rprofile (e.g. a Posit Package +# Manager `repos` URL); prints nothing if there is none. +# Usage: R_PROFILE_USER=.Rprofile Rscript rprofile-snapshot.R +repo <- getOption("repos")[["CRAN"]] +if (!is.null(repo)) { + pattern <- "^https://packagemanager[.]posit[.]co/cran/([0-9]{4}-[0-9]{2}-[0-9]{2})/?$" + if (grepl(pattern, repo)) { + date <- sub(pattern, "\\1", repo) + if (is.na(as.Date(date))) stop("nullkomma.r: invalid PPM snapshot date") + writeLines(date) + } else if (grepl("[0-9]{4}-[0-9]{2}-[0-9]{2}", repo)) { + stop("nullkomma.r: dated CRAN repository must use https://packagemanager.posit.co/cran/YYYY-MM-DD") + } +} diff --git a/modules/dev/docs.nix b/modules/dev/docs.nix index 5e9fa16..c8f1fa4 100644 --- a/modules/dev/docs.nix +++ b/modules/dev/docs.nix @@ -56,6 +56,9 @@ for option in \ nullkomma.github.enable \ nullkomma.r.extraPackages \ + nullkomma.r.rprofile \ + perSystem.nullkomma.r.snapshot \ + perSystem.nullkomma.r.buildInputs \ nullkomma.quarto.root \ nullkomma.cloudflare-pages.project \ perSystem.nullkomma.tasks \ diff --git a/modules/dev/tests.nix b/modules/dev/tests.nix index d1d09b3..ea23e31 100644 --- a/modules/dev/tests.nix +++ b/modules/dev/tests.nix @@ -196,8 +196,13 @@ expr = (perSystem fixtures.r).nullkomma.r.dependencies; expected = [ "testthat" + "withr" ]; }; + testRSnapshot = { + expr = (perSystem fixtures.r).nullkomma.r.snapshot; + expected = "2026-01-05"; + }; testRHasAirAndCheck = { expr = { air = (perSystem fixtures.r).treefmt.programs.air.enable; @@ -242,11 +247,28 @@ in { checks = fixtureChecks // { + r-parsers = pkgs.runCommandLocal "nullkomma-r-parsers" { nativeBuildInputs = [ pkgs.R ]; } '' + export HOME="$TMPDIR" + printf 'Package: example\nVersion: 0.1\nDepends: R (>= 4.0)\nImports: stats, withr (>= 3.0.0)\n' > DESCRIPTION + printf 'withr\t3.0.3\n' > versions + Rscript --vanilla ${../../lib/description-constraints.R} DESCRIPTION versions Depends Imports + printf 'withr\t2.0.0\n' > versions + if Rscript --vanilla ${../../lib/description-constraints.R} DESCRIPTION versions Imports > error 2>&1; then exit 1; fi + grep -F 'selected version is 2.0.0' error + Rscript --vanilla ${../../lib/rprofile-snapshot.R} > date + test ! -s date + cp ${../../tests/fixtures/r/.Rprofile} .Rprofile + Rscript ${../../lib/rprofile-snapshot.R} > date + grep -Fx '2026-01-05' date + touch "$out" + ''; file-initialization = pkgs.runCommandLocal "nullkomma-file-initialization" { } '' mkdir project cd project ${fixtures.r.apps.${system}.write-files.program} test -f .github/workflows/push.yml + test -f .Rprofile + printf '# My profile\n' > .Rprofile test ! -e .github/workflows/cron.yml test -w .github/workflows/push.yml if grep -q 'Generated by nullkomma' .github/workflows/push.yml; then @@ -256,6 +278,8 @@ ${fixtures.r.apps.${system}.write-files.program} printf '# My workflow\n' > expected cmp expected .github/workflows/push.yml + printf '# My profile\n' > expected + cmp expected .Rprofile test ! -e .github/workflows/cron.yml touch $out ''; diff --git a/modules/lang/r/options.nix b/modules/lang/r/options.nix index 490c32e..126c40c 100644 --- a/modules/lang/r/options.nix +++ b/modules/lang/r/options.nix @@ -10,6 +10,7 @@ let rlib = import ../../../lib/r.nix { inherit lib; }; description = config.nullkomma.r.root + "/DESCRIPTION"; + rprofile = config.nullkomma.r.root + "/.Rprofile"; in { imports = [ inputs.self.flakeModules.default ]; @@ -27,6 +28,19 @@ defaultText = lib.literalExpression ''root + "/DESCRIPTION" (if it exists)''; description = "R DESCRIPTION file: the single source of truth for R dependencies."; }; + rprofile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = if builtins.pathExists rprofile then rprofile else null; + defaultText = lib.literalExpression ''root + "/.Rprofile" (if it exists)''; + description = '' + Project `.Rprofile`. If it sets a CRAN snapshot date, e.g. + `options(repos = c(CRAN = "https://packagemanager.posit.co/cran/2026-01-05"))`, + R and packages come from the shipped rstats-on-nix revision for that date, + not directly from PPM; equivalence of the two package sets is not guaranteed. + Plain R and tools honoring `getOption("repos")` see the PPM repository. + The profile runs during evaluation in an isolated directory: keep it self-contained. + ''; + }; fields = lib.mkOption { type = lib.types.listOf lib.types.str; default = rlib.defaultFields; diff --git a/modules/lang/r/package.nix b/modules/lang/r/package.nix index 92b8d79..f4b00d7 100644 --- a/modules/lang/r/package.nix +++ b/modules/lang/r/package.nix @@ -1,5 +1,7 @@ -# R and its packages come from `nixpkgs-r`, which follows nullkomma's nixpkgs -# unless a consumer repoints it (e.g. at an rstats-on-nix date). +# R and its packages come from nixpkgs. The project's `.Rprofile` may pin a CRAN +# snapshot date (a Posit Package Manager `repos` URL); then they come from the +# rstats-on-nix/nixpkgs revision of that date (see `lib/r-snapshots.json`). +# `Remotes:` in DESCRIPTION, pinned to a commit, are layered on top. { inputs, ... }: { flake.flakeModules.r = @@ -15,11 +17,22 @@ in { options.perSystem = flake-parts-lib.mkPerSystemOption { + options.nullkomma.r.snapshot = lib.mkOption { + type = lib.types.nullOr lib.types.str; + readOnly = true; + description = "CRAN snapshot date set by `nullkomma.r.rprofile`, as read by R itself; null if none."; + }; options.nullkomma.r.dependencies = lib.mkOption { type = lib.types.listOf lib.types.str; readOnly = true; description = "Package names `nullkomma.r.description` depends on in `nullkomma.r.fields`, as parsed by R itself (base packages excluded)."; }; + options.nullkomma.r.buildInputs = lib.mkOption { + type = lib.types.attrsOf (lib.types.listOf lib.types.package); + default = { }; + example = lib.literalExpression "{ sf = [ pkgs.gdal ]; }"; + description = "System libraries to add to the build of an R package, for packages whose needs nixpkgs does not know (typically `Remotes:`)."; + }; options.nullkomma.r.package = lib.mkOption { type = lib.types.package; readOnly = true; @@ -27,37 +40,209 @@ }; }; config.perSystem = - { system, ... }: + { system, config, ... }: let - pkgsR = inputs.nixpkgs-r.legacyPackages.${system}; - # Import from derivation: R's own DESCRIPTION parser, from the pinned R. - dependencies = lib.optionals (cfg.description != null) ( - lib.filter (n: n != "") ( - lib.splitString "\n" ( - builtins.readFile ( - pkgsR.runCommandLocal "r-description-deps" - { - nativeBuildInputs = [ pkgsR.R ]; - description = pkgsR.writeText "DESCRIPTION" (builtins.readFile cfg.description); - } - '' - export HOME="$TMPDIR" - Rscript ${../../../lib/description-deps.R} "$description" ${lib.escapeShellArgs cfg.fields} > $out - '' + pcfg = config.nullkomma.r; + boot = inputs.nixpkgs-r.legacyPackages.${system}; + + # IFD: bootstrap R reads the profile; selected R reads package metadata. + runR = + pkgs: script: files: args: + builtins.readFile ( + pkgs.runCommandLocal "r-${baseNameOf script}" + ( + { + nativeBuildInputs = [ pkgs.R ]; + } + // lib.mapAttrs (name: path: pkgs.writeText name (builtins.readFile path)) files ) + '' + export HOME="$TMPDIR" + { + ${args} + } > "$out" + '' + ); + lines = text: lib.filter (l: l != "") (lib.splitString "\n" text); + + snapshot = + if cfg.rprofile == null then + null + else + let + date = runR boot ../../../lib/rprofile-snapshot.R { rprofile = cfg.rprofile; } '' + R_PROFILE_USER="$rprofile" Rscript ${../../../lib/rprofile-snapshot.R} + ''; + in + if lines date == [ ] then null else lib.head (lines date); + snapshots = lib.importJSON ../../../lib/r-snapshots.json; + pkgsR = + if snapshot == null then + boot + else + import (builtins.fetchTree { + type = "github"; + owner = "rstats-on-nix"; + repo = "nixpkgs"; + rev = + snapshots.${snapshot} or (throw ( + "nullkomma.r: no rstats-on-nix snapshot for ${snapshot} (from ${toString cfg.rprofile}); " + + "choose a date in lib/r-snapshots.json (available range: ${lib.head (lib.attrNames snapshots)} to ${lib.last (lib.attrNames snapshots)})." + )); + }) { inherit system; }; + + describe = + src: + let + meta = lines ( + runR pkgsR ../../../lib/description-meta.R { description = "${src}/DESCRIPTION"; } '' + Rscript ${../../../lib/description-meta.R} "$description" + '' + ); + in + { + src = + if + lines ( + runR pkgsR ../../../lib/description-remotes.R { description = "${src}/DESCRIPTION"; } '' + Rscript --vanilla ${../../../lib/description-remotes.R} "$description" + '' + ) != [ ] + then + throw "nullkomma.r: transitive Remotes are not supported; declare pinned overrides in the project's DESCRIPTION." + else + src; + package = lib.elemAt meta 0; + version = lib.elemAt meta 1; + dependencies = depsOf "${src}/DESCRIPTION" [ + "Depends" + "Imports" + "LinkingTo" + ]; + }; + depsOf = + file: fields: + lines ( + runR pkgsR ../../../lib/description-deps.R { description = file; } '' + Rscript ${../../../lib/description-deps.R} "$description" ${lib.escapeShellArgs fields} + '' + ); + + dependencies = lib.optionals (cfg.description != null) (depsOf cfg.description cfg.fields); + + # Only `owner/repo@` is accepted: a commit is the one reference + # that cannot move, which is what makes the lock meaningful. + parseRemote = + entry: + let + m = builtins.match "([A-Za-z0-9.]+=)?(github::)?([^/@#=:]+)/([^/@#=:]+)@([0-9a-f]{40})" entry; + in + if m == null then + throw "nullkomma.r: Remotes entry `${entry}` must be `owner/repo@<40-character commit sha>` (GitHub, pinned to a commit)." + else + let + remote = describe ( + builtins.fetchTree { + type = "github"; + owner = lib.elemAt m 2; + repo = lib.elemAt m 3; + rev = lib.elemAt m 4; + } + ); + alias = lib.elemAt m 0; + in + if alias != null && lib.removeSuffix "=" alias != remote.package then + throw "nullkomma.r: Remotes alias `${alias}` does not match `${remote.package}` in the fetched DESCRIPTION." + else + remote; + remotes = lib.optionals (cfg.description != null) ( + map parseRemote ( + lines ( + runR pkgsR ../../../lib/description-remotes.R { description = cfg.description; } '' + Rscript ${../../../lib/description-remotes.R} "$description" + '' ) ) ); - names = lib.unique (dependencies ++ cfg.extraPackages ++ [ "languageserver" ]); + + base = pkgsR.rPackages; lookup = name: - pkgsR.rPackages.${rlib.attrName name} - or (throw "nullkomma.r: R package `${name}` is not in `rPackages` of nixpkgs-r; pin a newer nixpkgs-r or drop it from DESCRIPTION/extraPackages."); + final.${rlib.attrName name} + or (throw "nullkomma.r: R package `${name}` is not in `rPackages` of nixpkgs-r; pin a newer snapshot or drop it from DESCRIPTION/extraPackages."); + remoteOverrides = lib.listToAttrs ( + map ( + r: + let + attr = rlib.attrName r.package; + inputs' = map lookup r.dependencies; + in + lib.nameValuePair attr ( + if base ? ${attr} then + base.${attr}.overrideAttrs (old: { + pname = r.package; + name = "r-${r.package}-${r.version}"; + inherit (r) src version; + propagatedBuildInputs = + inputs' ++ lib.filter (p: !(p ? rCommand)) (old.propagatedBuildInputs or [ ]); + nativeBuildInputs = inputs' ++ lib.filter (p: !(p ? rCommand)) (old.nativeBuildInputs or [ ]); + }) + else + base.buildRPackage { + pname = r.package; + inherit (r) src version; + propagatedBuildInputs = inputs'; + nativeBuildInputs = inputs'; + } + ) + ) remotes + ); + systemLibraries = lib.mapAttrs' ( + name: libs: + let + attr = rlib.attrName name; + in + lib.nameValuePair attr ( + (remoteOverrides.${attr} or base.${attr}).overrideAttrs (old: { + buildInputs = (old.buildInputs or [ ]) ++ libs; + }) + ) + ) pcfg.buildInputs; + final = base.override { overrides = remoteOverrides // systemLibraries; }; + + names = lib.unique (dependencies ++ cfg.extraPackages ++ [ "languageserver" ]); + versionNames = lib.unique (names ++ lib.concatMap (r: r.dependencies) remotes); + versions = pkgsR.writeText "r-selected-versions" ( + lib.concatMapStrings (name: "${name}\t${lib.getVersion (lookup name)}\n") versionNames + ); + validate = + file: fields: + runR pkgsR ../../../lib/description-constraints.R { description = file; } '' + Rscript --vanilla ${../../../lib/description-constraints.R} "$description" ${versions} ${lib.escapeShellArgs fields} + ''; + validation = + lib.optionals (cfg.description != null) [ (validate cfg.description cfg.fields) ] + ++ map ( + r: + validate "${r.src}/DESCRIPTION" [ + "Depends" + "Imports" + "LinkingTo" + ] + ) remotes; + uniqueRemotes = lib.length remotes == lib.length (lib.unique (map (r: r.package) remotes)); + remoteOrder = lib.toposort (a: b: lib.elem a.package b.dependencies) remotes; in { config.nullkomma.r = { - inherit dependencies; - package = pkgsR.rWrapper.override { packages = map lookup names; }; + inherit snapshot dependencies; + package = + if !uniqueRemotes then + throw "nullkomma.r: duplicate package names in Remotes." + else if remoteOrder ? cycle then + throw "nullkomma.r: dependency cycle between Remotes." + else + builtins.deepSeq validation (pkgsR.rWrapper.override { packages = map lookup names; }); }; }; }; diff --git a/modules/lang/r/profile.nix b/modules/lang/r/profile.nix new file mode 100644 index 0000000..e8826b3 --- /dev/null +++ b/modules/lang/r/profile.nix @@ -0,0 +1,28 @@ +{ + flake.flakeModules.r = + { + config, + lib, + self, + ... + }: + let + cfg = config.nullkomma.r; + relativeRoot = lib.removePrefix "${toString self}/" (toString cfg.root); + profile = if toString cfg.root == toString self then ".Rprofile" else "${relativeRoot}/.Rprofile"; + date = lib.last (lib.attrNames (lib.importJSON ../../../lib/r-snapshots.json)); + in + { + perSystem = { + nullkomma.files.${profile}.text = '' + options(repos = c(CRAN = "https://packagemanager.posit.co/cran/${date}")) + ''; + nullkomma.devshell.shellHook = '' + if [ -z "''${R_LIBS_USER:-}" ]; then + export R_LIBS_USER="''${XDG_DATA_HOME:-$HOME/.local/share}/nullkomma/R/$(Rscript --vanilla -e 'cat(as.character(getRversion()))')" + fi + mkdir -p "$R_LIBS_USER" + ''; + }; + }; +} diff --git a/templates/r/flake.nix b/templates/r/flake.nix index 0ba6371..8187e9e 100644 --- a/templates/r/flake.nix +++ b/templates/r/flake.nix @@ -2,8 +2,7 @@ description = "An R project with batteries included by nullkomma"; inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; - # Pin R and CRAN to a date (adds one input to flake.lock): - # inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; + # CRAN date lives in .Rprofile; packages and pinned Remotes live in DESCRIPTION. outputs = inputs: diff --git a/tests/fixtures/r/.Rprofile b/tests/fixtures/r/.Rprofile new file mode 100644 index 0000000..2f4c036 --- /dev/null +++ b/tests/fixtures/r/.Rprofile @@ -0,0 +1 @@ +options(repos = c(CRAN = "https://packagemanager.posit.co/cran/2026-01-05")) diff --git a/tests/fixtures/r/DESCRIPTION b/tests/fixtures/r/DESCRIPTION index f64a794..1ade30a 100644 --- a/tests/fixtures/r/DESCRIPTION +++ b/tests/fixtures/r/DESCRIPTION @@ -11,5 +11,7 @@ Depends: Imports: stats Suggests: - testthat (>= 3.0.0) + testthat (>= 3.0.0), + withr (>= 3.0.3.9000) +Remotes: r-lib/withr@d82e4bc2d69a34f044ad205210e26207bfb8f3e0 Config/testthat/edition: 3 diff --git a/tests/fixtures/r/flake.nix b/tests/fixtures/r/flake.nix index 0ba6371..8187e9e 100644 --- a/tests/fixtures/r/flake.nix +++ b/tests/fixtures/r/flake.nix @@ -2,8 +2,7 @@ description = "An R project with batteries included by nullkomma"; inputs.nullkomma.url = "https://flakehub.com/f/dataheld/nullkomma/0.1.*"; - # Pin R and CRAN to a date (adds one input to flake.lock): - # inputs.nullkomma.inputs.nixpkgs-r.url = "github:rstats-on-nix/nixpkgs/2026-01-05"; + # CRAN date lives in .Rprofile; packages and pinned Remotes live in DESCRIPTION. outputs = inputs: diff --git a/tests/fixtures/r/tests/testthat/test-hello.R b/tests/fixtures/r/tests/testthat/test-hello.R index 41ac753..8b87009 100644 --- a/tests/fixtures/r/tests/testthat/test-hello.R +++ b/tests/fixtures/r/tests/testthat/test-hello.R @@ -1,3 +1,4 @@ test_that("hello greets", { expect_equal(hello("you"), "Hello, you!") + expect_equal(as.character(packageVersion("withr")), "3.0.3.9000") }) diff --git a/tools/update-r-snapshots.py b/tools/update-r-snapshots.py new file mode 100644 index 0000000..f2770c8 --- /dev/null +++ b/tools/update-r-snapshots.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Append dated rstats-on-nix revisions without changing existing pins.""" +import json +import pathlib +import re +import subprocess + +path = pathlib.Path(__file__).resolve().parents[1] / "lib/r-snapshots.json" +snapshots = json.loads(path.read_text()) +refs = subprocess.check_output( + ["git", "ls-remote", "--heads", "https://github.com/rstats-on-nix/nixpkgs.git"], + text=True, +) +for line in refs.splitlines(): + sha, ref = line.split() + date = ref.removeprefix("refs/heads/") + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + snapshots.setdefault(date, sha) +path.write_text(json.dumps(snapshots, indent=2, sort_keys=True) + "\n")