Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ jobs:
fail-fast: false
matrix:
lab: ${{ fromJSON(github.event_name == 'workflow_dispatch'
&& '["pratico", "publisher", "ptbr-pdf", "lint", "paper", "gleam-website", "agentic", "cuda", "present", "viz"]'
|| '["pratico", "publisher", "lint", "paper", "gleam-website", "agentic", "cuda", "present", "viz"]') }}
&& '["pratico", "publisher", "ptbr-pdf", "ptbr-tools", "lint", "paper", "gleam-website", "agentic", "cuda", "present", "viz"]'
|| '["pratico", "publisher", "ptbr-tools", "lint", "paper", "gleam-website", "agentic", "cuda", "present", "viz"]') }}
defaults:
run:
working-directory: labs/${{ matrix.lab }}
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ redirects it, and the repositories marked so still write it.
| [h0ffmann/ptbr-writer](https://github.com/h0ffmann/ptbr-writer) | `gleam-website` | flake input `github:h0ffmann/picasso?dir=labs/gleam-website` | [`flake.nix`](https://github.com/h0ffmann/ptbr-writer/blob/main/flake.nix) | `4bd3b22` |
| | `gleam-website` (the site's sources) | git submodule `forks/picasso`; `just pins` fails when it differs from the lock; ⚠ sparse not checked | [`.gitmodules`](https://github.com/h0ffmann/ptbr-writer/blob/main/.gitmodules) | `4bd3b22` |
| | `ptbr-pdf` | `nix run "github:h0ffmann/picasso/$PICASSO_REV?dir=labs/ptbr-pdf"` for the plugin's `.pdf` output; ⚠ on the open pull request h0ffmann/ptbr-writer#29 | `plugins/ptbr-writer/scripts/to-pdf.sh` | `53372f0` |
| | `ptbr-tools` | `nix build "github:h0ffmann/picasso/$PICASSO_REV?dir=labs/ptbr-tools#static"` for the plugin's hook, reading-time and router binaries; ⚠ pending ptbr-writer pull request | none yet | none yet |

Labs with no consumer found: `present` (the workstation only). `pratico`, `cuda` and `agentic` are
also used on this workstation. marola does not take `cuda` as an input today. Private repositories
Expand All @@ -72,6 +73,7 @@ Each lab README has the recipes, what the lab pins (`What it pins`) and how to r
| [`pratico`](labs/pratico) | WAVEWATCH III toolchain (gfortran, OpenMPI, NetCDF), Kokkos, zsh-ai pilot, ai-jail recipes | `just ww3`, `just smoke` |
| [`publisher`](labs/publisher) | pandoc + TeX Live; `mkPdf` and `mkDocx`; an Action that builds and commits PDFs | `just shell`, `lib.<system>.mkPdf`, Action |
| [`ptbr-pdf`](labs/ptbr-pdf) | Markdown to PDF for Portuguese: one Rust binary, `to-pdf`, Typst as a library, six fonts inside | `nix run github:h0ffmann/picasso?dir=labs/ptbr-pdf -- texto.md` |
| [`ptbr-tools`](labs/ptbr-tools) | The ptbr-writer plugin's runtime scripts (hook, reading time, router) as one static Rust binary, 0.54 MiB, std only | `nix build github:h0ffmann/picasso?dir=labs/ptbr-tools#static` |
| [`lint`](labs/lint) | hadolint, actionlint, shellcheck, ruff, pyflakes, cloc, coverage.py, pdoc as one list | `lib.<system>.tools` in your `mkShell` |
| [`paper`](labs/paper) | Markdown papers to PDF and arXiv source: pandoc-crossref, XeLaTeX, pdfLaTeX | `just shell`, `just smoke` |
| [`gleam-website`](labs/gleam-website) | Gleam (JavaScript target), node, esbuild, just: the toolchain of this repo's site | `just shell`, `just smoke` |
Expand Down
2 changes: 2 additions & 0 deletions labs/ptbr-tools/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
target/
result
7 changes: 7 additions & 0 deletions labs/ptbr-tools/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions labs/ptbr-tools/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "ptbr-tools"
version = "0.1.0"
edition = "2021"
description = "The runtime scripts of the ptbr-writer plugin, as small static binaries"
license = "AGPL-3.0-only"
publish = false

[lib]
name = "ptbr_tools"
path = "src/lib.rs"

[[bin]]
name = "ptbr"
path = "src/bin/ptbr.rs"

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = { level = "warn", priority = -1 }

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
99 changes: 99 additions & 0 deletions labs/ptbr-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# ptbr-tools

The runtime scripts of the [ptbr-writer](https://github.com/h0ffmann/ptbr-writer) plugin, ported
from Python to Rust. They are std only: the crate has no dependencies, and `Cargo.lock` lists one
package. The plugin ships the static binary, so a person who installs it does not need Python.

There is one multi-call binary, `ptbr`. It runs as the command that its file name gives, so the
plugin installs three symlinks to it:

| command | what it does | replaces |
| :- | :- | :- |
| `ptbr-ask-levels SKILL.md TO_PDF DEFAULTS [READING_TIME] < hook.json` | The `UserPromptSubmit` hook. It reads the hook JSON on stdin and, for a request that starts with `ptbr`, prints the hook JSON with one instruction for Claude. The config layers are the plugin's `ptbr-writer.default.toml`, `~/.config/ptbr-writer/ptbr-writer.toml`, `./ptbr-writer.toml` and the request. `--self-test` runs the built-in checks. | `hooks/ask_levels.py` |
| `ptbr-reading-time [--lang pt-BR\|en] [--json] [FILE]` | Prints the reading-time line for a file or for stdin. `--self-test` runs the built-in checks. | `scripts/reading_time.py` |
| `ptbr-route "<request>"` / `--check` / `--self-test` | Prints `<skill> · <level>` for a request, from `routes.tsv`. `--check` compares `routes.tsv` with `SKILL.md`. | `skills/ptbr-routing/scripts/route.py` |

`ptbr ask-levels`, `ptbr reading-time` and `ptbr route` work too, with the same arguments.

```console
nix run github:h0ffmann/picasso?dir=labs/ptbr-tools#static -- reading-time README.md
nix build github:h0ffmann/picasso?dir=labs/ptbr-tools#static # result/bin/ptbr and three symlinks
just test # cargo test in the dev shell
just build # the static binary in ./result
just size # bytes, xz bytes, closure
```

`ptbr-route` finds `routes.tsv` and `SKILL.md` from the path that it is started with, as
`route.py` did from `__file__`: the binary (or the symlink to it) goes in `<skill>/scripts/`,
`routes.tsv` is in `<skill>/`, and `SKILL.md` is two directories above `<skill>/`.

## Same output as the Python

The output is the same byte for byte, and the exit codes are the same, for every input that was
tested (see the pull request that added this lab). These differences are on purpose:

- **Usage text.** It names the Rust command, not `python3 -I reading_time.py`.
- **`READING_TIME` that does not end in `.py`.** The instruction that `ptbr-ask-levels` writes for
Claude says `python3 -I <path>` when the path ends in `.py`, and `<path>` alone when it does not.
A path to `ptbr-reading-time` gives a command that Claude can run.
- **Where Python crashes with a traceback** (a TOML file that is not UTF-8, a list inside a list
in a TOML array of levels), `ptbr-ask-levels` reads the file as "cannot read" or ignores the
value, and goes on. A prompt that is not a JSON string still gives exit code 1.
- **Order of the duplicate errors of `ptbr-route --check`.** Python walks a set, so its order
changes from run to run. The Rust order is the order of the file.

Shortcuts, each with a comment in the code:

- **Accent folding.** The level and route words are matched after `fold`: lower case, then a
table of the precomposed Portuguese letters (and a few neighbours: `à â ã ä å é è ê ë í ì î ï ó ò ô õ ö ú ù û ü ç ñ ý ÿ`)
to plain ASCII, and the combining marks U+0300..U+036F dropped. Python used a full NFD. An
accent of another script stays in the Rust text.
- **TOML.** The reader takes `key = value` lines (string, integer, boolean, array of these;
floats and inline tables are skipped), comments and `[table]` headers. Dates, multi-line
strings, dotted keys and `[[arrays]]` make the file "cannot read" (the hook then tells Claude to
read it itself), where Python's `tomllib` would read them.
- **JSON.** The parser is strict as Python's `json.load` is. A lone `\ud800` escape in the prompt
becomes U+FFFD.
- The regular expressions of `reading_time.py` are written by hand, with the order of the
engine's backtracking. `\w` is `char::is_alphanumeric` or `_`.

## Sizes

Measured on x86_64-linux, 2026-10-21, `nix build`, stripped, `opt-level = "z"`, `lto`,
`codegen-units = 1`, `panic = "abort"`.

| build | file | closure |
| :- | -: | -: |
| **`packages.static`**, one multi-call binary (musl, static-pie) | **570 112 bytes (0.54 MiB)**; 241 720 bytes with `xz -9` | 557.8 KiB |
| `packages.static`, three separate binaries (the first design) | 492 288 + 480 000 + 459 488 = 1 431 776 bytes (1.37 MiB) | 1.37 MiB |
| `packages.static-aarch64` (musl, static) | 528 696 bytes | 517.4 KiB |
| `packages.default` (glibc) | 480 176 bytes | 47.4 MiB (glibc and libgcc_s from the store) |

The three separate binaries each carry their own copy of `std`. One binary that dispatches on its
name is 2.5 times smaller in total, so the lab ships that one. `checks.size` fails if the static
binary grows more than 10% over 570 112 bytes (a ceiling of 627 123 bytes).

## Platforms

| package | runs on | needs |
| :- | :- | :- |
| `packages.static` | any x86_64 Linux | nothing. Ran with an empty environment (`env -i`) |
| `packages.static-aarch64` | aarch64 Linux | nothing. Built; **not run** ⚠ (no emulator here) |
| `packages.default` | x86_64 Linux with Nix | glibc from the store |
| aarch64-darwin | declared for `packages.default` | not built ⚠ |
| x86_64-darwin, Windows | not declared | not built ⚠ |

## Checks

`nix flake check --no-update-lock-file` builds and runs `cargo test` (the self-tests of all three
scripts, ported one for one, and tests of the TOML and JSON readers), `clippy -D warnings`,
`cargo fmt --check`, `lab.json`, the size ceiling and the aarch64 build.

## Reusing it from another repository

```nix
inputs.ptbr-tools.url = "github:h0ffmann/picasso?dir=labs/ptbr-tools";
```

Then use `ptbr-tools.packages.x86_64-linux.static` for the plugin's files, or `.default` in a
Nix shell. A plugin that ships the binary copies `bin/ptbr` and makes the three symlinks.
27 changes: 27 additions & 0 deletions labs/ptbr-tools/flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 98 additions & 0 deletions labs/ptbr-tools/flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{
description = "ptbr-tools — the runtime scripts of the ptbr-writer plugin as small static Rust binaries";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; # locked to the same rev as labs/ptbr-pdf
};

outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
forAll = f: lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s});

src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [ ./Cargo.toml ./Cargo.lock ./src ./tests ];
};

