From f5c58806aac2f089087bf3e2475eeb7a7ca477de Mon Sep 17 00:00:00 2001 From: "M.Hoffmann" Date: Wed, 21 Oct 2026 02:11:22 -0300 Subject: [PATCH] feat(ptbr-tools): the ptbr-writer plugin's runtime scripts as one static Rust binary New lab. It ports ask_levels.py (the UserPromptSubmit hook), reading_time.py and route.py (+ routes.tsv) of h0ffmann/ptbr-writer to Rust, std only, no crates. One multi-call binary, `ptbr`, runs as ptbr-ask-levels, ptbr-reading-time or ptbr-route (symlinks, or `ptbr `). The static musl build is 570 112 bytes; three separate binaries were 1 431 776 bytes in total, so the lab ships the multi-call one. Same flags, outputs and exit codes as the Python, with a minimal TOML reader, a minimal JSON reader/writer, a table-based accent fold and hand-written versions of the regular expressions. The self-tests are ported to cargo test and --self-test still works. checks: build + tests, clippy -D warnings, fmt, lab-json, a size ceiling (+10%), and the aarch64 static build. Tested: nix flake check --no-update-lock-file in labs/ptbr-tools (build+cargo test, clippy, fmt, lab-json, size, static-aarch64 ok); nixpkgs-fmt, statix, deadnix with the lab's pinned nixpkgs ok; actionlint ok; reuse lint ok; 2297 differential comparisons against the Python originals, 8 differ only where Python crashes with a traceback; aarch64 binary built, not run Co-Authored-By: Claude --- .github/workflows/ci.yml | 4 +- README.md | 2 + labs/ptbr-tools/.gitignore | 2 + labs/ptbr-tools/Cargo.lock | 7 + labs/ptbr-tools/Cargo.toml | 28 + labs/ptbr-tools/README.md | 99 +++ labs/ptbr-tools/flake.lock | 27 + labs/ptbr-tools/flake.nix | 98 +++ labs/ptbr-tools/justfile | 23 + labs/ptbr-tools/lab.json | 4 + labs/ptbr-tools/src/ask_levels.rs | 769 ++++++++++++++++++ labs/ptbr-tools/src/bin/ptbr.rs | 25 + labs/ptbr-tools/src/json.rs | 277 +++++++ labs/ptbr-tools/src/lib.rs | 8 + labs/ptbr-tools/src/reading_time.rs | 679 ++++++++++++++++ labs/ptbr-tools/src/route.rs | 286 +++++++ labs/ptbr-tools/src/text.rs | 139 ++++ labs/ptbr-tools/src/toml.rs | 337 ++++++++ .../tests/fixtures/ptbr-writer.default.toml | 20 + labs/ptbr-tools/tests/fixtures/routes.tsv | 6 + 20 files changed, 2838 insertions(+), 2 deletions(-) create mode 100644 labs/ptbr-tools/.gitignore create mode 100644 labs/ptbr-tools/Cargo.lock create mode 100644 labs/ptbr-tools/Cargo.toml create mode 100644 labs/ptbr-tools/README.md create mode 100644 labs/ptbr-tools/flake.lock create mode 100644 labs/ptbr-tools/flake.nix create mode 100644 labs/ptbr-tools/justfile create mode 100644 labs/ptbr-tools/lab.json create mode 100644 labs/ptbr-tools/src/ask_levels.rs create mode 100644 labs/ptbr-tools/src/bin/ptbr.rs create mode 100644 labs/ptbr-tools/src/json.rs create mode 100644 labs/ptbr-tools/src/lib.rs create mode 100644 labs/ptbr-tools/src/reading_time.rs create mode 100644 labs/ptbr-tools/src/route.rs create mode 100644 labs/ptbr-tools/src/text.rs create mode 100644 labs/ptbr-tools/src/toml.rs create mode 100644 labs/ptbr-tools/tests/fixtures/ptbr-writer.default.toml create mode 100644 labs/ptbr-tools/tests/fixtures/routes.tsv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00eb9e3..1c24f65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/README.md b/README.md index 712eb16..0e5b58f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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..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..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` | diff --git a/labs/ptbr-tools/.gitignore b/labs/ptbr-tools/.gitignore new file mode 100644 index 0000000..ff69d57 --- /dev/null +++ b/labs/ptbr-tools/.gitignore @@ -0,0 +1,2 @@ +target/ +result diff --git a/labs/ptbr-tools/Cargo.lock b/labs/ptbr-tools/Cargo.lock new file mode 100644 index 0000000..278c29d --- /dev/null +++ b/labs/ptbr-tools/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ptbr-tools" +version = "0.1.0" diff --git a/labs/ptbr-tools/Cargo.toml b/labs/ptbr-tools/Cargo.toml new file mode 100644 index 0000000..9422211 --- /dev/null +++ b/labs/ptbr-tools/Cargo.toml @@ -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 diff --git a/labs/ptbr-tools/README.md b/labs/ptbr-tools/README.md new file mode 100644 index 0000000..6c2433b --- /dev/null +++ b/labs/ptbr-tools/README.md @@ -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 ""` / `--check` / `--self-test` | Prints ` · ` 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 `/scripts/`, +`routes.tsv` is in `/`, and `SKILL.md` is two directories above `/`. + +## 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 ` when the path ends in `.py`, and `` 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. diff --git a/labs/ptbr-tools/flake.lock b/labs/ptbr-tools/flake.lock new file mode 100644 index 0000000..915f6ef --- /dev/null +++ b/labs/ptbr-tools/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791594108, + "narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6510408d147d8c5e6d76043e899e9251756f6a75", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/labs/ptbr-tools/flake.nix b/labs/ptbr-tools/flake.nix new file mode 100644 index 0000000..7ab1500 --- /dev/null +++ b/labs/ptbr-tools/flake.nix @@ -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 + ''; + }); + }; +} diff --git a/labs/ptbr-tools/justfile b/labs/ptbr-tools/justfile new file mode 100644 index 0000000..b6a5768 --- /dev/null +++ b/labs/ptbr-tools/justfile @@ -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}}" diff --git a/labs/ptbr-tools/lab.json b/labs/ptbr-tools/lab.json new file mode 100644 index 0000000..baaf1d2 --- /dev/null +++ b/labs/ptbr-tools/lab.json @@ -0,0 +1,4 @@ +{ + "summary": "The ptbr-writer plugin's runtime scripts as three small static Rust binaries, std only", + "headline": ["rustc", "cargo"] +} diff --git a/labs/ptbr-tools/src/ask_levels.rs b/labs/ptbr-tools/src/ask_levels.rs new file mode 100644 index 0000000..c34193a --- /dev/null +++ b/labs/ptbr-tools/src/ask_levels.rs @@ -0,0 +1,769 @@ +//! ptbr-ask-levels: the UserPromptSubmit hook's logic. A 1:1 port of ask_levels.py. +//! +//! A request that starts with `ptbr` (`ptbr: ...`, `ptbr ...`, `/ptbr`, `/ptbr-writer:...`) gets one +//! instruction for Claude; any other request gets nothing, so the hook costs no context. +//! +//! The choices live in ptbr-writer.toml, read in this order, each overriding the one before: +//! the plugin's ptbr-writer.default.toml, ~/.config/ptbr-writer/ptbr-writer.toml, then +//! ./ptbr-writer.toml; the request itself overrides all. With no config file, Claude asks the +//! levels, the formats and the folder once and writes ./ptbr-writer.toml. +//! +//! ```text +//! ptbr-ask-levels SKILL.md TO_PDF DEFAULTS [READING_TIME] < hook-input.json +//! ptbr-ask-levels --self-test +//! ``` + +use crate::json::{self, J}; +use crate::text::{fold, has_word, is_word, join, py_repr, py_space, read_text}; +use crate::toml::{self, Value}; +use std::io::Read; + +const COMPOSES: &str = "smart-note"; // combines with another level: the `notes` format, not one of the levels +const MAX: usize = 4; // AskUserQuestion takes at most 4 options; "Other" is added by itself +const FORMATS: [&str; 4] = ["txt", "md", "pdf", "notes"]; +const LANGUAGES: [&str; 2] = ["pt-BR", "asd-ste100"]; // pt-BR is the default; ASD-STE100 is Simplified Technical English +const NAME: &str = "ptbr-writer---."; + +fn lower(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +/// `(?<=...)` helper: does `s` (already a char slice) start with `word`, ignoring case? +fn starts_ci(s: &[char], word: &str) -> Option { + let w: Vec = word.chars().collect(); + (s.len() >= w.len() && s.iter().zip(&w).all(|(a, b)| lower(*a) == *b)).then_some(w.len()) +} + +fn after_ws_slash(prompt: &str) -> Vec { + let c: Vec = prompt.chars().collect(); + let mut i = 0; + while i < c.len() && py_space(c[i]) { + i += 1; + } + if c.get(i) == Some(&'/') { + i += 1; + } + c[i..].to_vec() +} + +/// `^\s*/?ptbr(-writer)?(:\S*)?(?=[\s:,.]|$)`, ignoring case. +fn is_trigger(prompt: &str) -> bool { + let c = after_ws_slash(prompt); + let Some(n) = starts_ci(&c, "ptbr") else { + return false; + }; + let tail = |r: &[char]| { + r.first() + .is_none_or(|&x| py_space(x) || matches!(x, ':' | ',' | '.')) + }; + let rest = &c[n..]; + starts_ci(rest, "-writer").is_some_and(|w| tail(&rest[w..])) || tail(rest) +} + +/// `^\s*/?ptbr(-writer)?\s*:?\s*(reconfigur\w*|config\w*)\b`, ignoring case. +fn is_reconfig(prompt: &str) -> bool { + let c = after_ws_slash(prompt); + let Some(n) = starts_ci(&c, "ptbr") else { + return false; + }; + let rest = &c[n..]; + let tail = |r: &[char]| { + let mut i = 0; + while r.get(i).is_some_and(|&x| py_space(x)) { + i += 1; + } + if r.get(i) == Some(&':') { + i += 1; + } + while r.get(i).is_some_and(|&x| py_space(x)) { + i += 1; + } + // `\w*` takes the rest of the word, so `\b` always holds after it. + starts_ci(&r[i..], "reconfigur") + .or_else(|| starts_ci(&r[i..], "config")) + .is_some() + }; + starts_ci(rest, "-writer").is_some_and(|w| tail(&rest[w..])) || tail(rest) +} + +/// `(? bool { + let c: Vec = prompt.chars().collect(); + (0..c.len()).any(|i| { + if i > 0 && is_word(c[i - 1]) { + return false; + } + let try_from = |mut j: usize| -> bool { + let Some(n) = starts_ci(&c[j..], "ste") else { + return false; + }; + j += n; + if c.get(j) == Some(&'-') { + j += 1; + } + let Some(n) = starts_ci(&c[j..], "100") else { + return false; + }; + j += n; + c.get(j).is_none_or(|&x| !is_word(x)) + }; + let with_asd = starts_ci(&c[i..], "asd").is_some_and(|n| { + try_from(i + n) || (c.get(i + n) == Some(&'-') && try_from(i + n + 1)) + }); + with_asd || try_from(i) + }) +} + +/// The "## Níveis de rigor" list of SKILL.md: (name, what it is, without the final dots). +pub fn skill_levels(text: &str) -> Vec<(String, String)> { + let section = text.split_once("## Níveis de rigor").map_or(text, |x| x.1); + let section = section.split("\n## ").next().unwrap_or(section); + section + .split('\n') + .filter_map(|l| { + let r = l.strip_prefix("- `")?; + let (name, after) = r.split_once('`')?; + let u = after.strip_prefix(": ")?; + (!name.is_empty() && !u.is_empty()) + .then(|| (name.to_string(), u.trim_end_matches('.').to_string())) + }) + .collect() +} + +#[derive(Clone, Default)] +struct Cfg { + levels: Option>, + formats: Option>, + folder: Option, + print: Option>, + languages: Option>, +} + +impl Cfg { + fn apply(&mut self, o: Cfg) { + macro_rules! over { + ($($f:ident),*) => { $(if o.$f.is_some() { self.$f = o.$f; })* }; + } + over!(levels, formats, folder, print, languages); + } +} + +/// The table in the file: empty when absent, `None` when it cannot be read. +fn load(path: &str) -> Option { + if !std::path::Path::new(path).is_file() { + return Some(toml::Doc::default()); + } + toml::parse(&read_text(path).ok()?).ok() +} + +/// A value as Python's `str()` and `repr()` show it inside an f-string. +fn show(v: &Value, repr: bool) -> String { + match v { + Value::Str(s) if repr => py_repr(s), + Value::Str(s) => s.clone(), + Value::Int(i) => i.to_string(), + Value::Bool(b) => if *b { "True" } else { "False" }.to_string(), + Value::Arr(a) => format!( + "[{}]", + a.iter() + .map(|x| show(x, true)) + .collect::>() + .join(", ") + ), + Value::Table => "{}".to_string(), + Value::Other(t) => t.clone(), + } +} + +/// Keep only values the plugin knows; a folder must stay inside the session's root. +fn clean( + doc: &toml::Doc, + levels: &[(String, String)], + problems: &mut Vec, + where_: &str, +) -> Cfg { + let mut out = Cfg::default(); + let names: Vec<&str> = levels + .iter() + .map(|l| l.0.as_str()) + .filter(|n| *n != COMPOSES) + .collect(); + for (key, allowed) in [ + ("levels", &names[..]), + ("formats", &FORMATS[..]), + ("print", &FORMATS[..]), + ("languages", &LANGUAGES[..]), + ] { + let Some(v) = doc.get(key) else { continue }; + let (vals, bad): (Vec, Vec<&Value>) = match v { + Value::Arr(a) => { + let ok = |x: &&Value| matches!(x, Value::Str(s) if allowed.contains(&s.as_str())); + ( + a.iter().filter(ok).map(|x| show(x, false)).collect(), + a.iter().filter(|x| !ok(x)).collect(), + ) + } + _ => (vec![], vec![]), + }; + if !bad.is_empty() || !matches!(v, Value::Arr(_)) { + let shown = if bad.is_empty() { + show(v, false) + } else { + format!( + "[{}]", + bad.iter() + .map(|x| show(x, true)) + .collect::>() + .join(", ") + ) + }; + problems.push(format!( + "{where_}: `{key}` tem valores desconhecidos {shown}; ignorados" + )); + } + match key { + "levels" => out.levels = Some(vals), + "formats" => out.formats = Some(vals), + "print" => out.print = Some(vals), + _ => out.languages = Some(vals), + } + } + if let Some(f) = doc.get("folder") { + match f { + Value::Str(s) + if !s.is_empty() + && !s.starts_with('/') + && !s.replace('\\', "/").split('/').any(|p| p == "..") => + { + out.folder = Some(s.clone()) + } + _ => problems.push(format!( + "{where_}: `folder` precisa ser relativa à raiz, sem `..`; ignorada" + )), + } + } + out +} + +fn list_or_null(v: &Option>) -> String { + v.as_deref().map_or("null".to_string(), json::list) +} + +/// The additionalContext text, or `None` when the request is not for the plugin. +pub fn instruction( + prompt: &str, + skill_md: &str, + to_pdf: &str, + defaults_path: &str, + home: &str, + cwd: &str, + reading_time: &str, +) -> Option { + if !is_trigger(prompt) { + return None; + } + let levels = skill_levels(&read_text(skill_md).ok()?); + if levels.is_empty() { + return None; + } + // A Python path is run by the interpreter; a binary is run as it is. + let rt = if reading_time.ends_with(".py") { + format!("python3 -I {reading_time}") + } else { + reading_time.to_string() + }; + let project = join(cwd, "ptbr-writer.toml"); + let user = join( + &join(&join(home, ".config"), "ptbr-writer"), + "ptbr-writer.toml", + ); + let (mut problems, mut cfg, mut found) = (Vec::new(), Cfg::default(), Vec::::new()); + for (path, where_) in [ + (defaults_path, "padrões"), + (user.as_str(), user.as_str()), + (project.as_str(), "./ptbr-writer.toml"), + ] { + let Some(doc) = load(path) else { + problems.push(format!( + "{where_}: não consegui ler; leia o arquivo você mesmo e siga-o" + )); + if path != defaults_path { + found.push(where_.to_string()); + } + continue; + }; + if !doc.top.is_empty() && path != defaults_path { + found.push(where_.to_string()); + } + cfg.apply(clean(&doc, &levels, &mut problems, where_)); + } + let folded = fold(prompt); + let named: Vec<&str> = levels + .iter() + .map(|l| l.0.as_str()) + .filter(|n| has_word(&folded, &fold(n))) + .collect(); + let ste = mentions_ste(prompt); + + let opts: Vec<&(String, String)> = levels + .iter() + .filter(|l| l.0 != COMPOSES) + .take(MAX) + .collect(); + let notes = levels.iter().any(|l| l.0 == COMPOSES); + let mut fmts = vec![ + "`txt` (texto puro)".to_string(), + "`md` (Markdown)".to_string(), + format!("`pdf` (do .md, com `bash {to_pdf} .md`)"), + ]; + if notes { + fmts.push(format!( + "`notes` (o nível {COMPOSES}: notas indexáveis, uma ideia por nota, com tags e links)" + )); + } + let write = format!( + "Grave cada versão em cada formato, na pasta configurada (crie se faltar), com o nome `{NAME}`: \ + o carimbo em UTC, o mesmo para todos os arquivos do pedido; o objetivo em 2 a 4 palavras, minúsculas sem acento, \ + unidas por hífen; o nível sem acento; a versão em ASD-STE100 leva `-ste100` antes da extensão{}\ + . Uma versão por nível em cada idioma de `languages`: `pt-BR` pelas regras da skill; `asd-ste100` em inglês técnico \ + simplificado (ASD-STE100: vocabulário controlado, voz ativa, frases curtas, uma instrução por frase), com o mesmo conteúdo, \ + sem inventar nada. Na resposta, cada versão sob `## ` (ou `## · ASD-STE100`) e a lista dos arquivos gravados; o conteúdo dos formatos em `print` \ + aparece inteiro na resposta, num bloco ```text ou ```markdown, nunca só o caminho. Se o script do pdf sair com 3, \ + ele precisa baixar ferramentas: diga o tamanho que ele informa, pergunte se pode baixar e, com o sim, rode de novo \ + com `--download`; sem o sim, ou se falhar, entregue o .md e diga por quê. \ + Tempo de leitura: cada versão da resposta termina com a linha `Leitura estimada: ~N min (...)`, e cada `.txt` e `.md` começa com ela \ + (a versão ASD-STE100 usa `Estimated reading time: ...`); o `.pdf` herda a linha do `.md`; em `notes`, ponha `reading_time` \ + no front matter de cada nota. Calcule-a com `{rt} [--lang en] ` (grave o arquivo sem a linha, \ + calcule e então insira a linha), nunca de cabeça.", + if notes { format!("; com `notes`, as notas pelas regras de {COMPOSES} (`{COMPOSES} + `)") } else { String::new() } + ); + let print = cfg.print.as_ref().map_or_else( + || json::list(&["txt".to_string(), "md".to_string()]), + |p| json::list(p), + ); + let ask = [ + "Chame AskUserQuestion com quatro perguntas numa chamada só:".to_string(), + format!( + "1. header \"Nível\", multiSelect, \"Em quais níveis?\", opções: {}.", + opts.iter().map(|(n, u)| format!("{n} ({u})")).collect::>().join("; ") + ), + format!("2. header \"Formato\", multiSelect, \"Em quais formatos?\", opções: {}.", fmts.join("; ")), + "3. header \"Pasta\", uma escolha, \"Onde gravar os arquivos?\", opções nesta ordem: raiz (Recommended), \ + a pasta da sessão, o padrão; docs/. Outra pasta vem pelo Other." + .to_string(), + "4. header \"Idioma\", multiSelect, \"Em quais idiomas?\", opções nesta ordem: pt-BR (Recommended), o padrão; \ + ASD-STE100 (inglês técnico simplificado). Sem marcação, pt-BR." + .to_string(), + format!( + "Depois grave as respostas em ./ptbr-writer.toml, com `levels`, `formats`, `folder` (\".\" para a raiz), \ + `languages` (\"pt-BR\" e/ou \"asd-ste100\") e `print = {print}`, uma linha de comentário dizendo que o arquivo \ + pode ser editado ou refeito com `ptbr reconfigurar`, e diga isso à pessoa numa frase." + ), + ]; + let no_ask = "Sem AskUserQuestion (sessão não interativa): não pergunte nem grave arquivo; use o nível que a skill classificar, declare-o e responda sob `## `."; + let mut lines: Vec = + vec!["ptbr-writer: um pedido de escrita com o plugin.".to_string()]; + if is_reconfig(prompt) { + lines.push("A pessoa pediu para reconfigurar.".to_string()); + lines.extend(ask); + lines.push("Não escreva texto agora, só a configuração.".to_string()); + lines.push(no_ask.to_string()); + } else if !found.is_empty() || !named.is_empty() || ste { + let mut used = cfg.clone(); + if !named.is_empty() { + used.levels = Some( + named + .iter() + .filter(|n| **n != COMPOSES) + .map(|n| n.to_string()) + .collect(), + ); + let f = used.formats.get_or_insert_with(Vec::new); + if named.contains(&COMPOSES) && !f.iter().any(|x| x == "notes") { + f.push("notes".to_string()); + } + } + if ste + && !used + .languages + .as_ref() + .is_some_and(|l| l.iter().any(|x| x == "asd-ste100")) + { + let mut l = used + .languages + .take() + .unwrap_or_else(|| vec!["pt-BR".to_string()]); + l.push("asd-ste100".to_string()); + used.languages = Some(l); + } + let shown = + format!( + "{{\"levels\": {}, \"formats\": {}, \"folder\": {}, \"print\": {}, \"languages\": {}}}", + list_or_null(&used.levels), + list_or_null(&used.formats), + used.folder.as_deref().map_or("null".to_string(), json::quote), + list_or_null(&used.print), + list_or_null(&used.languages) + ); + lines.push(format!( + "Configuração em uso ({}; o pedido vence): {shown}. Não pergunte nada disso.", + if found.is_empty() { + "padrões".to_string() + } else { + found.join(", ") + } + )); + let tail = if used.formats.as_ref().is_none_or(Vec::is_empty) { + format!( + "Sem formatos: só a resposta, sem arquivo, mas cada versão termina com a linha `Leitura estimada: ~N min (...)` (`Estimated reading time: ...` em inglês), calculada com `{rt} [--lang en] -` lendo o texto da entrada padrão; com `asd-ste100`, a versão em inglês técnico simplificado sob `## · ASD-STE100`. " + ) + } else { + write + }; + lines.push(format!( + "Carregue a skill ptbr-writer e escreva uma versão por nível de `levels` (vazio: a skill classifica e declara o nível) em cada idioma de `languages`. {tail}" + )); + } else { + lines + .push("Não há ptbr-writer.toml ainda: esta é a única vez que se pergunta.".to_string()); + lines.extend(ask); + lines.push(format!( + "Então carregue a skill ptbr-writer e escreva uma versão por nível marcado. {write}" + )); + lines.push(no_ask.to_string()); + } + lines.extend( + problems + .iter() + .map(|p| format!("Aviso da configuração: {p}.")), + ); + Some(format!( + "{{\"hookSpecificOutput\": {{\"hookEventName\": \"UserPromptSubmit\", \"additionalContext\": {}}}}}", + json::quote(&lines.join("\n")) + )) +} + +/// The self-test of ask_levels.py: (name, ok). +pub fn self_test_cases() -> Vec<(String, bool)> { + use std::fs; + let tmp = std::env::temp_dir().join(format!("ptbr-ask-levels-{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + let s = |p: &std::path::Path| p.to_string_lossy().into_owned(); + let (home, cwd) = (tmp.join("home"), tmp.join("proj")); + let user_dir = home.join(".config").join("ptbr-writer"); + fs::create_dir_all(&user_dir).expect("temp dir"); + fs::create_dir_all(&cwd).expect("temp dir"); + let skill = s(&tmp.join("SKILL.md")); + fs::write(&skill, "# t\n\n## Níveis de rigor\n\n- `estrito`: runbooks.\n- `leve`: comunicados.\n- `científico`: artigos.\n- `smart-note`: notas.\n\n## Regras\n").expect("write"); + let defaults = s(&tmp.join("defaults.toml")); + fs::write(&defaults, "levels = []\nformats = []\nfolder = \".\"\nprint = [\"txt\", \"md\"]\nlanguages = [\"pt-BR\"]\n").expect("write"); + let (home_s, cwd_s) = (s(&home), s(&cwd)); + let ask = |p: &str| -> Option { + let out = instruction( + p, + &skill, + "/p/to-pdf.sh", + &defaults, + &home_s, + &cwd_s, + "/p/reading_time.py", + )?; + match json::parse(&out)? + .get("hookSpecificOutput")? + .get("additionalContext")? + { + J::Str(t) => Some(t.clone()), + _ => None, + } + }; + let project = |t: &str| fs::write(cwd.join("ptbr-writer.toml"), t).expect("write"); + let mut checks: Vec<(String, bool)> = Vec::new(); + let mut check = |name: &str, ok: bool| checks.push((name.to_string(), ok)); + let has = |o: &Option, subs: &[&str]| { + o.as_ref() + .is_some_and(|t| subs.iter().all(|x| t.contains(x))) + }; + let lacks = |o: &Option, sub: &str| o.as_ref().is_some_and(|t| !t.contains(sub)); + + let out = ask("ptbr: reescreva este texto"); + check( + "no config: asks once, four questions", + has(&out, &["única vez", "quatro perguntas"]), + ); + check( + "no config: writes ./ptbr-writer.toml", + has(&out, &["grave as respostas em ./ptbr-writer.toml"]), + ); + check( + "notes is a format, smart-note is not a level option", + has(&out, &["`notes` (o nível smart-note"]) && lacks(&out, "smart-note (notas)"), + ); + check( + "pdf points at to-pdf", + has(&out, &["bash /p/to-pdf.sh .md"]), + ); + check( + "folder question: raiz first, docs/ offered", + has(&out, &["raiz (Recommended)", "docs/"]), + ); + check( + "language question: pt-BR default, ASD-STE100 offered, multiSelect", + has( + &out, + &[ + "quatro perguntas", + "header \"Idioma\", multiSelect", + "pt-BR (Recommended)", + "ASD-STE100 (inglês técnico simplificado)", + ], + ), + ); + check("languages saved in the config", has(&out, &["`languages`"])); + check( + "reading time: line in the answer, .txt/.md start with it, pdf inherits, notes front matter", + has(&out, &["termina com a linha `Leitura estimada", "começa com ela", "`.pdf` herda", "`reading_time` no front matter"]), + ); + check( + "reading time: the script's absolute path, English line for ASD-STE100", + has( + &out, + &["python3 -I /p/reading_time.py", "Estimated reading time"], + ), + ); + check( + "non-interactive fallback", + has(&out, &["Sem AskUserQuestion"]), + ); + for p in [ + "/ptbr revise isto", + "/ptbr-writer:ptbr revise", + "PTBR reescreva", + "ptbr", + ] { + check(&format!("trigger: {}", py_repr(p)), ask(p).is_some()); + } + for p in [ + "for ptbr-writer", + "abra skills/ptbr/SKILL.md", + "rode o ptbr-routing", + "reescreva este runbook", + "o plugin ptbr é bom", + ] { + check(&format!("no trigger: {}", py_repr(p)), ask(p).is_none()); + } + check( + "bad input is silent", + instruction( + "", + &skill, + "x", + &defaults, + &home_s, + &cwd_s, + "reading_time.py", + ) + .is_none(), + ); + + project("levels = [\"estrito\"]\nformats = [\"md\", \"pdf\"]\nfolder = \"docs\"\n"); + let out = ask("ptbr: reescreva"); + check( + "config: no question", + lacks(&out, "AskUserQuestion com quatro") && has(&out, &["Não pergunte"]), + ); + check( + "config: values in use", + has(&out, &["\"levels\": [\"estrito\"]", "\"folder\": \"docs\""]), + ); + check( + "config: pt-BR is the default language", + has(&out, &["\"languages\": [\"pt-BR\"]"]), + ); + let out = ask("ptbr: reescreva"); + check( + "config: the reading-time instruction is there too", + has(&out, &["python3 -I /p/reading_time.py", "começa com ela"]), + ); + project("levels = [\"estrito\"]\nformats = []\n"); + let out = ask("ptbr: reescreva"); + check( + "no files: the answer still ends with the reading-time line", + has( + &out, + &["sem arquivo, mas cada versão termina com a linha `Leitura estimada"], + ), + ); + project("levels = [\"estrito\"]\nformats = [\"md\", \"pdf\"]\nfolder = \"docs\"\n"); + let out = ask("ptbr ste100: reescreva"); + check( + "ste100 in the request adds ASD-STE100", + has( + &out, + &["\"languages\": [\"pt-BR\", \"asd-ste100\"]", "-ste100"], + ), + ); + check("config: file name pattern", has(&out, &[NAME])); + let out = ask("ptbr leve: reescreva"); + check( + "the request overrides the config", + has(&out, &["\"levels\": [\"leve\"]"]), + ); + let out = ask("ptbr reconfigurar"); + check( + "reconfigure asks again", + has(&out, &["reconfigurar", "quatro perguntas"]), + ); + + fs::write( + user_dir.join("ptbr-writer.toml"), + "formats = [\"txt\"]\nfolder = \"notas\"\n", + ) + .expect("write"); + project("folder = \"docs\"\n"); + let out = ask("ptbr: x"); + check( + "project overrides user, user overrides defaults", + has(&out, &["\"folder\": \"docs\"", "\"formats\": [\"txt\"]"]), + ); + project("folder = \"../fora\"\nformats = [\"exe\"]\n"); + let out = ask("ptbr: x"); + check( + "a folder outside the root is refused", + has(&out, &["sem `..`", "\"folder\": \"notas\""]), + ); + check( + "an unknown format is dropped", + has(&out, &["`formats` tem valores desconhecidos"]), + ); + project("levels = [\n"); + let out = ask("ptbr: x"); + check("broken TOML is reported", has(&out, &["não consegui ler"])); + let out = instruction( + "ptbr", + &skill, + "x", + &defaults, + &home_s, + &cwd_s, + "reading_time.py", + ); + check( + "valid hook JSON", + out.and_then(|o| json::parse(&o)).and_then(|j| { + match j.get("hookSpecificOutput")?.get("hookEventName")? { + J::Str(s) => Some(s == "UserPromptSubmit"), + _ => None, + } + }) == Some(true), + ); + let _ = fs::remove_dir_all(&tmp); + checks +} + +fn self_test() -> i32 { + let checks = self_test_cases(); + let fails = checks.iter().filter(|c| !c.1).count(); + for (n, ok) in &checks { + println!("{}{n}", if *ok { " ok " } else { " FAIL " }); + } + if fails == 0 { + println!("ask-levels self-test: ok"); + } else { + eprintln!("ask-levels self-test: {fails} failure(s)"); + } + (fails > 0) as i32 +} + +pub fn main(argv: Vec) -> i32 { + if argv.len() == 2 && argv[1] == "--self-test" { + return self_test(); + } + let mut buf = Vec::new(); + // Python: an unreadable or non-JSON stdin is a silent success. + let Some(input) = std::io::stdin() + .read_to_end(&mut buf) + .ok() + .and_then(|_| String::from_utf8(buf).ok()) + .and_then(|s| json::parse(&s)) + else { + return 0; + }; + let prompt = match &input { + J::Obj(_) => match input.get("prompt") { + None => String::new(), + Some(J::Str(s)) => s.clone(), + Some(_) => return 1, + }, + _ => return 1, + }; + if argv.len() < 4 { + eprintln!( + "usage: ptbr-ask-levels SKILL.md TO_PDF DEFAULTS [READING_TIME] < hook-input.json" + ); + return 1; + } + let home = std::env::var("HOME").map_or("~".to_string(), |h| { + let t = h.trim_end_matches('/'); + if t.is_empty() { + "/".to_string() + } else { + t.to_string() + } + }); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let rt = argv.get(4).map_or("reading_time.py", String::as_str); + if let Some(out) = instruction(&prompt, &argv[1], &argv[2], &argv[3], &home, &cwd, rt) { + println!("{out}"); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_test_cases_pass() { + for (name, ok) in self_test_cases() { + assert!(ok, "{name}"); + } + } + + #[test] + fn trigger_and_reconfig_edges() { + for p in [ + "ptbr:x", + " /PTBR, x", + "ptbr-writer", + "ptbr-writer:y", + "ptbr.", + ] { + assert!(is_trigger(p), "{p}"); + } + for p in ["ptbrx", "ptbr-writerx", "x ptbr", "", "ptbr-"] { + assert!(!is_trigger(p), "{p}"); + } + assert!(is_reconfig("ptbr: Reconfigurar")); + assert!(is_reconfig("/ptbr-writer configuração")); + assert!(!is_reconfig("ptbr reescreva")); + } + + #[test] + fn ste_and_accent_fold() { + for p in [ + "ptbr ste100", + "ptbr ASD-STE-100 x", + "asdste100", + "(ste-100)", + ] { + assert!(mentions_ste(p), "{p}"); + } + for p in ["xste100", "ste1000", "ste10"] { + assert!(!mentions_ste(p), "{p}"); + } + assert!(has_word(&fold("Científico"), "cientifico")); + assert!(has_word(&fold("CIENTI\u{301}FICO"), "cientifico")); + } +} diff --git a/labs/ptbr-tools/src/bin/ptbr.rs b/labs/ptbr-tools/src/bin/ptbr.rs new file mode 100644 index 0000000..5b11c74 --- /dev/null +++ b/labs/ptbr-tools/src/bin/ptbr.rs @@ -0,0 +1,25 @@ +//! One multi-call binary: it runs as the command named by argv[0] (`ptbr-ask-levels`, +//! `ptbr-reading-time`, `ptbr-route`, the names of symlinks to it), or by its first argument +//! (`ptbr route --check`). One std copy instead of three. +use ptbr_tools::{ask_levels, reading_time, route}; + +fn main() { + let mut a: Vec = std::env::args().collect(); + let mut name = a + .first() + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("") + .to_string(); + if name == "ptbr" && a.len() > 1 { + name = format!("ptbr-{}", a.remove(1)); + } + std::process::exit(match name.as_str() { + "ptbr-ask-levels" => ask_levels::main(a), + "ptbr-reading-time" => reading_time::main(a), + "ptbr-route" => route::main(a.into_iter().skip(1).collect()), + _ => { + eprintln!("usage: ptbr ask-levels|reading-time|route ... (or run it as ptbr-ask-levels, ptbr-reading-time, ptbr-route)"); + 2 + } + }); +} diff --git a/labs/ptbr-tools/src/json.rs b/labs/ptbr-tools/src/json.rs new file mode 100644 index 0000000..69eaba4 --- /dev/null +++ b/labs/ptbr-tools/src/json.rs @@ -0,0 +1,277 @@ +//! Minimal JSON: a strict parser for the hook input (what Python's `json.load` accepts) and an +//! escaper that writes what `json.dumps(..., ensure_ascii=False)` writes. + +pub enum J { + Null, + Bool(bool), + Num, + Str(String), + Arr, + Obj(Vec<(String, J)>), +} + +impl J { + /// The last value of a key, as `dict` keeps it. + pub fn get(&self, key: &str) -> Option<&J> { + match self { + J::Obj(v) => v.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } +} + +pub fn parse(s: &str) -> Option { + let c: Vec = s.chars().collect(); + let mut i = 0; + let v = value(&c, &mut i)?; + ws(&c, &mut i); + (i == c.len()).then_some(v) +} + +fn ws(c: &[char], i: &mut usize) { + while matches!(c.get(*i), Some(' ' | '\t' | '\n' | '\r')) { + *i += 1; + } +} + +fn lit(c: &[char], i: &mut usize, word: &str) -> bool { + let w: Vec = word.chars().collect(); + if c[*i..].starts_with(&w) { + *i += w.len(); + true + } else { + false + } +} + +fn value(c: &[char], i: &mut usize) -> Option { + ws(c, i); + match *c.get(*i)? { + '"' => string(c, i).map(J::Str), + '{' => { + *i += 1; + let mut o = Vec::new(); + ws(c, i); + if c.get(*i) == Some(&'}') { + *i += 1; + return Some(J::Obj(o)); + } + loop { + ws(c, i); + if c.get(*i) != Some(&'"') { + return None; + } + let k = string(c, i)?; + ws(c, i); + if c.get(*i) != Some(&':') { + return None; + } + *i += 1; + o.push((k, value(c, i)?)); + ws(c, i); + match c.get(*i)? { + ',' => *i += 1, + '}' => { + *i += 1; + return Some(J::Obj(o)); + } + _ => return None, + } + } + } + '[' => { + *i += 1; + ws(c, i); + if c.get(*i) == Some(&']') { + *i += 1; + return Some(J::Arr); + } + loop { + value(c, i)?; + ws(c, i); + match c.get(*i)? { + ',' => *i += 1, + ']' => { + *i += 1; + return Some(J::Arr); + } + _ => return None, + } + } + } + 'n' => lit(c, i, "null").then_some(J::Null), + 't' => lit(c, i, "true").then_some(J::Bool(true)), + 'f' => lit(c, i, "false").then_some(J::Bool(false)), + 'N' => lit(c, i, "NaN").then_some(J::Num), + 'I' => lit(c, i, "Infinity").then_some(J::Num), + '-' | '0'..='9' => { + if lit(c, i, "-Infinity") { + return Some(J::Num); + } + number(c, i).then_some(J::Num) + } + _ => None, + } +} + +fn digits(c: &[char], i: &mut usize) -> bool { + let s = *i; + while c.get(*i).is_some_and(char::is_ascii_digit) { + *i += 1; + } + *i > s +} + +fn number(c: &[char], i: &mut usize) -> bool { + if c.get(*i) == Some(&'-') { + *i += 1; + } + match c.get(*i) { + Some('0') => *i += 1, + Some('1'..='9') => { + digits(c, i); + } + _ => return false, + } + // A bad fraction or exponent ends the number there; the caller then fails on the leftover. + if c.get(*i) == Some(&'.') && c.get(*i + 1).is_some_and(char::is_ascii_digit) { + *i += 1; + digits(c, i); + } + if matches!(c.get(*i), Some('e' | 'E')) { + let mut j = *i + 1; + if matches!(c.get(j), Some('+' | '-')) { + j += 1; + } + if digits(c, &mut j) { + *i = j; + } + } + true +} + +fn hex4(c: &[char], i: usize) -> Option { + let h: String = c.get(i..i + 4)?.iter().collect(); + if h.chars().all(|d| d.is_ascii_hexdigit()) { + u32::from_str_radix(&h, 16).ok() + } else { + None + } +} + +fn string(c: &[char], i: &mut usize) -> Option { + *i += 1; + let mut o = String::new(); + loop { + let ch = *c.get(*i)?; + *i += 1; + match ch { + '"' => return Some(o), + '\\' => { + let e = *c.get(*i)?; + *i += 1; + match e { + '"' | '\\' | '/' => o.push(e), + 'b' => o.push('\x08'), + 'f' => o.push('\x0c'), + 'n' => o.push('\n'), + 'r' => o.push('\r'), + 't' => o.push('\t'), + 'u' => { + let mut u = hex4(c, *i)?; + *i += 4; + if (0xd800..0xdc00).contains(&u) + && c.get(*i) == Some(&'\\') + && c.get(*i + 1) == Some(&'u') + { + if let Some(lo) = + hex4(c, *i + 2).filter(|l| (0xdc00..0xe000).contains(l)) + { + u = 0x10000 + ((u - 0xd800) << 10) + (lo - 0xdc00); + *i += 6; + } + } + // shortcut: a lone surrogate (Python keeps it) becomes U+FFFD. + o.push(char::from_u32(u).unwrap_or('\u{fffd}')); + } + _ => return None, + } + } + c if (c as u32) < 0x20 => return None, + c => o.push(c), + } + } +} + +/// `json.dumps(s, ensure_ascii=False)` for a string. +pub fn quote(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + '\x08' => o.push_str("\\b"), + '\x0c' => o.push_str("\\f"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o.push('"'); + o +} + +/// `json.dumps(list_of_str)`, the default separators. +pub fn list(v: &[String]) -> String { + format!( + "[{}]", + v.iter().map(|s| quote(s)).collect::>().join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_prompt_with_escapes() { + let j = parse(r#"{"a": [1, 2.5e3, null], "prompt": "ptbr \"x\"\nç 😀"}"#).unwrap(); + match j.get("prompt") { + Some(J::Str(s)) => assert_eq!(s, "ptbr \"x\"\nç 😀"), + _ => panic!(), + } + } + + #[test] + fn rejects_what_python_rejects() { + for bad in [ + "", + "{", + "{\"a\":1,}", + "[1,]", + "01", + "{\"a\":\"\n\"}", + "'x'", + "{} x", + "\u{feff}{}", + "-", + "1.", + ] { + assert!(parse(bad).is_none(), "{bad:?}"); + } + for ok in ["NaN", "-Infinity", "1e5", " [] ", "{\"a\":{}}", "-0"] { + assert!(parse(ok).is_some(), "{ok:?}"); + } + } + + #[test] + fn quote_matches_dumps() { + assert_eq!( + quote("a\"b\\c\n\u{1}é\u{7f}"), + "\"a\\\"b\\\\c\\n\\u0001é\u{7f}\"" + ); + } +} diff --git a/labs/ptbr-tools/src/lib.rs b/labs/ptbr-tools/src/lib.rs new file mode 100644 index 0000000..dac60c0 --- /dev/null +++ b/labs/ptbr-tools/src/lib.rs @@ -0,0 +1,8 @@ +//! The three runtime scripts of the ptbr-writer plugin, ported from Python. std only. + +pub mod ask_levels; +pub mod json; +pub mod reading_time; +pub mod route; +pub mod text; +pub mod toml; diff --git a/labs/ptbr-tools/src/reading_time.rs b/labs/ptbr-tools/src/reading_time.rs new file mode 100644 index 0000000..1cd41b8 --- /dev/null +++ b/labs/ptbr-tools/src/reading_time.rs @@ -0,0 +1,679 @@ +//! ptbr-reading-time: an honest, deterministic estimate of how long a person takes to read a text. +//! A 1:1 port of reading_time.py; the regular expressions are written out by hand. +//! +//! ```text +//! ptbr-reading-time [--lang pt-BR|en] [--json] [FILE] (stdin when no FILE) +//! ptbr-reading-time --self-test +//! ``` +//! +//! Formula (seconds, then rounded UP to whole minutes; 60 s or less prints "< 1 min"): +//! `seconds = words * complexity / 238 * 60 + code_lines * 10 + links * 5` +//! 238 words per minute is Brysbaert (2019), silent reading of English non-fiction, DOI +//! 10.1016/j.jml.2019.104047; pt-BR uses the same rate, unscaled (unverified). + +use crate::text::{is_word, os_error, py_space, splitlines, universal_newlines}; +use std::io::Read; + +const WPM: f64 = 238.0; +const SECONDS_PER_CODE_LINE: f64 = 10.0; +const SECONDS_PER_LINK: f64 = 5.0; +const LONG_WORD: usize = 10; + +pub struct Report { + pub minutes: u64, + pub seconds: i64, + pub words: usize, + pub links: usize, + pub code_blocks: usize, + pub code_lines: usize, + pub complexity: f64, + pub mean_sentence_words: f64, + pub long_word_share: f64, +} + +type Chars = Vec; + +fn first(c: &[char], from: usize, x: char) -> Option { + c[from..].iter().position(|&y| y == x).map(|p| p + from) +} + +/// `re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)` +fn front_matter(t: &str) -> &str { + match t + .strip_prefix("---\n") + .and_then(|r| r.find("\n---\n").map(|p| &r[p + 5..])) + { + Some(rest) => rest, + None => t, + } +} + +/// `LINE.sub("", text)`: the reading-time line itself, one per line start. +fn drop_line(t: &str) -> String { + t.split('\n') + .map(|l| { + if ["Leitura estimada:", "Estimated reading time:"] + .iter() + .any(|p| l.starts_with(p)) + { + "" + } else { + l + } + }) + .collect::>() + .join("\n") +} + +/// Fenced blocks: `^ {0,3}(```|~~~).*?^ {0,3}\1[ \t]*$` (multiline, dotall). Returns the text +/// without the blocks and the blocks. +fn fences(t: &str) -> (String, Vec) { + fn marker(l: &str) -> Option<&'static str> { + let r = l.trim_start_matches(' '); + if l.len() - r.len() > 3 { + return None; + } + if r.starts_with("```") { + Some("```") + } else if r.starts_with("~~~") { + Some("~~~") + } else { + None + } + } + fn closer(l: &str, m: &str) -> bool { + let r = l.trim_start_matches(' '); + l.len() - r.len() <= 3 + && r.strip_prefix(m) + .is_some_and(|x| x.chars().all(|c| c == ' ' || c == '\t')) + } + let lines: Vec<&str> = t.split('\n').collect(); + let (mut out, mut blocks, mut i) = (Vec::new(), Vec::new(), 0); + while i < lines.len() { + let end = + marker(lines[i]).and_then(|m| (i + 1..lines.len()).find(|&j| closer(lines[j], m))); + if let Some(j) = end { + blocks.push(lines[i..=j].join("\n")); + // the match leaves the line break that follows it: an empty piece in place of the block + out.push(""); + i = j + 1; + } else { + out.push(lines[i]); + i += 1; + } + } + (out.join("\n"), blocks) +} + +/// `(?]+>`: the number of non-overlapping matches. +fn count_links(c: &[char]) -> usize { + let (mut n, mut i) = (0, 0); + while i < c.len() { + let mut end = None; + if c[i] == '[' && (i == 0 || c[i - 1] != '!') { + if let Some(j) = first(c, i + 1, ']').filter(|&j| c.get(j + 1) == Some(&'(')) { + if let Some(m) = first(c, j + 2, ')') { + if m > j + 2 && !py_space(c[j + 2]) { + end = Some(m + 1); + } + } + } + } else if c[i] == '<' { + end = angle_url(c, i); + } + match end { + Some(e) => { + n += 1; + i = e; + } + None => i += 1, + } + } + n +} + +/// `]+>` at i: the end of the match. +fn angle_url(c: &[char], i: usize) -> Option { + let rest = &c[i + 1..]; + let skip = if rest.starts_with(&['h', 't', 't', 'p', 's', ':', '/', '/']) { + 8 + } else if rest.starts_with(&['h', 't', 't', 'p', ':', '/', '/']) { + 7 + } else { + return None; + }; + let body = i + 1 + skip; + first(c, body, '>').filter(|&g| g > body).map(|g| g + 1) +} + +/// `re.sub(r"]+>", "", text)` +fn drop_angle_urls(c: &[char]) -> Chars { + let (mut o, mut i) = (Vec::new(), 0); + while i < c.len() { + if c[i] == '<' { + if let Some(e) = angle_url(c, i) { + i = e; + continue; + } + } + o.push(c[i]); + i += 1; + } + o +} + +/// `re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", t)` (image) and `re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)` +/// (link: keep its text). +fn markdown_links(c: &[char], image: bool) -> Chars { + let (mut o, mut i) = (Vec::new(), 0); + while i < c.len() { + let at = if image { + c[i] == '!' && c.get(i + 1) == Some(&'[') + } else { + c[i] == '[' + }; + if at { + let open = i + if image { 2 } else { 1 }; + if let Some(j) = first(c, open, ']').filter(|&j| c.get(j + 1) == Some(&'(')) { + if let Some(m) = first(c, j + 2, ')') { + if image { + o.push(' '); + } else { + o.extend_from_slice(&c[open..j]); + } + i = m + 1; + continue; + } + } + } + o.push(c[i]); + i += 1; + } + o +} + +/// `URL.sub(" ", t)` with `https?://[^\s)>\]]+|www\.[^\s)>\]]+`; returns the text and the count. +fn urls(c: &[char]) -> (Chars, usize) { + let (mut o, mut n, mut i) = (Vec::new(), 0, 0); + let stop = |x: char| py_space(x) || x == ')' || x == '>' || x == ']'; + while i < c.len() { + let rest = &c[i..]; + let skip = if rest.starts_with(&['h', 't', 't', 'p', 's', ':', '/', '/']) { + 8 + } else if rest.starts_with(&['h', 't', 't', 'p', ':', '/', '/']) { + 7 + } else if rest.starts_with(&['w', 'w', 'w', '.']) { + 4 + } else { + 0 + }; + if skip > 0 && rest.get(skip).is_some_and(|&x| !stop(x)) { + let mut j = i + skip; + while j < c.len() && !stop(c[j]) { + j += 1; + } + o.push(' '); + n += 1; + i = j; + } else { + o.push(c[i]); + i += 1; + } + } + (o, n) +} + +fn is_class(x: char) -> bool { + matches!(x, '-' | ':' | '|' | ' ') +} + +/// One match of `^\s*(\|?[-:| ]+\|?)\s*$` (multiline) at the line start p, with the engine's +/// backtracking order; returns where it ends. +fn rule_at(t: &[char], p: usize) -> Option { + let n = t.len(); + let mut q = p; + while q < n && py_space(t[q]) { + q += 1; + } + for s1 in (p..=q).rev() { + for lead in [true, false] { + let mut g = s1; + if lead { + if t.get(g) == Some(&'|') { + g += 1; + } else { + continue; + } + } + let mut r = g; + while r < n && is_class(t[r]) { + r += 1; + } + for c_end in (g + 1..=r).rev() { + for trail in [true, false] { + let mut h = c_end; + if trail { + if t.get(h) == Some(&'|') { + h += 1; + } else { + continue; + } + } + let mut z = h; + while z < n && py_space(t[z]) { + z += 1; + } + if let Some(e) = (h..=z).rev().find(|&e| e == n || t[e] == '\n') { + return Some(e); + } + } + } + } + } + None +} + +fn drop_rules(t: &[char]) -> Chars { + let (mut o, mut i) = (Vec::new(), 0); + while i < t.len() { + if i == 0 || t[i - 1] == '\n' { + if let Some(e) = rule_at(t, i) { + o.push(' '); + i = e; + continue; + } + } + o.push(t[i]); + i += 1; + } + o +} + +/// `WORD.findall`: `[^\W_][\w'’-]*` +fn words(t: &[char]) -> Vec<&[char]> { + let (mut v, mut i) = (Vec::new(), 0); + while i < t.len() { + if t[i].is_alphanumeric() { + let st = i; + i += 1; + while i < t.len() && (is_word(t[i]) || matches!(t[i], '\'' | '’' | '-')) { + i += 1; + } + v.push(&t[st..i]); + } else { + i += 1; + } + } + v +} + +/// The end of a match of `[.!?]+(?:\s|$)|\n\s*\n|\n\s*(?:[-*]|\d+\.)\s` at i. +fn sentence_break(t: &[char], i: usize) -> Option { + let n = t.len(); + if matches!(t[i], '.' | '!' | '?') { + let mut j = i; + while j < n && matches!(t[j], '.' | '!' | '?') { + j += 1; + } + if j == n { + return Some(j); + } + if py_space(t[j]) { + return Some(j + 1); + } + } + if t[i] == '\n' { + let mut q = i + 1; + while q < n && py_space(t[q]) { + q += 1; + } + if let Some(k) = (i + 1..q).rev().find(|&k| t[k] == '\n') { + return Some(k + 1); + } + let mut k = q; + if k < n && matches!(t[k], '-' | '*') { + k += 1; + } else { + let d = k; + while k < n && t[k].is_ascii_digit() { + k += 1; + } + if k == d || t.get(k) != Some(&'.') { + return None; + } + k += 1; + } + if k < n && py_space(t[k]) { + return Some(k + 1); + } + } + None +} + +fn sentences(t: &[char]) -> usize { + let (mut count, mut last, mut i) = (0, 0, 0); + let has_word = |p: &[char]| p.iter().any(|c| c.is_alphanumeric()); + while i < t.len() { + match sentence_break(t, i) { + Some(e) if e > i => { + count += has_word(&t[last..i]) as usize; + last = e; + i = e; + } + _ => i += 1, + } + } + count + has_word(&t[last..]) as usize +} + +/// Python's `round(x, nd)` for a float, printed back as `repr` does. +fn round_to(x: f64, nd: usize) -> f64 { + format!("{x:.nd$}").parse().unwrap_or(x) +} + +pub fn analyse(text: &str) -> Report { + let text = text.replace("\r\n", "\n"); + let text = drop_line(front_matter(&text)); + let (text, blocks) = fences(&text); + let code_lines = blocks + .iter() + .map(|b| { + let l = splitlines(b); + l.get(1..l.len().saturating_sub(1)) + .unwrap_or(&[]) + .iter() + .filter(|x| !x.trim_matches(py_space).is_empty()) + .count() + }) + .sum::(); + let t: Chars = text.chars().collect(); + let mut links = count_links(&t); + let t = markdown_links(&markdown_links(&t, true), false); + links += urls(&drop_angle_urls(&t)).1; + let (t, _) = urls(&t); + let t = drop_rules(&t); + let w = words(&t); + let sent = sentences(&t); + let mean_len = if sent > 0 { + w.len() as f64 / sent as f64 + } else { + 0.0 + }; + let long_share = if w.is_empty() { + 0.0 + } else { + w.iter() + .filter(|x| x.iter().filter(|c| c.is_alphabetic()).count() >= LONG_WORD) + .count() as f64 + / w.len() as f64 + }; + let complexity = + 1.0 + if mean_len > 20.0 { 0.15 } else { 0.0 } + if long_share > 0.25 { 0.15 } else { 0.0 }; + let seconds = w.len() as f64 * complexity / WPM * 60.0 + + code_lines as f64 * SECONDS_PER_CODE_LINE + + links as f64 * SECONDS_PER_LINK; + Report { + minutes: if seconds <= 60.0 { + 0 + } else { + (seconds / 60.0).ceil() as u64 + }, + seconds: seconds.round_ties_even() as i64, + words: w.len(), + links, + code_blocks: blocks.len(), + code_lines, + complexity, + mean_sentence_words: round_to(mean_len, 1), + long_word_share: round_to(long_share, 2), + } +} + +/// The words of the line, per language: label, "< 1 min", word(s), link(s), code block(s). +fn labels(lang: &str) -> (&'static str, [&'static str; 6]) { + if lang == "en" { + ( + "Estimated reading time", + [ + "word", + "words", + "link", + "links", + "code block", + "code blocks", + ], + ) + } else { + ( + "Leitura estimada", + [ + "palavra", + "palavras", + "link", + "links", + "bloco de código", + "blocos de código", + ], + ) + } +} + +pub fn line(r: &Report, lang: &str) -> String { + let (label, [w1, wn, l1, ln, b1, bn]) = labels(lang); + let n = |k: usize, one: &str, many: &str| format!("{k} {}", if k == 1 { one } else { many }); + let mut parts = vec![n(r.words, w1, wn), n(r.links, l1, ln)]; + if r.code_blocks > 0 { + parts.push(n(r.code_blocks, b1, bn)); + } + let t = if r.minutes > 0 { + format!("~{} min", r.minutes) + } else { + "< 1 min".to_string() + }; + format!("{label}: {t} ({})", parts.join(", ")) +} + +fn json(r: &Report, lang: &str) -> String { + format!( + "{{\"minutes\": {}, \"seconds\": {}, \"words\": {}, \"links\": {}, \"code_blocks\": {}, \"code_lines\": {}, \ + \"complexity\": {:?}, \"mean_sentence_words\": {}, \"long_word_share\": {}, \"line\": {}}}", + r.minutes, + r.seconds, + r.words, + r.links, + r.code_blocks, + r.code_lines, + r.complexity, + // Python's `round(0, n)` of the int 0 (no words) prints `0`, not `0.0` + if r.words == 0 { "0".to_string() } else { format!("{:?}", r.mean_sentence_words) }, + if r.words == 0 { "0".to_string() } else { format!("{:?}", r.long_word_share) }, + crate::json::quote(&line(r, lang)) + ) +} + +/// The self-test cases of reading_time.py: (name, ok, detail). +pub fn self_test_cases() -> Vec<(String, bool, String)> { + let long = ("responsabilidades ".repeat(99) + "agora. ").repeat(10); + let cases: Vec<(&str, String, &str, &str)> = vec![ + ( + "empty", + String::new(), + "pt-BR", + "Leitura estimada: < 1 min (0 palavras, 0 links)", + ), + ( + "short", + "Reinicie o serviço.".into(), + "pt-BR", + "Leitura estimada: < 1 min (3 palavras, 0 links)", + ), + ( + "short en", + "Restart the service.".into(), + "en", + "Estimated reading time: < 1 min (3 words, 0 links)", + ), + ( + "long: 2400 words = 10.1 min -> 11", + "Reinicie o serviço agora. ".repeat(600), + "pt-BR", + "Leitura estimada: ~11 min (2400 palavras, 0 links)", + ), + ( + "links cost 5 s, url and target not read", + "Veja [a doc](https://example.com/muito/longa) e https://x.org agora. ".to_string() + + &"a ".repeat(300), + "pt-BR", + "Leitura estimada: ~2 min (305 palavras, 2 links)", + ), + ( + "code: 29 lines = 290 s -> 5 min", + "Rode:\n\n```bash\n".to_string() + &"make test\n".repeat(29) + "```\n", + "pt-BR", + "Leitura estimada: ~5 min (1 palavra, 0 links, 1 bloco de código)", + ), + ( + "code blocks (en)", + "Run:\n\n```\na\n```\n\n```\nb\n```\n".into(), + "en", + "Estimated reading time: < 1 min (1 word, 0 links, 2 code blocks)", + ), + ( + "inline code is words; the line itself is ignored", + "Leitura estimada: ~9 min (1 palavra, 0 links)\n\nUse `git commit` hoje.".into(), + "pt-BR", + "Leitura estimada: < 1 min (4 palavras, 0 links)", + ), + ( + "long sentences and long words: x1.3, 6 min instead of 5", + long, + "pt-BR", + "Leitura estimada: ~6 min (1000 palavras, 0 links)", + ), + ]; + let mut out: Vec<_> = cases + .into_iter() + .map(|(name, text, lang, want)| { + let got = line(&analyse(&text), lang); + ( + name.to_string(), + got == want, + format!("got {got:?}, want {want:?}: "), + ) + }) + .collect(); + let r = analyse("a [b](http://x.y) c"); + out.push(( + "json parts".into(), + r.links == 1 && r.words == 3, + String::new(), + )); + out +} + +fn self_test() -> i32 { + let mut bad = 0; + for (name, ok, detail) in self_test_cases() { + bad += !ok as i32; + println!( + "{}{name}", + if ok { + " ok ".to_string() + } else { + format!(" FAIL {detail}") + } + ); + } + println!( + "reading-time self-test: {}", + if bad == 0 { + "ok".to_string() + } else { + format!("{bad} failure(s)") + } + ); + (bad > 0) as i32 +} + +const USAGE: &str = " ptbr-reading-time [--lang pt-BR|en] [--json] [FILE] (stdin when no FILE)\n ptbr-reading-time --self-test"; + +pub fn main(argv: Vec) -> i32 { + let mut args: Vec = argv.into_iter().skip(1).collect(); + if args == ["--self-test"] { + return self_test(); + } + let (mut lang, mut as_json, mut path) = ("pt-BR".to_string(), false, None); + args.reverse(); + while let Some(a) = args.pop() { + if a == "--json" { + as_json = true; + } else if a == "--lang" && args.last().is_some_and(|l| l == "pt-BR" || l == "en") { + lang = args.pop().unwrap_or_default(); + } else if (a == "-" || !a.starts_with('-')) && path.is_none() { + path = Some(a); + } else { + eprintln!("{USAGE}"); + return 2; + } + } + // A file is read in Python's text mode (universal newlines); stdin on POSIX is not translated. + let text = match path.as_deref() { + Some(p) if p != "-" => std::fs::read(p) + .map(|b| (b, true)) + .map_err(|e| os_error(&e, p)), + _ => { + let mut b = Vec::new(); + std::io::stdin() + .read_to_end(&mut b) + .map(|_| (b, false)) + .map_err(|e| e.to_string()) + } + }; + let text = match text.map(|(b, file)| { + String::from_utf8(b).map(|t| if file { universal_newlines(&t) } else { t }) + }) { + Ok(Ok(t)) => t, + Ok(Err(e)) => { + eprintln!("reading_time: {e}"); + return 1; + } + Err(e) => { + eprintln!("reading_time: {e}"); + return 1; + } + }; + let r = analyse(&text); + println!( + "{}", + if as_json { + json(&r, &lang) + } else { + line(&r, &lang) + } + ); + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_test_cases_pass() { + for (name, ok, detail) in self_test_cases() { + assert!(ok, "{detail}{name}"); + } + } + + #[test] + fn front_matter_and_rules_are_not_words() { + let r = analyse("---\ntitle: x y z\n---\nUm dois.\n\n|---|---|\n\n---\n\ntrês"); + assert_eq!(r.words, 3); + } + + #[test] + fn tilde_fences_and_unclosed_fences() { + assert_eq!(analyse("a\n~~~\nx\ny\n~~~\nb").code_lines, 2); + assert_eq!(analyse("a\n```\nx\nb").code_blocks, 0); + } +} diff --git a/labs/ptbr-tools/src/route.rs b/labs/ptbr-tools/src/route.rs new file mode 100644 index 0000000..077f2e4 --- /dev/null +++ b/labs/ptbr-tools/src/route.rs @@ -0,0 +1,286 @@ +//! ptbr-route: pick the skill and the level for a writing request, before any skill is loaded. +//! +//! ```text +//! ptbr-route "" one line: ` · `, from routes.tsv beside the skill +//! ptbr-route --check routes.tsv against SKILL.md: one row per level, every skill exists +//! ptbr-route --self-test the answers for a few known requests +//! ``` +//! +//! The binary sits in `/scripts/`, as route.py did: routes.tsv is in `/`, SKILL.md +//! two directories above that (the plugin root). + +use crate::text::{fold, has_word, os_error, read_text, splitlines}; +use std::path::PathBuf; + +const COMPOSES: &str = "smart-note"; + +pub type Row = (String, String, Vec); + +pub fn routes(tsv: &str) -> Result, String> { + let mut rows = Vec::new(); + for line in splitlines(tsv).into_iter().skip(1) { + if line.trim_matches(crate::text::py_space).is_empty() { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + let [skill, level, words] = f[..] else { + return Err(format!( + "routes.tsv: a row needs 3 tab-separated fields: {line:?}" + )); + }; + let words = words + .split(',') + .map(|w| w.trim_matches(crate::text::py_space)) + .filter(|w| !w.is_empty()) + .map(fold) + .collect(); + rows.push((skill.to_string(), level.to_string(), words)); + } + Ok(rows) +} + +fn score(request: &str, words: &[String]) -> usize { + let text = fold(request); + words.iter().filter(|w| has_word(&text, w)).count() +} + +pub fn route(request: &str, rows: &[Row]) -> String { + let scored: Vec<(usize, &str, &str)> = rows + .iter() + .map(|(s, lv, w)| (score(request, w), s.as_str(), lv.as_str())) + .collect(); + let note = scored.iter().any(|&(n, _, lv)| n > 0 && lv == COMPOSES); + let rest: Vec<_> = scored + .iter() + .filter(|&&(n, _, lv)| lv != COMPOSES && n > 0) + .collect(); + let best = rest.iter().map(|x| x.0).max().unwrap_or(0); + let top: Vec<_> = rest.iter().filter(|x| x.0 == best).collect(); + if top.is_empty() { + return if note { + "ptbr-writer · smart-note" + } else { + "ptbr-writer · sem nível: a skill classifica e declara" + } + .to_string(); + } + let pre = if note { + format!("{COMPOSES} + ") + } else { + String::new() + }; + let line = top + .iter() + .map(|x| format!("{} · {pre}{}", x.1, x.2)) + .collect::>() + .join(" | "); + line + if top.len() > 1 { + " (empate: escolha um e declare)" + } else { + "" + } +} + +/// `re.findall(r"^- `([^`]+)`:", section, re.M)` over the "## Níveis de rigor" section. +pub fn levels(skill_md: &str) -> Vec { + let section = skill_md + .split_once("## Níveis de rigor") + .map_or(skill_md, |x| x.1); + let section = section.split("\n## ").next().unwrap_or(section); + section + .split('\n') + .filter_map(|l| { + let r = l.strip_prefix("- `")?; + let (name, after) = r.split_once('`')?; + (!name.is_empty() && after.starts_with(':')).then(|| name.to_string()) + }) + .collect() +} + +pub fn check_with(rows: &[Row], skill_md: &str, skills: &[String]) -> Vec { + let have: Vec<&str> = rows.iter().map(|r| r.1.as_str()).collect(); + let want = levels(skill_md); + let mut errors: Vec = want + .iter() + .filter(|lv| !have.contains(&lv.as_str())) + .map(|lv| format!("routes.tsv: no row for level {lv}")) + .collect(); + errors.extend( + have.iter() + .filter(|lv| !want.iter().any(|w| w == **lv)) + .map(|lv| format!("routes.tsv: {lv} is not a level in SKILL.md")), + ); + let mut seen: Vec<&str> = Vec::new(); + for lv in &have { + let n = have.iter().filter(|x| *x == lv).count(); + if n > 1 && !seen.contains(lv) { + errors.push(format!("routes.tsv: {lv} appears {n} times")); + seen.push(lv); + } + } + errors.extend( + rows.iter() + .filter(|r| !skills.contains(&r.0)) + .map(|r| format!("routes.tsv: no skill {}", r.0)), + ); + errors +} + +fn here() -> Result<(PathBuf, PathBuf), String> { + // argv[0] when it is a path (a symlink to the binary keeps its place, as route.py's + // `__file__` did not resolve at the script level), else where the binary is. + let exe = match std::env::args().next().filter(|a| a.contains('/')) { + Some(a) => std::env::current_dir().map_err(|e| e.to_string())?.join(a), + None => std::env::current_exe().map_err(|e| e.to_string())?, + }; + let here = exe + .parent() + .and_then(|p| p.parent()) + .ok_or("cannot find the skill directory")? + .to_path_buf(); + let root = here + .parent() + .and_then(|p| p.parent()) + .ok_or("cannot find the plugin root")? + .to_path_buf(); + Ok((here, root)) +} + +fn read(p: &std::path::Path) -> Result { + let p = p.to_string_lossy(); + read_text(&p).map_err(|e| os_error(&e, &p)) +} + +fn check() -> Result, String> { + let (here, root) = here()?; + let rows = routes(&read(&here.join("routes.tsv"))?)?; + let mut skills = vec!["ptbr-writer".to_string()]; + if let Ok(dir) = std::fs::read_dir(root.join("skills")) { + let mut found: Vec = dir + .flatten() + .filter(|d| d.path().join("SKILL.md").is_file()) + .map(|d| d.file_name().to_string_lossy().into_owned()) + .collect(); + found.sort(); + skills.extend(found); + } + Ok(check_with(&rows, &read(&root.join("SKILL.md"))?, &skills)) +} + +/// The assertions of route.py's self_test; the first failure is returned. +pub fn self_test_with(rows: &[Row]) -> Result<(), String> { + let eq = |got: String, want: &str| { + if got == want { + Ok(()) + } else { + Err(format!("got {got:?}, want {want:?}")) + } + }; + eq( + route("reescreva este runbook de incidente", rows), + "ptbr-writer · estrito", + )?; + eq( + route("revise o RELATORIO e as citações", rows), + "cientifico · científico", + )?; + eq( + route("transforme em notas do Obsidian este comunicado", rows), + "ptbr-writer · smart-note + leve", + )?; + eq( + route("guarde no meu segundo cérebro", rows), + "ptbr-writer · smart-note", + )?; + let r = route("melhore isto", rows); + if !r.starts_with("ptbr-writer · sem nível") { + return Err(r); + } + let r = route("o comunicado sobre o runbook", rows); + if !r.ends_with("(empate: escolha um e declare)") { + return Err(r); + } + let r = route("um postmortem", rows); + if !r.starts_with("ptbr-writer · sem nível") { + return Err(format!("\"post\" is a word, not a prefix: {r}")); + } + if levels("## Níveis de rigor\n\n- `a`: x.\n- `b`: y.\n\n## Regras\n- `c`: z.\n") != ["a", "b"] + { + return Err("levels".into()); + } + Ok(()) +} + +const USAGE: &str = + "ptbr-route \"\" one line: ` · `, from routes.tsv beside the skill\n\ +ptbr-route --check routes.tsv against SKILL.md: one row per level, every skill exists\n\ +ptbr-route --self-test the answers for a few known requests"; + +fn run(argv: &[String]) -> Result { + if argv == ["--self-test"] { + self_test_with(&routes(&read(&here()?.0.join("routes.tsv"))?)?)?; + println!("route self-test: ok"); + return Ok(0); + } + if argv == ["--check"] { + let errors = check()?; + if errors.is_empty() { + println!("route check: ok"); + return Ok(0); + } + eprintln!("{}", errors.join("\n")); + return Ok(1); + } + if argv.is_empty() || argv[0].starts_with('-') { + eprintln!("{USAGE}"); + return Ok(2); + } + println!( + "{}", + route( + &argv.join(" "), + &routes(&read(&here()?.0.join("routes.tsv"))?)? + ) + ); + Ok(0) +} + +pub fn main(argv: Vec) -> i32 { + run(&argv).unwrap_or_else(|e| { + eprintln!("ptbr-route: {e}"); + 1 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec { + routes(include_str!("../tests/fixtures/routes.tsv")).unwrap() + } + + #[test] + fn self_test() { + self_test_with(&rows()).unwrap(); + } + + #[test] + fn check_finds_drift() { + let skills = vec!["ptbr-writer".to_string(), "cientifico".to_string()]; + let md = "## Níveis de rigor\n\n- `estrito`: a.\n- `descritivo`: b.\n- `leve`: c.\n- `científico`: d.\n- `smart-note`: e.\n"; + assert!(check_with(&rows(), md, &skills).is_empty()); + let md2 = md.replace("- `leve`: c.\n", "- `novo`: c.\n"); + assert_eq!( + check_with(&rows(), &md2, &skills), + [ + "routes.tsv: no row for level novo", + "routes.tsv: leve is not a level in SKILL.md" + ] + ); + assert_eq!( + check_with(&rows(), md, &skills[..1]), + ["routes.tsv: no skill cientifico"] + ); + } +} diff --git a/labs/ptbr-tools/src/text.rs b/labs/ptbr-tools/src/text.rs new file mode 100644 index 0000000..0d1be1e --- /dev/null +++ b/labs/ptbr-tools/src/text.rs @@ -0,0 +1,139 @@ +//! Helpers that mimic the parts of Python's `str` and `re` that the scripts rely on. + +/// Python's `\s` and `str.isspace`: Unicode White_Space plus U+001C..U+001F. +pub fn py_space(c: char) -> bool { + c.is_whitespace() || ('\x1c'..='\x1f').contains(&c) +} + +/// Python's `\w`. +pub fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// Lower case, then strip the accents of Portuguese (and a few neighbours). +/// shortcut: a table of precomposed letters plus a drop of U+0300..U+036F, not a full NFD; +/// accents of other scripts stay (Python's NFD would strip them), upgrade if a level or a route +/// word is ever not Latin-1. +pub fn fold(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| !('\u{300}'..='\u{36f}').contains(c)) + .map(|c| match c { + 'á' | 'à' | 'â' | 'ã' | 'ä' | 'å' => 'a', + 'é' | 'è' | 'ê' | 'ë' => 'e', + 'í' | 'ì' | 'î' | 'ï' => 'i', + 'ó' | 'ò' | 'ô' | 'õ' | 'ö' => 'o', + 'ú' | 'ù' | 'û' | 'ü' => 'u', + 'ç' => 'c', + 'ñ' => 'n', + 'ý' | 'ÿ' => 'y', + c => c, + }) + .collect() +} + +/// `re.search(rf"(? bool { + let h: Vec = hay.chars().collect(); + let n: Vec = needle.chars().collect(); + if n.is_empty() || n.len() > h.len() { + return false; + } + (0..=h.len() - n.len()).any(|i| { + h[i..i + n.len()] == n[..] + && (i == 0 || !is_word(h[i - 1])) + && h.get(i + n.len()).is_none_or(|&c| !is_word(c)) + }) +} + +/// Python's `repr(str)`. +pub fn py_repr(s: &str) -> String { + let q = if s.contains('\'') && !s.contains('"') { + '"' + } else { + '\'' + }; + let mut o = String::from(q); + for c in s.chars() { + match c { + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if c == q => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 0x20 || (0x7f..=0xa0).contains(&(c as u32)) => { + o.push_str(&format!("\\x{:02x}", c as u32)) + } + c => o.push(c), + } + } + o.push(q); + o +} + +/// Python's `os.path.join` for two parts. +pub fn join(a: &str, b: &str) -> String { + if b.starts_with('/') { + b.to_string() + } else if a.is_empty() || a.ends_with('/') { + format!("{a}{b}") + } else { + format!("{a}/{b}") + } +} + +/// Python's `str.splitlines`. +pub fn splitlines(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut it = s.char_indices().peekable(); + while let Some((i, c)) = it.next() { + if matches!( + c, + '\n' | '\r' + | '\x0b' + | '\x0c' + | '\x1c' + | '\x1d' + | '\x1e' + | '\u{85}' + | '\u{2028}' + | '\u{2029}' + ) { + out.push(&s[start..i]); + let mut end = i + c.len_utf8(); + if c == '\r' && it.peek().is_some_and(|&(_, n)| n == '\n') { + it.next(); + end += 1; + } + start = end; + } + } + if start < s.len() { + out.push(&s[start..]); + } + out +} + +/// Python's `open(path, encoding="utf-8").read()`: universal newlines. +pub fn read_text(path: &str) -> std::io::Result { + let s = std::fs::read_to_string(path)?; + Ok(universal_newlines(&s)) +} + +pub fn universal_newlines(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n") +} + +/// `OSError` as Python prints it: `[Errno 2] No such file or directory: 'x'`. +pub fn os_error(e: &std::io::Error, path: &str) -> String { + let msg = e.to_string(); + let msg = msg.split(" (os error").next().unwrap_or(&msg); + match e.raw_os_error() { + Some(n) => format!("[Errno {n}] {msg}: {}", py_repr(path)), + None => msg.to_string(), + } +} diff --git a/labs/ptbr-tools/src/toml.rs b/labs/ptbr-tools/src/toml.rs new file mode 100644 index 0000000..9f8253a --- /dev/null +++ b/labs/ptbr-tools/src/toml.rs @@ -0,0 +1,337 @@ +//! A minimal TOML reader for the one flat shape of ptbr-writer.toml: `key = value` lines +//! (string, integer, boolean, array of those), comments, and `[table]` headers whose contents +//! the plugin ignores. Anything else is an error, which the caller reports as "cannot read". +//! shortcut: floats and inline tables are skipped (kept as text); no dates, multi-line strings, +//! dotted keys or `[[arrays]]`; +//! Python's tomllib reads those, so such a file is "unreadable" here. Upgrade if the shape grows. + +/// The file is not in the supported shape. +#[derive(Debug)] +pub struct Error; + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Str(String), + Int(i64), + Bool(bool), + Arr(Vec), + Table, + /// A float or an inline table: read, kept as written, never used. + Other(String), +} + +#[derive(Default)] +pub struct Doc { + /// Top-level keys and table names, in file order. + pub top: Vec<(String, Value)>, +} + +impl Doc { + pub fn get(&self, key: &str) -> Option<&Value> { + self.top.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } +} + +pub fn parse(src: &str) -> Result { + let c: Vec = src.chars().collect(); + let mut i = 0; + let mut doc = Doc::default(); + let mut in_table = false; + loop { + blanks(&c, &mut i); + match c.get(i) { + None => return Ok(doc), + Some('\n') => i += 1, + Some('\r') if c.get(i + 1) == Some(&'\n') => i += 2, + Some('#') => comment(&c, &mut i), + Some('[') => { + i += 1; + if c.get(i) == Some(&'[') { + return Err(Error); + } + blanks(&c, &mut i); + let name = key(&c, &mut i)?; + blanks(&c, &mut i); + if c.get(i) != Some(&']') { + return Err(Error); + } + i += 1; + eol(&c, &mut i)?; + if doc.get(&name).is_some() { + return Err(Error); + } + doc.top.push((name, Value::Table)); + in_table = true; + } + Some(_) => { + let k = key(&c, &mut i)?; + blanks(&c, &mut i); + if c.get(i) != Some(&'=') { + return Err(Error); + } + i += 1; + blanks(&c, &mut i); + let v = value(&c, &mut i)?; + eol(&c, &mut i)?; + if !in_table { + if doc.get(&k).is_some() { + return Err(Error); + } + doc.top.push((k, v)); + } + } + } + } +} + +fn blanks(c: &[char], i: &mut usize) { + while matches!(c.get(*i), Some(' ' | '\t')) { + *i += 1; + } +} + +fn comment(c: &[char], i: &mut usize) { + while c.get(*i).is_some_and(|&x| x != '\n') { + *i += 1; + } +} + +/// After a value or a header: spaces, an optional comment, then a newline or the end. +fn eol(c: &[char], i: &mut usize) -> Result<(), Error> { + blanks(c, i); + if c.get(*i) == Some(&'#') { + comment(c, i); + } + match c.get(*i) { + None => Ok(()), + Some('\n') => { + *i += 1; + Ok(()) + } + Some('\r') if c.get(*i + 1) == Some(&'\n') => { + *i += 2; + Ok(()) + } + _ => Err(Error), + } +} + +fn key(c: &[char], i: &mut usize) -> Result { + match c.get(*i) { + Some('"') => basic(c, i), + Some('\'') => literal(c, i), + _ => { + let s = *i; + while c + .get(*i) + .is_some_and(|x| x.is_ascii_alphanumeric() || *x == '_' || *x == '-') + { + *i += 1; + } + if *i == s { + Err(Error) + } else { + Ok(c[s..*i].iter().collect()) + } + } + } +} + +fn basic(c: &[char], i: &mut usize) -> Result { + if c[*i..].starts_with(&['"', '"', '"']) { + return Err(Error); + } + *i += 1; + let mut o = String::new(); + loop { + let ch = *c.get(*i).ok_or(Error)?; + *i += 1; + match ch { + '"' => return Ok(o), + '\\' => { + let e = *c.get(*i).ok_or(Error)?; + *i += 1; + match e { + 'b' => o.push('\x08'), + 't' => o.push('\t'), + 'n' => o.push('\n'), + 'f' => o.push('\x0c'), + 'r' => o.push('\r'), + '"' | '\\' => o.push(e), + 'u' | 'U' => { + let n = if e == 'u' { 4 } else { 8 }; + let h: String = c.get(*i..*i + n).ok_or(Error)?.iter().collect(); + if !h.chars().all(|d| d.is_ascii_hexdigit()) { + return Err(Error); + } + o.push( + char::from_u32(u32::from_str_radix(&h, 16).map_err(|_| Error)?) + .ok_or(Error)?, + ); + *i += n; + } + _ => return Err(Error), + } + } + c if (c as u32) < 0x20 && c != '\t' || c == '\x7f' => return Err(Error), + c => o.push(c), + } + } +} + +fn literal(c: &[char], i: &mut usize) -> Result { + if c[*i..].starts_with(&['\'', '\'', '\'']) { + return Err(Error); + } + *i += 1; + let s = *i; + loop { + let ch = *c.get(*i).ok_or(Error)?; + if ch == '\'' { + let o = c[s..*i].iter().collect(); + *i += 1; + return Ok(o); + } + if (ch as u32) < 0x20 && ch != '\t' || ch == '\x7f' { + return Err(Error); + } + *i += 1; + } +} + +fn value(c: &[char], i: &mut usize) -> Result { + match c.get(*i).ok_or(Error)? { + '"' => basic(c, i).map(Value::Str), + '\'' => literal(c, i).map(Value::Str), + '[' => { + *i += 1; + let mut v = Vec::new(); + loop { + gap(c, i); + if c.get(*i) == Some(&']') { + *i += 1; + return Ok(Value::Arr(v)); + } + v.push(value(c, i)?); + gap(c, i); + match c.get(*i) { + Some(',') => *i += 1, + Some(']') => {} + _ => return Err(Error), + } + } + } + 't' if c[*i..].starts_with(&['t', 'r', 'u', 'e']) => { + *i += 4; + Ok(Value::Bool(true)) + } + 'f' if c[*i..].starts_with(&['f', 'a', 'l', 's', 'e']) => { + *i += 5; + Ok(Value::Bool(false)) + } + '+' | '-' | '0'..='9' => { + let s = *i; + if matches!(c[*i], '+' | '-') { + *i += 1; + } + while c.get(*i).is_some_and(|x| x.is_ascii_digit() || *x == '_') { + *i += 1; + } + let t: String = c[s..*i].iter().collect(); + let d = t.trim_start_matches(['+', '-']); + let ok = !d.is_empty() + && !d.starts_with('_') + && !d.ends_with('_') + && !d.contains("__") + && (d == "0" || !d.starts_with('0')); + if ok && matches!(c.get(*i), Some('.' | 'e' | 'E')) { + while c.get(*i).is_some_and(|x| { + x.is_ascii_digit() || matches!(x, '_' | '.' | 'e' | 'E' | '+' | '-') + }) { + *i += 1; + } + return Ok(Value::Other(c[s..*i].iter().collect())); + } + if !ok || matches!(c.get(*i), Some('x' | 'o' | 'b' | ':' | '-')) { + return Err(Error); + } + t.replace('_', "") + .parse() + .map(Value::Int) + .map_err(|_| Error) + } + '{' => { + let (s, mut depth) = (*i, 0); + loop { + match *c.get(*i).ok_or(Error)? { + '"' => drop(basic(c, i)?), + '\'' => drop(literal(c, i)?), + ch => { + depth += (ch == '{') as i32 - (ch == '}') as i32; + *i += 1; + if depth == 0 { + return Ok(Value::Other(c[s..*i].iter().collect())); + } + } + } + } + } + _ => Err(Error), + } +} + +/// Spaces, newlines and comments inside an array. +fn gap(c: &[char], i: &mut usize) { + loop { + match c.get(*i) { + Some(' ' | '\t' | '\n' | '\r') => *i += 1, + Some('#') => comment(c, i), + _ => return, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_default_file() { + let d = parse(include_str!("../tests/fixtures/ptbr-writer.default.toml")).unwrap(); + assert_eq!(d.get("folder"), Some(&Value::Str(".".into()))); + assert_eq!( + d.get("languages"), + Some(&Value::Arr(vec![Value::Str("pt-BR".into())])) + ); + assert_eq!(d.get("levels"), Some(&Value::Arr(vec![]))); + } + + #[test] + fn arrays_span_lines_and_comments() { + let d = parse("a = [\n \"x\", # c\n 'y',\n]\n[t]\nb = 1\n").unwrap(); + assert_eq!( + d.get("a"), + Some(&Value::Arr(vec![ + Value::Str("x".into()), + Value::Str("y".into()) + ])) + ); + assert_eq!(d.get("t"), Some(&Value::Table)); + assert!(d.get("b").is_none()); + } + + #[test] + fn rejects_broken_files() { + for bad in [ + "levels = [\n", + "a = 1\na = 2\n", + "a = \"x\" y\n", + "a = 01\n", + "a = \"\n\"\n", + "a\n", + "[[t]]\n", + ] { + assert!(parse(bad).is_err(), "{bad:?}"); + } + } +} diff --git a/labs/ptbr-tools/tests/fixtures/ptbr-writer.default.toml b/labs/ptbr-tools/tests/fixtures/ptbr-writer.default.toml new file mode 100644 index 0000000..1bf51d4 --- /dev/null +++ b/labs/ptbr-tools/tests/fixtures/ptbr-writer.default.toml @@ -0,0 +1,20 @@ +# ptbr-writer: o que o plugin faz quando um pedido começa com `ptbr`. +# Estes são os padrões. Copie para ./ptbr-writer.toml (o projeto) ou +# ~/.config/ptbr-writer/ptbr-writer.toml (você) e mude o que quiser: +# o projeto vence o usuário, o usuário vence estes padrões, e o pedido vence todos. + +# Níveis de rigor, um texto por nível: estrito, descritivo, leve, científico. +# Vazio: a skill classifica o texto e declara o nível. +levels = [] + +# Formatos gravados: txt, md, pdf, notes (notes = smart-note). Vazio: só a resposta. +formats = [] + +# Pasta dos arquivos, relativa à raiz da sessão: "." (a raiz) ou, por exemplo, "docs". +folder = "." + +# Formatos cujo conteúdo aparece inteiro na resposta, além do arquivo. +print = ["txt", "md"] + +# Idiomas, um texto por idioma: "pt-BR" e/ou "asd-ste100" (inglês técnico simplificado). +languages = ["pt-BR"] diff --git a/labs/ptbr-tools/tests/fixtures/routes.tsv b/labs/ptbr-tools/tests/fixtures/routes.tsv new file mode 100644 index 0000000..030b987 --- /dev/null +++ b/labs/ptbr-tools/tests/fixtures/routes.tsv @@ -0,0 +1,6 @@ +skill level words +ptbr-writer estrito runbook,runbooks,procedimento,passo a passo,playbook,checklist,prompt,agente,agentes,instrução,instruções,incidente,on-call,plantão +ptbr-writer descritivo documentação,documente,arquitetura,readme,design doc,adr,api,sistema,módulo,serviço +ptbr-writer leve comunicado,anúncio,e-mail,email,aviso,newsletter,slack,mensagem,post +cientifico científico artigo,paper,proposta,relatório,tese,dissertação,citação,citações,referências,doi,abstract,revisão por pares,revisor,revisores,bibliografia +ptbr-writer smart-note nota,notas,zettelkasten,obsidian,segundo cérebro,second brain,slip box,fichamento,vault,fichas