# One multi-call binary, `ptbr`, with ptbr-ask-levels, ptbr-reading-time and ptbr-route as
# symlinks to it: std only, no crates, one copy of std (0.54 MiB against 1.37 MiB for
# three binaries). Tests run in the build (checkPhase).
toolsFor = pkgs: pkgs.rustPlatform.buildRustPackage {
pname = "ptbr-tools";
version = "0.1.0";
inherit src;
cargoLock.lockFile = ./Cargo.lock;
stripAllList = [ "bin" ]; # the symbol table too, not only debug info
postInstall = ''
for c in ask-levels reading-time route; do ln -s ptbr "$out/bin/ptbr-$c"; done
'';
meta.mainProgram = "ptbr";
};

# bytes: the measured size of the static binary plus 10% (README, Sizes).
sizeCeiling = 627123;

# Same sources, `cargo clippy -D warnings` as the build.
clippyFor = pkgs: (toolsFor pkgs).overrideAttrs (old: {
pname = "ptbr-tools-clippy";
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.clippy ];
buildPhase = "cargo clippy --release --all-targets --offline -- -D warnings";
doCheck = false;
installPhase = "touch $out";
});

# lab.json, the card the profile README renders (copied in every lab: a lab references
# nothing outside itself).
labCardFor = pkgs:
let
card = lib.importJSON ./lab.json;
version = attr: (lib.getAttrFromPath (lib.splitString "." attr) pkgs).version;
in
assert lib.assertMsg (lib.isString card.summary && card.summary != "" && lib.stringLength card.summary <= 100)
"lab.json: summary must be one non-empty line of at most 100 characters";
assert lib.assertMsg (lib.isList card.headline && card.headline != [ ] && lib.all lib.isString card.headline)
"lab.json: headline must be a non-empty list of nixpkgs attribute paths";
pkgs.writeText "lab-json" (lib.concatMapStrings (a: "${a} ${version a}\n") card.headline);
in
{
packages = forAll (pkgs: {
default = toolsFor pkgs;
} // lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
# static musl binaries, for the plugin to ship
static = toolsFor pkgs.pkgsStatic;
static-aarch64 = toolsFor pkgs.pkgsCross.aarch64-multiplatform.pkgsStatic;
});

apps = forAll (pkgs: {
default = { type = "app"; program = "${toolsFor pkgs}/bin/ptbr"; };
});

devShells = forAll (pkgs: {
default = pkgs.mkShell {
name = "ptbr-tools";
packages = [ pkgs.rustc pkgs.cargo pkgs.clippy pkgs.rustfmt pkgs.rust-analyzer ] ++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.gcc;
};
});

checks = forAll (pkgs:
let system = pkgs.stdenv.hostPlatform.system; in {
lab-json = labCardFor pkgs;
ptbr-tools = toolsFor pkgs; # builds and runs cargo test
clippy = clippyFor pkgs;
fmt = pkgs.runCommand "ptbr-tools-fmt" { nativeBuildInputs = [ pkgs.cargo pkgs.rustfmt ]; } ''
cargo fmt --check --manifest-path ${src}/Cargo.toml && touch $out
'';
} // lib.optionalAttrs (system == "x86_64-linux") {
# the static binary must stay light: fails if it grows past the ceiling (measured size + 10%)
static-aarch64 = self.packages.${system}.static-aarch64; # builds; not run here, there is no emulator
size = pkgs.runCommand "ptbr-tools-size" { } ''
size=$(stat -L -c %s ${self.packages.${system}.static}/bin/ptbr)
echo "ptbr: $size bytes, ceiling ${toString sizeCeiling}"
[ "$size" -le ${toString sizeCeiling} ] || { echo "ptbr grew past its ceiling" >&2; exit 1; }
touch $out
'';
});
};
}
23 changes: 23 additions & 0 deletions labs/ptbr-tools/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# ptbr-tools — the ptbr-writer plugin's runtime scripts as static Rust binaries. `just` lists recipes.
set shell := ["bash", "-euo", "pipefail", "-c"]
set positional-arguments
here := justfile_directory()

default:
@just --list

# cargo test in the dev shell (the self-tests of the three scripts, the TOML and JSON readers).
test:
cd "{{here}}" && nix develop "{{here}}" -c cargo test

# Build the static binary (musl) and list it with its three command names.
build:
nix build "{{here}}#static" --print-build-logs --out-link "{{here}}/result" && ls -lL "{{here}}/result/bin/"

# The size of the static binary, its xz size and the closure a consumer downloads.
size:
p="$(nix build "{{here}}#static" --no-link --print-out-paths)"; stat -L -c '%s bytes %n' "$p/bin/ptbr"; xz -9 -c "$p/bin/ptbr" | wc -c; nix path-info -Sh "$p"

# Enter the shell: rustc, cargo, clippy, rustfmt and rust-analyzer on PATH.
shell:
nix develop "{{here}}"
4 changes: 4 additions & 0 deletions labs/ptbr-tools/lab.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"summary": "The ptbr-writer plugin's runtime scripts as three small static Rust binaries, std only",
"headline": ["rustc", "cargo"]
}
Loading
Loading