diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fb33abd..35da87a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -106,8 +106,8 @@ Run a focused inner suite: $ mise exec -- python scripts/check.py unit query ``` -Run unit suites, Lua51 formatting, lint, generated-field drift, links and -whitespace checks: +Run unit suites, Lua51 formatting, lint, generated-field drift, links, +whitespace and example-width checks: ```console $ mise exec -- python scripts/check.py mid @@ -120,6 +120,15 @@ artifact builds/imports, LuaLS diagnostics and actual editor completion: $ mise exec -- python scripts/check.py outer ``` +The outer gate also runs `scripts/check_doc_fences.py`: every `lua` fence in +the README and `docs/` runs or parses according to its `` +directive, against a private owned tmux server selected by `TMUX_BIN`. Run it +alone with: + +```console +$ mise exec -- python scripts/check.py fences +``` + Integration includes real luv and Neovim loop ownership, process cancellation, post-exit pipe draining and metadata byte round-trips. These use the selected Lua interpreter and its own luv ABI; Neovim uses its embedded runtime. @@ -177,6 +186,14 @@ generation and drift checks are offline: $ mise exec -- python scripts/generate_options.py ``` +Examples stay within 80 columns. The width check reads +[example-width.toml](example-width.toml), proves it can fail, then scans the +files it names. The width gate runs both steps, and the mid gate runs it: + +```console +$ mise exec -- python scripts/check.py width +``` + Check whitespace in unstaged changes: ```console diff --git a/.github/WRITING.md b/.github/WRITING.md index 7506783..430bd48 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -54,6 +54,8 @@ lists and code blocks. Wrap repository prose at 80 columns, except for URLs and other tokens that cannot be split. Do not hard-wrap issue or pull-request body paragraphs. +### Code blocks + A shell code block is one paste-and-run action: - Put one command in each block. An explicit `&&`, `;`, or `\` continuation @@ -72,6 +74,102 @@ $ git log \ --oneline ``` +## Examples + + + +An example is code written for a reader: a program under `examples/`, code in +a doc comment or docstring, and every fenced block in a README or docs page. +Shell blocks also follow [Code blocks](#code-blocks). + +The text between the shared markers is the same in every libtmux port. +Change it in all of them together. + +### Width + +- **Examples stay within 80 columns.** They render in fixed-width boxes that + scroll sideways, and 80 columns fits a libtmux.org code block in a + laptop-width window. Comments inside examples wrap at 80 too. +- **The width check enforces it.** It reads the tracked files that + `.github/example-width.toml` names and fails on a wider line. It measures + the whole source line, so code in a doc comment counts its indent and + comment marker. It skips output (a fence tagged `text`, and what a + `console` block prints), hidden setup lines, and a line that is only a URL; + an untagged fence counts as code. +- **A line that must stay wider is listed there with its reason.** An entry + that no longer matches a line fails the check, so no stale entry stays. +- **The formatter's width is the hard limit for all other source.** Example + directories set their formatter to 80 where the formatter takes a width. + +### Reaching 80 + +- **Change the code, not the line breaks.** A formatter rejoins any line that + fits its width. Name a sub-expression, use a short example name, hide setup + the reader does not need, or print less. +- **Break at the outermost level when a break is still needed:** after an + opening parenthesis with one argument per line, one call per line in a + chain, one field per line in a literal. +- **Put a comment on its own line above the code it explains.** Never trail + one after code in an example, unless the repository's example runner reads + it there, as with an assertion marker. +- **Break a long string at a word boundary,** never inside a tmux format + (`#{...}`) or an escape sequence; the joined text stays the same. +- **Continue a long command in a `console` block the way its shell does:** + `\` after a `$ ` prompt, a backtick after `PS> `, one flag per continuation + line. + +### What never breaks + +- **Output a test compares.** Wrapping it changes what the test expects. +- **A block copied from a source file.** Fix the width in the source, then + refresh the copy with the sync command the port block names, or by hand + where it names none; never fix the copy alone. +- **Marker lines and URLs,** which tools and readers take whole. + + + +### In this repository + +- **Hard limit:** StyLua with Lua51 syntax, configured by `stylua.toml` and + its `column_width` key; `examples/stylua.toml` sets 80 for the example + directories, and `stylua --syntax Lua51 examples` applies it; run it again + until `stylua --check --syntax Lua51 examples` passes, since StyLua 2.4.0 + rewrites its own output once for a method chain inside a call argument. + The width check is `python3 scripts/check_example_width.py`, + run by the `mid` gate. It reads `.github/example-width.toml` and checks + that `examples/stylua.toml` differs from the root config only in + `column_width`. +- **Not formatted:** StyLua does not wrap comments and never reads Markdown. + Hold comments, the fences in every Markdown file, and + `examples/api/run.sh` to the example width by hand; the width check fails on a + wider line. +- **Runs, compiles, exempt:** a program listed in `examples/api/manifest.json` + runs through `scripts/api_examples.py` and its output is compared. + Programs the package gate lists run there. Every `lua` fence in the README + and `docs/` carries `` + above it and an unmarked fence fails. `run` is a whole program, + `fragment` runs inside a live server with `server`, `session`, `window`, + `pane`, `snapshot` and `must` bound, and `compile-only` states why it + cannot run. +- **Compared output and copied blocks:** never wrap the `stdout` in + `manifest.json` or a line a test matches. The README snapshot fence copies + `examples/snapshot.lua` and the quickstart region between `docs:begin` and + `docs:end` is quoted by the documentation site. Neither has a sync tool; + edit the source file, then copy it. + +Bad, over 80: + +```lua +local split = must(editor.pane:split({ direction = "right", percent = 40 }):await()) +``` + +Good, a named option table: + +```lua +local right = { direction = "right", percent = 40 } +local split = must(editor.pane:split(right):await()) +``` + ## Commit messages Use `Scope(type[detail]): Description`. The detail is optional. Name the diff --git a/.github/example-width.toml b/.github/example-width.toml new file mode 100644 index 0000000..8889076 --- /dev/null +++ b/.github/example-width.toml @@ -0,0 +1,24 @@ +width = 80 +markdown = ["**/*.md"] +sources = ["examples/**/*.lua", "examples/api/run.sh"] +allow_ceiling = 1 +shared_digest = "28b74bae1f7a06731fea5a150cbde4430179b7788b7564ea1644c3951df8531b" +checker_digest = "eb87afceb3fc8f11ef5a478cd38f901ef3950e89ff94fbe0ca281c0cfc1a0e2e" + +[[allow]] +path = ".github/WRITING.md" +line = 'local split = must(editor.pane:split({ direction = "right", percent = 40 }):await())' +reason = "the Bad example in the port block; it shows the width problem the Good example fixes" + +[[exclude]] +glob = "examples/stylua.toml" +reason = "a copy of stylua.toml that [[formatter_copies]] already compares" + +[[exclude]] +glob = "examples/api/manifest.json" +reason = "a machine-read index; its description and stdout values are JSON strings that cannot wrap, and tests compare them" + +[[formatter_copies]] +copy = "examples/stylua.toml" +root = "stylua.toml" +key = "column_width" diff --git a/AGENTS.md b/AGENTS.md index 85061ef..4ccd691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,10 @@ requested work. Prefer `rg`, `ag`, and `fd` for discovery. - Setup, validation, tmux isolation, and pull requests: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md). -- Documentation, user-facing text, comments, and commit messages: - [.github/WRITING.md](.github/WRITING.md). +- Documentation, user-facing text, comments, commit messages, and any code + example (example programs, Markdown code blocks): + [.github/WRITING.md](.github/WRITING.md), and its + [Examples](.github/WRITING.md#examples) section for code a reader sees. Each guide is the single home for its subject. `CLAUDE.md` is a relative symlink to this file so both entry points use the same instructions. diff --git a/README.md b/README.md index e938572..c46a185 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ for exact versions and remaining platform coverage. Connect to an existing server by its explicit socket path. This is the full [snapshot example](examples/snapshot.lua), which prints pane and window IDs: + ```lua local adapter = require("libtmux.runtime.luv") @@ -77,13 +78,14 @@ local function must(value, err) return value end +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") + must(adapter.run(function(runtime) - local server = must(runtime - :connect({ - binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable"), - socket_path = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket"), - }) - :await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) local snapshot = must(server:snapshot({ strict = true }):await()) for _, pane in ipairs(snapshot.panes) do io.stdout:write(pane.id, "\t", pane.window_id, "\n") @@ -134,6 +136,7 @@ SH After capturing `snapshot` in the example above, filter its panes with structured criteria or an ordinary Lua function: + ```lua local editors = snapshot.panes:where({ current_command = { one_of = { "nvim", "vim" } }, @@ -165,10 +168,12 @@ $ lua examples/native_query.lua Before closing `server` in that runtime body, create a session and split a window. These calls use the same `must` helper: + ```lua local work = must(server:new_session({ name = "work" }):await()) local editor = must(work.session:new_window({ name = "editor" }):await()) -local split = must(editor.pane:split({ direction = "right", percent = 40 }):await()) +local right = { direction = "right", percent = 40 } +local split = must(editor.pane:split(right):await()) must(split.pane:send_text("printf '%s\\n' hello"):await()) must(split.pane:send_keys({ "Enter" }):await()) @@ -191,6 +196,7 @@ $ nvim --cmd 'set runtimepath+=.' With `TMUX_SOCKET` set to an existing server's absolute socket path, run this Lua code. `start` uses the editor's loop and reports the result in a callback: + ```lua local adapter = require("libtmux.runtime.nvim") diff --git a/docs/buffers.md b/docs/buffers.md index ff3cb9d..34214ae 100644 --- a/docs/buffers.md +++ b/docs/buffers.md @@ -4,6 +4,7 @@ Buffers hold bytes in the tmux daemon. Use an explicit name for every read, write, deletion and paste; these methods never infer the most recent buffer. Each live operation returns a Request through the PROCESS endpoint. + ```lua assert(server:set_buffer("build-output", "one\000two\n"):await()) local content = assert(server:show_buffer("build-output"):await()) diff --git a/docs/clients.md b/docs/clients.md index 93f0d2d..748c169 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -7,6 +7,7 @@ the snapshot. Client records do not prove attachment continuity. Inside a [runtime task](runtime.md), with a connected `server`, choose the client and destination session by their observed names: + ```lua local snapshot = assert(server:snapshot():await()) local observed = snapshot.clients:where({ name = "/dev/pts/7" }):one() diff --git a/docs/environment.md b/docs/environment.md index 082d332..81ba566 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -4,6 +4,7 @@ Server methods address tmux's global environment store. Session methods address that session's local store. Each method returns a Request; reads never evaluate shell text. Window and Pane handles reject these operations with `invalid_scope`. + ```lua local adapter = require("libtmux.runtime.luv") local result, err = adapter.run(function(runtime) @@ -18,7 +19,9 @@ local result, err = adapter.run(function(runtime) assert(session:set_environment("APP_MODE", "local"):await()) local local_value = assert(session:get_environment("APP_MODE"):await()) assert(session:unset_environment("APP_MODE"):await()) - local inherited = assert(session:get_environment("APP_MODE", { inherit = true }):await()) + local inherit = { inherit = true } + local request = session:get_environment("APP_MODE", inherit) + local inherited = assert(request:await()) return { local_value = local_value, inherited = inherited } end) assert(result, tostring(err)) diff --git a/docs/panes.md b/docs/panes.md index 01f0959..af3d7db 100644 --- a/docs/panes.md +++ b/docs/panes.md @@ -5,6 +5,7 @@ PROCESS endpoint. Each method returns a Request; await it inside a managed runtime task or use its completion callback. Snapshot records remain plain captured data. Obtain a handle from a creation receipt or `server:handle`. + ```lua local capture = assert(pane:capture({ history_lines = 20 }):await()) local text = assert(capture:text()) @@ -130,6 +131,7 @@ The initial action subset includes cursor/word/paragraph/page/history navigation, selection marking, rectangle modes, refresh, search and jumps. For example: + ```lua assert(pane:copy_mode():await()) assert(pane:copy_command("search-forward-text", { "ready" }):await()) diff --git a/docs/query.md b/docs/query.md index 2f058cf..c565b56 100644 --- a/docs/query.md +++ b/docs/query.md @@ -71,11 +71,13 @@ ordinary JSON `decode` function cannot preserve evidence of duplicate keys. Core imports and ordinary queries require only Lua. Codec functions are trusted synchronous code; the library does not load a codec automatically. + ```lua local query = require("libtmux.query") local json = require("lunajson") local schema = { fields = { active = { type = "boolean" } } } -local text = assert(query.encode_json(schema, { active = false, AND = {} }, json)) +local input = { active = false, AND = {} } +local text = assert(query.encode_json(schema, input, json)) local criteria = assert(query.decode_json(schema, text, json)) ``` diff --git a/docs/snapshots.md b/docs/snapshots.md index 722f8fb..c3412c0 100644 --- a/docs/snapshots.md +++ b/docs/snapshots.md @@ -93,7 +93,9 @@ Cancellation retires owned clients; it does not kill the daemon. Run the example against an explicitly selected existing server: ```console -$ TMUX_BIN=/usr/bin/tmux TMUX_SOCKET=/tmp/example-tmux.sock lua examples/snapshot.lua +$ TMUX_BIN=/usr/bin/tmux \ + TMUX_SOCKET=/tmp/example-tmux.sock \ + lua examples/snapshot.lua ``` The package gate runs this file from installed core and luv outside the diff --git a/docs/topology.md b/docs/topology.md index 05f9442..54ce0c7 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -6,6 +6,7 @@ the PROCESS endpoint. Each operation returns a Request that resolves to explicitly to inspect the resulting state; existing records do not change in place. + ```lua assert(session:rename("build"):await()) assert(window:resize({ width = 120, height = 40 }):await()) diff --git a/examples/api/capture.lua b/examples/api/capture.lua index 86c88c3..5f6f6c6 100644 --- a/examples/api/capture.lua +++ b/examples/api/capture.lua @@ -7,18 +7,24 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must( + server:new_session({ name = "demo", argv = { "/bin/sh" } }):await() + ) local pane = created.pane local function quote(text) return "'" .. text:gsub("'", "'\\''") .. "'" end - -- Signal completion on this socket; do not guess when the shell has printed. + -- Signal completion on this socket; do not guess when the shell + -- has printed. local command = "printf '\\nlua capture ready\\n'; " .. quote(binary) .. " -S " @@ -26,7 +32,8 @@ must(adapter.run(function(runtime) .. " wait-for -S example-ready" must(pane:send_text(command):await()) must(pane:send_keys({ "Enter" }):await()) - must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await()) + local wait = { "wait-for", "example-ready" } + must(server:command(wait, { timeout = 1000 }):await()) local capture = must(pane:capture({ history_lines = 20 }):await()) local found = false for line in must(capture:text()):gmatch("[^\r\n]+") do diff --git a/examples/api/connect.lua b/examples/api/connect.lua index 7cc4346..0d0e70c 100644 --- a/examples/api/connect.lua +++ b/examples/api/connect.lua @@ -7,13 +7,19 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) local snapshot = must(server:snapshot({ strict = true }):await()) - assert(#snapshot.sessions:where({ name = "bootstrap" }) == 1, "bootstrap session is missing") + assert( + #snapshot.sessions:where({ name = "bootstrap" }) == 1, + "bootstrap session is missing" + ) print("connected") must(server:close():await()) diff --git a/examples/api/new_session.lua b/examples/api/new_session.lua index 7771ef4..8b925a8 100644 --- a/examples/api/new_session.lua +++ b/examples/api/new_session.lua @@ -7,19 +7,24 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - local created = must(server - :new_session({ - name = "demo", - window_name = "main", - argv = { "/bin/cat" }, - }) - :await()) - assert(created.session and created.window and created.pane, "creation handles are missing") + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local session_options = { + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + } + local created = must(server:new_session(session_options):await()) + assert( + created.session and created.window and created.pane, + "creation handles are missing" + ) local snapshot = must(created.session:snapshot():await()) assert(snapshot.name == "demo", "created session has the wrong name") print("session: " .. snapshot.name) diff --git a/examples/api/new_window.lua b/examples/api/new_window.lua index df609e8..679837e 100644 --- a/examples/api/new_window.lua +++ b/examples/api/new_window.lua @@ -7,19 +7,25 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - local created = must(server - :new_session({ - name = "demo", - window_name = "main", - argv = { "/bin/cat" }, - }) - :await()) - local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local session_options = { + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + } + local created = must(server:new_session(session_options):await()) + local logs = must( + created.session + :new_window({ name = "logs", argv = { "/bin/cat" } }) + :await() + ) local snapshot = must(logs.window:snapshot():await()) assert(snapshot.name == "logs", "created window has the wrong name") print("window: " .. snapshot.name) diff --git a/examples/api/query.lua b/examples/api/query.lua index c83db66..fe03132 100644 --- a/examples/api/query.lua +++ b/examples/api/query.lua @@ -7,28 +7,32 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - must(server - :new_session({ - name = "demo", - window_name = "main", - argv = { "/bin/cat" }, - }) - :await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local session_options = { + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + } + must(server:new_session(session_options):await()) must(server:new_session({ name = "worker", argv = { "/bin/cat" } }):await()) - local result = must(server - :query({ - kind = "session", - where = { name = "demo" }, - snapshot = { strict = true }, - }) - :await()) + local query_options = { + kind = "session", + where = { name = "demo" }, + snapshot = { strict = true }, + } + local result = must(server:query(query_options):await()) assert(result.complete, "query observed a topology change") - assert(#result.rows == 1 and result.rows[1].name == "demo", "query selected the wrong session") + assert( + #result.rows == 1 and result.rows[1].name == "demo", + "query selected the wrong session" + ) print("matched: " .. result.rows[1].name) must(server:close():await()) diff --git a/examples/api/run.sh b/examples/api/run.sh index b6b6047..406e2cf 100644 --- a/examples/api/run.sh +++ b/examples/api/run.sh @@ -25,6 +25,7 @@ trap 'exit 1' HUP INT TERM unset TMUX TMUX_PANE export TMUX_BIN="$binary" TMUX_SOCKET="$socket" ENV=/dev/null BASH_ENV=/dev/null -"$binary" -S "$socket" -f /dev/null new-session -d -s bootstrap -n bootstrap /bin/cat +"$binary" -S "$socket" -f /dev/null \ + new-session -d -s bootstrap -n bootstrap /bin/cat "${LUA_BIN:-lua}" "$program" "$binary" -S "$socket" has-session -t '=bootstrap' diff --git a/examples/api/send_keys.lua b/examples/api/send_keys.lua index cad5c28..78cd131 100644 --- a/examples/api/send_keys.lua +++ b/examples/api/send_keys.lua @@ -7,18 +7,24 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local created = must( + server:new_session({ name = "demo", argv = { "/bin/sh" } }):await() + ) local pane = created.pane local function quote(text) return "'" .. text:gsub("'", "'\\''") .. "'" end - -- Signal completion on this socket; do not guess when the shell has printed. + -- Signal completion on this socket; do not guess when the shell + -- has printed. local command = "printf '\\nlua input ready\\n'; " .. quote(binary) .. " -S " @@ -26,7 +32,8 @@ must(adapter.run(function(runtime) .. " wait-for -S example-ready" must(pane:send_text(command):await()) must(pane:send_keys({ "Enter" }):await()) - must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await()) + local wait = { "wait-for", "example-ready" } + must(server:command(wait, { timeout = 1000 }):await()) local capture = must(pane:capture({ history_lines = 20 }):await()) local found = false for line in must(capture:text()):gmatch("[^\r\n]+") do diff --git a/examples/api/snapshot.lua b/examples/api/snapshot.lua index 03a7277..6241077 100644 --- a/examples/api/snapshot.lua +++ b/examples/api/snapshot.lua @@ -7,18 +7,20 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket") must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) - must(server - :new_session({ - name = "demo", - window_name = "main", - argv = { "/bin/cat" }, - }) - :await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local session_options = { + name = "demo", + window_name = "main", + argv = { "/bin/cat" }, + } + must(server:new_session(session_options):await()) local snapshot = must(server:snapshot({ strict = true }):await()) local sessions, windows = {}, {} for _, session in ipairs(snapshot.sessions) do @@ -29,7 +31,11 @@ must(adapter.run(function(runtime) end table.sort(sessions) table.sort(windows) - assert(#snapshot.sessions == 2 and #snapshot.windows == 2 and #snapshot.panes == 2) + assert( + #snapshot.sessions == 2 + and #snapshot.windows == 2 + and #snapshot.panes == 2 + ) print("sessions: " .. table.concat(sessions, ", ")) print("windows: " .. table.concat(windows, ", ")) print("panes: " .. #snapshot.panes) diff --git a/examples/native_query.lua b/examples/native_query.lua index 366aada..4492b36 100644 --- a/examples/native_query.lua +++ b/examples/native_query.lua @@ -13,7 +13,8 @@ local rows = { { id = "%3", active = false, current_command = "vim" }, } local panes = query.select(rows, schema) -local editors = panes:where({ current_command = { one_of = { "nvim", "vim" } } }) +local editors = + panes:where({ current_command = { one_of = { "nvim", "vim" } } }) assert(#editors == 2 and editors[1] == rows[1] and editors[2] == rows[3]) local inactive = panes:filter(function(pane) diff --git a/examples/quickstart.lua b/examples/quickstart.lua index bf2efae..f2b29a5 100644 --- a/examples/quickstart.lua +++ b/examples/quickstart.lua @@ -7,33 +7,54 @@ local function must(value, err) return value end -local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") -local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") local result = must(adapter.run(function(runtime) - local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) -- docs:begin main - local created = must( - server - :new_session({ name = "quickstart", window_name = "main", argv = { "/bin/sh" } }) + local session_options = { + name = "quickstart", + window_name = "main", + argv = { "/bin/sh" }, + } + local created = must(server:new_session(session_options):await()) + local logs = must( + created.session + :new_window({ name = "logs", argv = { "/bin/cat" } }) + :await() + ) + local split = must( + logs.pane + :split({ direction = "right", percent = 40, argv = { "/bin/cat" } }) :await() ) - local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await()) - local split = - must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await()) -- The pane signals through the same tmux binary; a bare `tmux` there may be -- another build that cannot reach this server. local marker = "libtmux-lua-quickstart" - local signal = ("printf 'libtmux ready\\n'; '%s' wait-for -S %s"):format(binary, marker) + local signal = ("printf 'libtmux ready\\n'; '%s' wait-for -S %s"):format( + binary, + marker + ) must(created.pane:send_text(signal):await()) must(created.pane:send_keys({ "Enter" }):await()) must(server:command({ "wait-for", marker }, { timeout = 10000 }):await()) local capture = must(created.pane:capture({ history_lines = 20 }):await()) - assert(must(capture:text()):find("libtmux ready", 1, true), "pane output was not captured") - print(created.session:reference().id, logs.window:reference().id, split.pane:reference().id) + assert( + must(capture:text()):find("libtmux ready", 1, true), + "pane output was not captured" + ) + print( + created.session:reference().id, + logs.window:reference().id, + split.pane:reference().id + ) must(created.session:kill():await()) -- docs:end main diff --git a/examples/snapshot.lua b/examples/snapshot.lua index 1b64b89..5d62cd8 100644 --- a/examples/snapshot.lua +++ b/examples/snapshot.lua @@ -7,13 +7,14 @@ local function must(value, err) return value end +local binary = + assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = + assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") + must(adapter.run(function(runtime) - local server = must(runtime - :connect({ - binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable"), - socket_path = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket"), - }) - :await()) + local server = + must(runtime:connect({ binary = binary, socket_path = socket }):await()) local snapshot = must(server:snapshot({ strict = true }):await()) for _, pane in ipairs(snapshot.panes) do io.stdout:write(pane.id, "\t", pane.window_id, "\n") diff --git a/examples/stylua.toml b/examples/stylua.toml new file mode 100644 index 0000000..5343e7d --- /dev/null +++ b/examples/stylua.toml @@ -0,0 +1,5 @@ +syntax = "Lua51" +column_width = 80 +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" diff --git a/scripts/check.py b/scripts/check.py index c790ce3..ead739c 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -95,7 +95,7 @@ def markdown_anchors(path): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("gate", choices=("unit", "mid", "format", "lint", "generated", "docs", "integration", "package", "types", "outer")) + parser.add_argument("gate", choices=("unit", "mid", "format", "lint", "generated", "docs", "width", "fences", "integration", "package", "types", "outer")) parser.add_argument("suites", nargs="*", help="unit suite names") parser.add_argument("--lua", default="lua", help="interpreter executable") parser.add_argument("--nvim", help="Neovim executable for embedded integration tests") @@ -127,6 +127,11 @@ def execute(gate): run([executable, "lua", "packages", "tests", "examples"], env=lint_env, timeout=10) elif gate == "docs": docs() + elif gate == "width": + run([sys.executable, "scripts/check_example_width.py", "--self-test"], timeout=5) + run([sys.executable, "scripts/check_example_width.py"], timeout=5) + elif gate == "fences": + run([sys.executable, "scripts/check_doc_fences.py", "--lua", lua], env=env, timeout=60) elif gate == "generated": run([sys.executable, "scripts/generate_fields.py", "--check"], env=env, timeout=5) run([sys.executable, "scripts/generate_options.py", "--check"], env=env, timeout=5) @@ -145,10 +150,10 @@ def execute(gate): elif gate == "mid": run([sys.executable, "-m", "unittest", "discover", "-s", "tests/tooling", "-v"], timeout=5) run([tool("actionlint")], timeout=5) - for item in ("unit", "format", "lint", "generated", "docs"): + for item in ("unit", "format", "lint", "generated", "docs", "width"): execute(item) elif gate == "outer": - for item in ("mid", "integration", "package", "types"): + for item in ("mid", "fences", "integration", "package", "types"): execute(item) execute(args.gate) diff --git a/scripts/check_doc_fences.py b/scripts/check_doc_fences.py new file mode 100644 index 0000000..179d73a --- /dev/null +++ b/scripts/check_doc_fences.py @@ -0,0 +1,102 @@ +"""Run or syntax-check every ```lua fence in the README and docs. + +A fence is prose until something executes it. Each one carries a directive on the +line above, and an unclassified fence fails: + + run as a program (it requires the runtime itself) + run inside a live server; `server`, `session`, + `window`, `pane`, `snapshot` and `must` are bound + must parse; cannot run (needs a client, nvim, ...) + +Usage: python3 scripts/check_doc_fences.py --lua lua +""" + +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).parent)) +from check import lua_environment, tool # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +FENCE = re.compile(r"(?:\s*\n)?^```lua[^\n]*\n(?P.*?)^```", re.S | re.M) +OPENING = re.compile(r"^[ \t>]*```[ \t]*lua\b", re.I | re.M) +HARNESS = """\ +local adapter = require("libtmux.runtime.luv") +local function must(value, err) + if err ~= nil then error(tostring(err), 0) end + return value +end +must(adapter.run(function(runtime) + local server = must(runtime:connect({ + binary = assert(os.getenv("TMUX_BIN")), socket_path = assert(os.getenv("TMUX_SOCKET")), + }):await()) + local created = must(server:new_session({ name = "fixture", window_name = "main", argv = { "/bin/cat" } }):await()) + local session, window, pane = created.session, created.window, created.pane + local snapshot = must(server:snapshot():await()) + ;(function(server, session, window, pane, snapshot, must) +%s + end)(server, session, window, pane, snapshot, must) + must(server:close():await()) + return true +end)) +""" +BOOT = """set -eu +d=$(mktemp -d); s=$d/t.sock; b=${TMUX_BIN:-$(command -v tmux)} +trap '"$b" -S "$s" kill-server 2>/dev/null; rm -rf "$d"' 0 +unset TMUX TMUX_PANE +"$b" -S "$s" -f /dev/null new-session -d -s bootstrap -n bootstrap /bin/cat +export TMUX_BIN="$b" TMUX_SOCKET="$s" ENV=/dev/null BASH_ENV=/dev/null +exec "$LUA" "$1" +""" + + +def documents(): + return [ROOT / "README.md", *sorted((ROOT / "docs").glob("**/*.md"))] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lua", default="lua") + args = parser.parse_args() + lua = tool(args.lua) + env = lua_environment(lua) + failures, counts = [], {"run": 0, "fragment": 0, "compile-only": 0} + for doc in documents(): + text = doc.read_text() + found = list(FENCE.finditer(text)) + if len(found) != len(OPENING.findall(text)): + failures.append(f"{doc.relative_to(ROOT)}: a lua fence the extractor cannot take") + for m in found: + where = f"{doc.relative_to(ROOT)}:{text[:m.start()].count(chr(10)) + 1}" + kind = (m["d"] or "").split(":")[0].strip() + if kind not in counts or (kind == "compile-only" and ":" not in m["d"]): + failures.append(f"{where}: unclassified lua fence (needs run | fragment | compile-only: reason)") + continue + counts[kind] += 1 + body = m["b"] + source = HARNESS % body if kind == "fragment" else body + with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False) as handle: + handle.write(source) + try: + if kind == "compile-only": + cmd = [lua, "-e", f"assert(loadfile({handle.name!r}))"] + else: + cmd = ["sh", "-c", BOOT, "sh", handle.name] + result = subprocess.run(cmd, env={**os.environ, **env, "LUA": lua}, capture_output=True, text=True, timeout=20, stdin=subprocess.DEVNULL) + finally: + os.unlink(handle.name) + if result.returncode: + failures.append(f"{where}: {kind} fence failed\n " + result.stderr.strip().replace("\n", "\n ")) + print(counts) + for failure in failures: + print(failure, file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_example_width.py b/scripts/check_example_width.py new file mode 100644 index 0000000..b299498 --- /dev/null +++ b/scripts/check_example_width.py @@ -0,0 +1,3066 @@ +r"""Fail when code a reader sees in rendered documentation is too wide. + +Shared by every libtmux port; change it in all of them together. It +enforces the example rules in WRITING.md. Configuration lives in +``.github/example-width.toml``:: + + width = 80 + tab_width = 4 + markdown = ["**/*.md"] + sources = ["examples/**/*.rs"] + doc_comments = ["src/**/*.rs"] + shared_digest = "" + checker_digest = "" + allow_ceiling = 1 + + [[exclude]] + glob = "docs/decisions/**" + reason = "dated decision records, not reader-facing examples" + + [[formatter_copies]] + copy = "examples/rustfmt.toml" + root = "rustfmt.toml" + key = "max_width" + + [[allow]] + path = "README.md" + line = "the exact line, as it appears in the file" + reason = "why breaking it would make the example worse" + +It checks code in ``markdown`` files (fences, indented blocks, including +one four columns past a list item's text, and ``
`` blocks; literal
+and ``code-block`` blocks in a ``.rst`` one), every line of ``sources``
+files, and the code inside doc comments of ``doc_comments`` files:
+fences, read as a Markdown page reads them, ``>>>`` doctests, and reST
+literal blocks in Python docstrings. Output is not checked: fences
+tagged ``text``, and the lines a session prints (a ``console``,
+``shell-session`` or ``pycon`` block, or a ``python`` fence that opens
+with ``>>> ``). A command runs on through an open quote, heredoc, loop,
+conditional or brace block and past a trailing ``\``, backtick, pipe,
+``&&`` or ``||``; a ``$ `` command must continue with ``\`` and a
+``PS> `` command with a backtick. An untagged Markdown fence is code,
+and a ``text`` fence that opens with a ``$ `` or ``PS> `` prompt is a
+console session. A line that is only a URL (after an optional comment, list or
+quote marker) and a hidden line (a rustdoc ``# `` line, a doctest marked
+``# doctest: +HIDE``) are skipped. Wide characters count as two columns;
+a tab advances to the next multiple of 4.
+
+``width`` must be 80, the shared rule, and ``tab_width``, if set, 4.
+Every key must be known, at the top level and inside each ``[[...]]``
+entry, so a key written below a table header cannot be swallowed by that
+entry; every entry must hold
+the keys the check reads, and every value must have the type it reads.
+Every glob must match a tracked file, and every exclusion a checked file
+or a file under ``examples``;
+every exclusion and allow entry must give a reason, and every allow
+entry must match a line. Every tracked file under a directory named
+``examples`` must be checked or excluded, so a new example file cannot
+go unread. ``allow_ceiling`` must equal the number of allow entries, so
+the list grows only through a reviewed change to that number and a fixed
+line must lower it. Each formatter copy must equal its root file once
+its ``key`` value is set aside, and the digests of this checker and of
+WRITING.md's shared section must equal ``checker_digest`` and
+``shared_digest``, both required, so a local edit to either fails until
+every port changes together. A WRITING.md without both shared markers is
+reported by name. AGENTS.md must hold a Markdown link that resolves to
+this WRITING.md and the heading the shared section sits under, so an
+agent is routed to the rules, and WRITING.md's
+``### In this repository`` block must hold the four labelled bullets in
+order, then one Bad and one Good example, with the Bad example still
+past 80 columns.
+
+Run ``--self-test`` to prove the checker can fail. ``--digest`` prints the
+SHA-256 of this file and of WRITING.md's shared example section (between
+```` and ````), so a job
+that reads every port can confirm they still agree.
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import hashlib
+import json
+import os
+import pathlib
+import re
+import subprocess
+import sys
+import typing as t
+import unicodedata
+
+try:
+    import tomllib
+except ModuleNotFoundError:  # tomllib is in the standard library from 3.11.
+    sys.exit("check_example_width.py needs Python 3.11 or newer")
+
+CONFIG = pathlib.PurePosixPath(".github/example-width.toml")
+KEYS = frozenset(
+    {
+        "width",
+        "tab_width",
+        "markdown",
+        "sources",
+        "doc_comments",
+        "exclude",
+        "allow",
+        "allow_ceiling",
+        "formatter_copies",
+        "shared_digest",
+        "checker_digest",
+    }
+)
+# Keys each [[table]] entry holds.
+TABLE_KEYS = {
+    "exclude": frozenset({"glob", "reason"}),
+    "allow": frozenset({"path", "line", "reason"}),
+    "formatter_copies": frozenset({"copy", "root", "key"}),
+}
+GLOB_KEYS = ("markdown", "sources", "doc_comments")
+# The reason the printed allow entry carries until someone writes a real one.
+PLACEHOLDER = "why breaking this line would make the example worse"
+# The type each top-level value must have; a TOML string where a list belongs
+# would otherwise be read one character at a time.
+VALUE_TYPES: dict[str, tuple[type, str]] = {
+    "width": (int, "an integer"),
+    "tab_width": (int, "an integer"),
+    "allow_ceiling": (int, "an integer"),
+    "shared_digest": (str, "a string"),
+    "checker_digest": (str, "a string"),
+}
+CODE_DIRECTIVES = frozenset({"code-block", "code", "code-cell", "sourcecode"})
+# gp-libs hides a doctest line carrying this directive from the rendered page.
+HIDDEN = re.compile(r"^\s*(?:>>>|\.\.\.) .*# doctest: \+HIDE\b")
+OUTPUT_TAGS = frozenset({"text"})
+# Fences that mix prompted commands with the output they print.
+SESSION_TAGS = frozenset({"console", "shell-session", "pycon"})
+# Fences of shell script, with no prompts and no output.
+SCRIPT_TAGS = frozenset({"sh", "bash", "zsh", "shell", "powershell", "pwsh", "ps1"})
+# A Python fence that opens with a doctest prompt is a session too.
+PYTHON_TAGS = frozenset({"python", "py", "python3"})
+PROMPTS = ("$ ", "PS> ", ">>> ", "... ")
+# Shell words that open and close a block a command continues through.
+SHELL_OPEN = frozenset({"if", "for", "while", "until", "case", "select"})
+SHELL_CLOSE = frozenset({"fi", "done", "esac"})
+HEREDOC = re.compile(r"(?[A-Za-z_]\w*)\1")
+# A reST line that opens an indented code block: a paragraph ending in
+# ``::`` or a code directive. Other directives, such as toctree, hold no code.
+REST_BLOCK = re.compile(
+    r"^\s*(?!\.\. )\S.*::\s*$|^\s*\.\. (code-block|code|sourcecode)::"
+)
+FENCE = re.compile(r"^(?P\s*)(?P`{3,}|~{3,})\s*(?P[^`]*)$")
+RUSTDOC_TAGS = frozenset({"rust", "no_run", "ignore", "should_panic", "compile_fail"})
+SHARED = re.compile(
+    r"\n(?P.*?)", re.DOTALL
+)
+DOC_LINE = re.compile(r"^\s*(?:///|//!|\*(?!/)|#'|--- ?)\s?")
+# Files under examples/ with no text lines to measure.
+BINARY = (
+    ".png",
+    ".jpg",
+    ".jpeg",
+    ".gif",
+    ".ico",
+    ".webp",
+    ".pdf",
+    ".woff",
+    ".woff2",
+    ".ttf",
+    ".zip",
+    ".gz",
+    ".tar",
+    ".wasm",
+    ".so",
+    ".dll",
+    ".exe",
+    ".jar",
+    ".class",
+    ".bin",
+    ".dylib",
+    ".a",
+    ".o",
+    ".db",
+    ".sqlite",
+)
+# Comments a test compares or a copy step reads: Go's `// Output:` and
+# `// docs:` lines, java's `// →` results, and region markers.
+MARKER = re.compile(
+    r"(?:Output:|Unordered output:|docs:|→|#?(?:end)?region\b)", re.IGNORECASE
+)
+# Shell and PowerShell scripts, whose lines continue the way a console command does.
+SCRIPTS = (".sh", ".bash", ".zsh", ".ps1", ".psm1")
+# Go marks doc-comment code with a tab after a bare //.
+GO_DOC = re.compile(r"^\s*//\t")
+DOCTEST = re.compile(r"(?:>>>|\.\.\.) ")
+BLOCK_OPEN = re.compile(r"
\s*\{@code|\s*$")
+BLOCK_CLOSE = re.compile(r"\}\s*
|^\s*") +URL = re.compile(r"^(?:(?:#+|//+!?|-+|\*|>|\n\n [x](y)\n- [z](w)\n") + 'a b \n\n- [z](w)\n' + >>> prose("```text\n[x](y)\n```\n") + '' + """ + text = unfenced(text) + code = {number for number, _ in markdown_lines(text)} + lines = text.splitlines(keepends=True) + text = "".join(line for n, line in enumerate(lines, 1) if n not in code) + text = re.sub(r"", "", text, flags=re.DOTALL) + span = r"(? list[str]: + r"""Return the targets of a Markdown text's inline and reference links. + + >>> links('[A](a.md#x "A"), [B]() and [C](c.md \'C\')\n[d]: d.md') + ['a.md#x', 'b.md', 'c.md', 'd.md'] + >>> links("![E](e.md) \\[F](f.md) [G\nH](g.md)") + ['g.md'] + """ + return [inline or reference for inline, reference in LINK.findall(text)] + + +def routing_problems(root: pathlib.Path) -> list[str]: + """Return a problem unless AGENTS.md links the shared section's heading. + + The heading is the last one of level one or two above the shared + markers: the section an agent routed from AGENTS.md should land in. + """ + writing = writing_path(root) + if writing is None: + return [] + text = writing.read_text(encoding="utf-8") + above = unfenced(text[: text.index("")]) + headings = [m[2] for m in HEADING.finditer(above) if len(m[1]) <= 2] + if not headings: + where = writing.relative_to(root).as_posix() + return [f"{where}: no heading above the shared section"] + anchor = slug(headings[-1]) + agents = root / "AGENTS.md" + where = f"{writing.relative_to(root).as_posix()}#{anchor}" + link = f"[{headings[-1]}]({where})" + if not agents.is_file(): + return [f"AGENTS.md is missing; add it with a link such as {link}"] + route = prose(agents.read_text(encoding="utf-8")) + for target in links(route): + path, _, fragment = target.partition("#") + # GitHub reads a leading slash as the repository root. + here = root / path.removeprefix("/") + if fragment == anchor and here.resolve() == writing.resolve(): + return [] + return [ + f"AGENTS.md: no link to {where}, the section with the example rules; " + + f"add one such as {link}" + ] + + +def port_block_problems(text: str) -> list[str]: + r"""Return how a WRITING.md's ``### In this repository`` block strays. + + >>> labels = "\n".join(label + " x" for label in PORT_LABELS) + >>> bad = "```text\n" + "x" * 81 + "\n```" + >>> head = f"### In this repository\n\n{labels}\n\n" + >>> block = head + f"Bad, over 80:\n\n{bad}\n\nGood, named:\n" + >>> port_block_problems(block) + [] + >>> [p.split(";")[0] for p in port_block_problems(block.replace("x" * 81, "x"))] + ['In this repository: the Bad example fits in 80 columns'] + >>> port_block_problems(block.replace("- **Not formatted:**", "- **Other:**")) + ['In this repository: no "- **Not formatted:**" bullet in its place'] + >>> port_block_problems("## Examples\n") + ['no "### In this repository" block'] + >>> port_block_problems(block + "\n" + block) + ['2 "### In this repository" blocks; keep one'] + """ + blocks = PORT_BLOCK.findall(unfenced(text)) + if len(blocks) > 1: + return [f'{len(blocks)} "### In this repository" blocks; keep one'] + match = PORT_BLOCK.search(unfenced(text)) + if match is None: + return ['no "### In this repository" block'] + lines = match["body"].splitlines() + problems = [] + at = 0 + for label in PORT_LABELS: + found = next((i for i, line in enumerate(lines) if line.startswith(label)), -1) + if found < at: + problems.append(f'In this repository: no "{label}" bullet in its place') + else: + at = found + for example in ("Bad, over 80", "Good"): + count = sum(line.startswith(example) for line in lines[at:]) + if count != 1: + problems.append(f'In this repository: {count} "{example}" examples, want 1') + # The fence after "Bad, over 80" is stripped above, so read it raw: a + # Bad example that a later edit brought within 80 no longer shows a fault. + raw = text[text.find("### In this repository") :].splitlines() + start = next( + (i for i, line in enumerate(raw) if line.startswith("Bad, over 80")), None + ) + if start is not None: + opens = [i for i in range(start + 1, len(raw)) if FENCE.match(raw[i])] + code = raw[opens[0] + 1 : opens[1]] if len(opens) > 1 else [] + if not any(columns(line.expandtabs(4)) > 80 for line in code): + problems.append( + "In this repository: the Bad example fits in 80 columns; " + "show a line that does not" + ) + return problems + + +# A comment after code, set off by two spaces as most style guides ask. +TRAILING = re.compile(r"\S\s{2,}(?://|#|--)\s+(?P.*)$") +# How a comment line starts in the languages the examples use. +COMMENTS = ("# ", "// ", "-- ", "/// ", "//! ", "/* ", "* ", "") + >>> fix_advice(html, "W.md")[:22] + 'Wrap the comment at 80' + >>> doc = Finding("a.rs", 2, 90, 80, "/// // a comment line in doc code", "doc") + >>> fix_advice(doc, "W.md")[:22] + 'Wrap the comment at 80' + >>> go = Finding("a.go", 2, 90, 80, "//\t// Wait for the marker line", "doc") + >>> pydoc = Finding("a.py", 2, 90, 80, " >>> # Wait for the marker", "doc") + >>> [fix_advice(f, "W.md")[:22] for f in (go, pydoc)] + ['Wrap the comment at 80', 'Wrap the comment at 80'] + >>> said = Finding("a.md", 2, 90, 80, ">>> # Wait for the marker", "session") + >>> data = Finding("a.md", 2, 90, 80, "# a heredoc line, not a comment", "session") + >>> fix_advice(said, "W.md")[:22] + 'Wrap the comment at 80' + >>> fix_advice(data, "W.md").split(":")[0] + 'Fix a wide line by changing the code, not the line breaks' + >>> doc = Finding("a.rs", 2, 90, 80, "/// let total = first + second;", "doc") + >>> fix_advice(doc, "W.md").split(":")[0], "copied" in fix_advice(doc, "W.md") + ('Fix a wide line by changing the code, not the line breaks', True) + >>> note = Finding("README.md", 3, 84, 80, "// wait until the pane prints it") + >>> "copied" in fix_advice(note, "W.md") + True + >>> line = "/// $ tmux new-session -d -s demo \\" + >>> doced = Finding("a.rs", 4, 90, 80, line, "doc:command") + >>> fix_advice(doced, "W.md").split(":")[0] + 'Split a wide command the way its shell continues' + >>> noted = Finding("a.rs", 5, 90, 80, "/// # Create a session first", "doc:note") + >>> fix_advice(noted, "W.md")[:24] + 'Move the comment out of ' + >>> bare_note = Finding("a.rs", 2, 90, 80, "/// # Create it", "doc:promptless") + >>> fix_advice(bare_note, "W.md")[:24] + 'Move the comment out of ' + >>> labelled = Finding("A.md", 3, 90, 80, "session.name(); // → demo", "") + >>> fix_advice(labelled, "W.md").split(": ")[0] + 'Keep the code and its marker on one line (W.md#what-never-breaks)' + >>> "shorten the code before the marker (W.md#reaching-80)" in fix_advice( + ... labelled, "W.md" + ... ) + True + >>> remark_copy = fix_advice(remark, "W.md").split(". ")[-1] + >>> remark_copy + 'If a Markdown block copies this file, re-copy it as W.md#in-this-repository says.' + >>> command = Finding("a.md", 1, 90, 80, " PS> Get-Item -Path x") + >>> fix_advice(command, "W.md") + 'Split a wide command the way its shell continues: W.md#reaching-80.' + """ + kind, text = finding.kind, finding.text.lstrip() + # A line in a doc-comment fence carries its Markdown kind after "doc:"; + # read it past its comment marker, as the Markdown line would be read. + if kind.startswith("doc:"): + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + kind, text = kind[4:], marker.sub("", finding.text, count=1).lstrip() + reach = f"{rules}#reaching-80" + advice = f"Fix a wide line by changing the code, not the line breaks: {reach}." + # Code blocks keeps explanations out of a console block, in the prose. + prose = ( + f"Move the comment out of the block into the prose around it: " + f"{rules}#code-blocks." + ) + if kind == "note": + return prose + if kind == "promptless": + if text.startswith("# "): + return prose + return ( + "If it is a command, add the prompt its shell takes, `$ ` or `PS> `, " + f"and continue it the way that shell does ({reach}); if it is " + "output, tag the fence `text`." + ) + if kind == "command" or text.startswith(("$ ", "PS> ")): + dialect = "PS> " if text.startswith("PS> ") else "$ " + if scan(text, Shell(dialect))[3] < len(text): + return prose + if len(text.rstrip(" \\`").split()) < 2: + # One token has nowhere to split. + return advice + return f"Split a wide command the way its shell continues: {reach}." + if kind == "doc": + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + text = marker.sub("", finding.text, count=1).lstrip() + # A doctest comment sits behind its prompt. A session line without one is + # heredoc or other data, where # starts no comment. + doctest = DOCTEST.match(text) if kind in ("doc", "session") else None + text = text[doctest.end() :] if doctest else text + commented = kind in ("", "source", "doc", "script") or bool(doctest) + # Markdown blocks and doc-comment fences can be copies of a source file, + # and a source file can be the original a block copies. + copied = ( + " In a block copied from a source file, fix the source, then follow the " + f"copy step that {rules}#in-this-repository gives." + if kind in ("", "doc", "script") + else " If a Markdown block copies this file, re-copy it as " + f"{rules}#in-this-repository says." + if kind == "source" + else "" + ) + if commented and text.startswith(COMMENTS): + if MARKER.match(text.split(None, 1)[1] if " " in text else ""): + return kept_whole(rules) + ": add this one to [[allow]] with its reason." + # Width says comments wrap at 80; What never breaks keeps compared + # output and marker lines whole. + return ( + f"Wrap the comment at 80 ({rules}#width); compared output and marker " + f"lines stay whole ({rules}#what-never-breaks).{copied}" + ) + # A marker after the code it labels, such as `name(); // → demo`, keeps + # its line whole too. + trailing = TRAILING.search(text) if commented else None + if trailing and MARKER.match(trailing["body"]): + return ( + f"Keep the code and its marker on one line ({rules}#what-never-breaks): " + f"shorten the code before the marker ({reach}); add it to [[allow]] only " + "if it still runs past 80." + ) + # A script line is a shell command, which breaks at its continuation. + script = kind == "script" or (kind == "source" and finding.path.endswith(SCRIPTS)) + if script and len(text.rstrip(" \\`").split()) > 1: + paste = ( + " A command a reader pastes goes in a `console` fence with its prompt " + f"({rules}#code-blocks)." + if kind == "script" + else "" + ) + return ( + f"{advice} If it is one command, it may instead continue the way its " + f"shell does.{paste}{copied}" + ) + if kind == "source": + return ( + f"If the port block names a formatter for this file, run it. {advice}" + + copied + ) + return advice + copied + + +# A line edited in place keeps most of its text; a stale entry and a wide line +# that share less are a deleted allowed line and a new wide one. +SIMILAR = 0.6 + + +def resemblance(finding: Finding, entry: dict[str, t.Any]) -> float: + """Return how alike a wide line and a stale entry's line are, 0 to 1. + + >>> line = Finding("a.md", 1, 90, 80, "print(totals, values)") + >>> round(resemblance(line, {"line": "print(total, values)"}), 2) + 0.98 + """ + return alike(finding.text, entry["line"]) + + +def alike(one: str, other: str) -> float: + """Return how alike two lines are, 0 to 1. + + >>> alike("abcd", "abce") + 0.75 + """ + import difflib + + return difflib.SequenceMatcher(None, one, other).ratio() + + +def edit_pairs( + findings: list[Finding], + stale: list[dict[str, t.Any]], + bad_path: str | None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[tuple[Finding, dict[str, t.Any]]]: + """Pair stale entries with the wide lines that kept most of their text. + + Each stale entry and each wide line joins at most one pair, closest + first, so two allowed lines edited in one file make two pairs; the rest + of the wide lines are new. + + >>> stale = [{"path": "a.md", "line": "alpha(" + "x" * 80 + ")"}] + >>> stale.append({"path": "a.md", "line": "beta(" + "y" * 80 + ")"}) + >>> one = Finding("a.md", 3, 90, 80, "alpha(" + "x" * 82 + ")") + >>> two = Finding("a.md", 7, 90, 80, "beta(" + "y" * 83 + ")") + >>> new = Finding("a.md", 9, 90, 80, "z" * 90) + >>> pairs = edit_pairs([two, new, one], stale, None) + >>> [(f.number, e["line"][:4]) for f, e in pairs] + [(7, 'beta'), (3, 'alph')] + >>> edit_pairs([one], stale, "a.md") + [] + >>> edit_pairs([one], stale, None, shortened=stale) + [] + """ + scored = sorted( + ( + (resemblance(f, e), i, j) + for i, f in enumerate(findings) + for j, e in enumerate(stale) + if f.path == e["path"] and f.path != bad_path and e not in (shortened or []) + ), + reverse=True, + ) + pairs, taken_lines, taken_entries = [], set(), set() + for score, i, j in scored: + if score < SIMILAR: + break + if i in taken_lines or j in taken_entries: + continue + taken_lines.add(i) + taken_entries.add(j) + pairs.append((i, findings[i], stale[j])) + return [(finding, entry) for _, finding, entry in sorted(pairs, key=lambda p: p[0])] + + +def edited_finding( + findings: list[Finding], stale: list[dict[str, t.Any]], bad_path: str | None +) -> Finding | None: + """Return the first wide line that is an edited allowed line, if any. + + >>> stale = [{"path": "a.md", "line": "print(total, " + "x" * 80 + ")"}] + >>> new = Finding("a.md", 2, 90, 80, "y" * 90) + >>> edit = Finding("a.md", 9, 95, 80, "print(totals, " + "x" * 80 + ")") + >>> edited_finding([new, edit], stale, None).number + 9 + >>> edited_finding([new, edit], stale, "a.md") is None + True + >>> far = Finding("a.md", 3, 92, 80, "print(total, " + "y" * 20 + "x" * 60 + ")") + >>> edited_finding([far, edit], stale, None).number + 9 + """ + pairs = edit_pairs(findings, stale, bad_path) + return pairs[0][0] if pairs else None + + +def shortened_entries( + root: pathlib.Path, + stale: list[dict[str, t.Any]], + width: int, + tab_width: int, +) -> list[dict[str, t.Any]]: + """Return stale entries whose line was shortened to fit since the last commit. + + Such an entry's file holds a line that fits, resembles the entry and is + not in the committed file. A wide line that also resembles the entry is + then new, not the allowed line edited, so it gets an entry of its own. + """ + found = [] + for entry in stale: + path = str(entry["path"]) + shown = subprocess.run( + ["git", "-C", str(root), "show", f"HEAD:{path}"], + capture_output=True, + text=True, + check=False, + ) + if shown.returncode != 0: + continue + before = {line.rstrip() for line in shown.stdout.splitlines()} + if any( + line not in before + and columns(line.expandtabs(tab_width)) <= width + and alike(line, str(entry["line"])) >= SIMILAR + for line in source_lines(root, entry) + ): + found.append(entry) + return found + + +def source_lines(root: pathlib.Path, entry: dict[str, t.Any]) -> set[str]: + """Return the stripped lines of an allow entry's file, or none if unreadable.""" + try: + text = (root / str(entry["path"])).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return set() + return {line.rstrip() for line in text.splitlines()} + + +def entry_name(entry: dict[str, t.Any], entries: list[dict[str, t.Any]]) -> str: + r"""Name an allow entry by its path, and by its line when the path has two. + + >>> one = {"path": "a.md", "line": "first line"} + >>> entry_name(one, [one]), entry_name(one, [one, {"path": "a.md", "line": "x"}]) + ('a.md', "a.md whose line begins 'first line'") + >>> indented = {"path": "a.md", "line": " first line"} + >>> entry_name(indented, [indented, one]) + "a.md whose line begins 'first line'" + >>> long = {"path": "a.md", "line": "print(total, first + second + third + fourth)"} + >>> entry_name(long, [long, one]) + "a.md whose line begins 'print(total, first +'" + >>> path = {"path": "a.ps1", "line": r"C:\Users\me\AppData\Local\Temp\run.ps1"} + >>> entry_name(path, [path, {"path": "a.ps1", "line": "x"}]) + "a.ps1 whose line begins 'C:\\Users\\me\\AppData\\Local\\Temp'" + """ + if sum(e.get("path") == entry["path"] for e in entries) < 2: + return str(entry["path"]) + text = entry["line"].strip() + if len(text) > 32: + # Cut at a space, or else before a backslash, so the quoted start + # never ends inside an escape. + space, slash = text.find(" ", 20), text.rfind("\\", 20, 33) + if 0 < space <= 40: + text = text[:space] + else: + text = text[:slash].rstrip("\\") if slash > 0 else text[:32] + return f"{entry['path']} whose line begins {quoted(text)}" + + +def quoted(text: str) -> str: + r"""Return a line in single quotes, as a TOML literal string writes it. + + A line that holds a single quote falls back to an escaped basic string. + + >>> print(quoted(r'"C:\Windows"'), quoted("it's")) + '"C:\Windows"' "it's" + """ + return f"'{text}'" if "'" not in text else literal(text) + + +def ceiling_hints( + findings: list[Finding], + stale: list[dict[str, t.Any]], + count: int, + bad_path: str | None = None, + hidden: list[dict[str, t.Any]] | None = None, + entries: list[dict[str, t.Any]] | None = None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[str]: + r"""Return what to change in the allow list after a failed run. + + A stale entry in a file that also has a wide line is an edited allowed + line, so its entry takes the new text. A stale entry for ``bad_path``, + the WRITING.md whose Bad example now fits, asks for a wide Bad line + again. Every other stale entry goes, and the last line gives the + ceiling all of that leaves. + + >>> wide = Finding("a.md", 3, 90, 80, "x" * 90) + >>> ceiling_hints([wide], [], 1) + ['Set allow_ceiling to 1 plus one for each line you add to [[allow]].'] + >>> edited = {"path": "a.md", "line": "x" * 88} + >>> for hint in ceiling_hints([wide], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 1. + >>> gone = {"path": "b.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [edited, gone], 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Delete the stale allow entry for b.md. + Lower allow_ceiling to 1. + >>> kept = {"path": "b.md", "line": "kept"} + >>> ceiling_hints([], [gone], 2, entries=[gone, kept])[0] + "Delete the stale allow entry for b.md whose line begins 'old'." + >>> second = Finding("a.md", 5, 88, 80, "z" * 88) + >>> ceiling_hints([wide, second], [edited], 1)[-1] + 'Set allow_ceiling to 1 plus one for each line you add to [[allow]].' + >>> other = Finding("c.md", 7, 85, 80, "y" * 85) + >>> for hint in ceiling_hints([wide, other], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + >>> both = [edited, {"path": "a.md", "line": "y" * 88}] + >>> lines = [wide, Finding("a.md", 5, 90, 80, "y" * 90)] + >>> for hint in ceiling_hints(lines, both, 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 2. + >>> kept = {"path": "a.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [kept], 1, hidden=[kept]): + ... print(hint.split(";")[0]) + a.md: the allowed line is still there but the check no longer reads it as wide + allow_ceiling stays 1. + >>> unrelated = {"path": "a.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [unrelated], 1): + ... print(hint) + Delete the stale allow entry for a.md. + Set allow_ceiling to 0 plus one for each line you add to [[allow]]. + >>> bad = {"path": "WRITING.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + allow_ceiling stays 1. + >>> other = Finding("WRITING.md", 9, 85, 80, "x" * 85) + >>> for hint in ceiling_hints([other], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + """ + pairs = edit_pairs(findings, stale, bad_path, shortened) + edited = [entry for _, entry in pairs] + hints = [ + f"{entry['path']}: an allowed line changed; put its new text in the " + "stale entry." + for entry in edited + ] + restored = [entry for entry in stale if entry["path"] == bad_path] + hints += [ + f"{bad_path}: restore a Bad example past 80; its [[allow]] entry quotes it." + ] * bool(restored) + # An allowed line still in its file was hidden by a config change, not + # fixed: deleting its entry would only weaken the gate. + hidden = [entry for entry in hidden or [] if entry in stale] + hints += [ + f"{entry_name(e, entries or stale)}: the allowed line is still there but " + "the check no longer reads it as wide; undo the change that hid it: a " + "glob, [[exclude]] or tab_width edit, or a fence tag or comment marker " + "around the line." + for e in hidden + ] + gone = [e for e in stale if e not in edited + restored + hidden] + hints += [ + f"Delete the stale allow entry for {entry_name(e, entries or stale)}." + for e in gone + ] + target = count - len(gone) + # Every wide line not paired with a stale entry is new. + if len(findings) > len(pairs): + hints.append( + f"Set allow_ceiling to {target} plus one for each line you add " + "to [[allow]]." + ) + elif target == count: + hints.append(f"allow_ceiling stays {count}.") + else: + hints.append(f"Lower allow_ceiling to {target}.") + return hints + + +def digests(root: pathlib.Path) -> list[str]: + """Return ``checker `` and ``shared `` lines. + + The shared digest covers the text between the markers in WRITING.md; + ``shared missing`` means no WRITING.md carries them. + """ + checker = hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest() + shared = "missing" + writing = writing_path(root) + if writing is not None: + match = SHARED.search(writing.read_text(encoding="utf-8")) + if match: + shared = hashlib.sha256(match["body"].encode()).hexdigest() + return [f"checker {checker}", f"shared {shared}"] + + +def digest_problems(root: pathlib.Path, config: dict[str, t.Any]) -> list[str]: + """Return digests that differ from the ones the configuration records. + + Each message names the file that changed. Both files are the same in + every port, so an intended change goes to all of them; an edit meant for + one repository is undone instead. + """ + actual = dict(line.split(" ", 1) for line in digests(root)) + here, top = pathlib.Path(__file__).resolve(), root.resolve() + script = here.relative_to(top) if here.is_relative_to(top) else here.name + writing = writing_file(root) + shared = writing.relative_to(root).as_posix() if writing else "WRITING.md" + where = {"checker": script, "shared": shared} + names = {"checker": "this checker", "shared": "the shared section"} + undo = { + "checker": f"it (`git checkout -- {script}`, or a copy from another port)", + "shared": ( + "the text between the shared markers, copied from " + f"`git show HEAD:{shared}` so port-block edits stay, or from another " + "port once the edit is committed" + ), + } + # A digest written below a [[...]] header or misspelled is reported as + # an unknown key already. + nested = { + key + for table in TABLE_KEYS + for entry in config.get(table, []) + if isinstance(entry, dict) + for key in entry + } | {meant(key) for key in set(config) - KEYS} + missing = [ + f"{CONFIG}: {name}_digest is missing; run `python3 {script} --digest` " + f"and set {name}_digest to the {name} value it prints" + for name in ("checker", "shared") + if name + "_digest" not in config and name + "_digest" not in nested + ] + return missing + [ + f"{where[name]}: {names[name]} has digest {actual[name][:12]}, but " + f"{name}_digest in {CONFIG} records {config[name + '_digest'][:12]}.\n" + f" To undo an edit meant for this repository only, restore {undo[name]}.\n" + " To change it everywhere, change libtmux/docs and each repository " + f"listed in {PORTS}, which also names where each keeps this checker; " + "run each copy with `--digest` and set checker_digest and " + "shared_digest to the values it prints." + for name in ("checker", "shared") + if name + "_digest" in config + and actual[name] not in (config[name + "_digest"], "missing") + ] + + +def self_test() -> int: + """Run the doctests above, plus planted failures and clean controls.""" + import contextlib + import doctest + import io + import tempfile + + failures, _ = doctest.testmod(sys.modules[__name__]) + + def expect(condition: bool, message: str) -> None: + nonlocal failures + if not condition: + print(f"self-test: {message}", file=sys.stderr) + failures += 1 + + with tempfile.TemporaryDirectory() as scratch: + root = pathlib.Path(scratch) + wide = "let total = " + " + ".join(["value"] * 12) + ";" + (root / "README.md").write_text(f"```rust\n{wide}\n```\n") + config: dict[str, t.Any] = {"width": 60, "markdown": ["README.md"]} + planted, _ = check(root, config, ["README.md"]) + expect(len(planted) == 1, "a planted wide line was not reported") + if planted: + pasted = tomllib.loads(allow_hint(planted[0]))["allow"][0] + expect(pasted["line"] == wide, "the allow hint does not round-trip") + excluded = dict(config, exclude=[{"glob": "README.md", "reason": "r"}]) + expect(not check(root, excluded, ["README.md"])[0], "an exclusion was read") + config["allow"] = [{"path": "README.md", "line": wide, "reason": "test"}] + clean, stale = check(root, config, ["README.md"]) + expect(not clean and not stale, "an allowed line was reported") + url = "https://example.com/" + "x" * 80 + hidden = ">>> setup = " + "x" * 70 + " # doctest: +HIDE" + (root / "skip.md").write_text(f"```\n{url}\n```\n```python\n{hidden}\n```\n") + skipped, _ = check(root, {"width": 80, "markdown": ["skip.md"]}, ["skip.md"]) + expect(not skipped, "a URL line or a hidden doctest line was reported") + body = "x" * 90 + (root / "here.md").write_text( + f"```console\n$ cat < one failed", + ) + (root / "page.rst").write_text(".. code-block:: sh\n\n " + "x" * 81 + "\n") + rst, _ = check(root, {"width": 80, "markdown": ["page.rst"]}, ["page.rst"]) + expect(len(rst) == 1, "a wide line in a reST code block passed") + config["allow"].append({"path": "README.md", "line": "gone", "reason": "x"}) + _, stale = check(root, config, ["README.md"]) + expect(len(stale) == 1, "a stale allow entry was not reported") + tabbed = "\t" + wide + (root / "main.go").write_text(tabbed + "\n") + config = {"width": 60, "sources": ["*.go"]} + config["allow"] = [{"path": "main.go", "line": tabbed, "reason": "test"}] + clean, stale = check(root, config, ["main.go"]) + expect(not clean and not stale, "a raw tabbed allow entry did not match") + (root / "tab.go").write_text("\t" + "x" * 78 + "\n") + tabbed_config = {"width": 80, "tab_width": 4, "sources": ["tab.go"]} + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(len(expanded) == 1, "a tab counted as one column") + (root / "tab.go").write_text("\t" + "x" * 76 + "\n") + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(not expanded, "a tabbed line within 80 was reported") + (root / "fmt.toml").write_text("edition = 1\nmax_width = 100\n") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + pair = {"copy": "copy.toml", "root": "fmt.toml", "key": "max_width"} + copies = {"formatter_copies": [pair]} + expect(not copy_problems(root, copies), "a width-only copy was reported") + wider_copies = ( + "max_width = 120", + "max_width = 100 # was 80", + "# max_width = 80", + "# keep max_width = 80\nmax_width = 100", + "max_width = 80\nmax_width = 120", + "max_width = 80, max_width = 120", + ) + for wider in wider_copies: + (root / "copy.toml").write_text(f"edition = 1\n{wider}\n") + expect(bool(copy_problems(root, copies)), f"a copy with {wider!r} passed") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80, max_width = 120\n" + ) + doubled = copy_problems(root, copies) + expect(bool(doubled) and "2 times" in doubled[0], "a doubled width passed") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80 # was 120\n") + expect(not copy_problems(root, copies), "a commented width was read twice") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80 # the root sets max_width = 100\n" + ) + expect( + not copy_problems(root, copies), "a width named in a comment was counted" + ) + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + (root / "copy.toml").write_text("edition = 2\nmax_width = 80\n") + expect(bool(copy_problems(root, copies)), "a drifted copy was not reported") + (root / "fmt.json").write_text('{\n "lineLength": 100, "indent": 2\n}\n') + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 9\n}\n') + same_line = {"copy": "copy.json", "root": "fmt.json", "key": "lineLength"} + expect( + bool(copy_problems(root, {"formatter_copies": [same_line]})), + "a setting changed on the width line passed", + ) + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 2\n}\n') + expect( + not copy_problems(root, {"formatter_copies": [same_line]}), + "a copy differing only in its width value was reported", + ) + expect(digests(root)[1] == "shared missing", "a missing section was digested") + shared = "\nrule\n\n" + (root / "WRITING.md").write_text(shared) + expect(digests(root)[1] != "shared missing", "a shared section was missed") + headless = routing_problems(root) + expect( + bool(headless) and "no heading" in headless[0], + "a shared section with no heading above passed", + ) + checker_now = digests(root)[0].split(" ", 1)[1] + recorded: dict[str, t.Any] = { + "checker_digest": checker_now, + "shared_digest": "0" * 64, + } + changed = digest_problems(root, recorded) + expect( + bool(changed) and "or from another port" in changed[0], + "a shared-section change was missed or offered only HEAD to restore", + ) + shared_now = digests(root)[1].split(" ", 1)[1] + recorded = {"checker_digest": checker_now, "shared_digest": shared_now} + expect(not digest_problems(root, recorded), "a matching digest was reported") + below = {"path": "a", "line": "b", "reason": "r"} + below["checker_digest"] = recorded.pop("checker_digest") + recorded["allow"] = [below] + expect( + not digest_problems(root, recorded), + "a digest below [[allow]] was also reported missing", + ) + del recorded["allow"] + recorded["checker_digset"] = below["checker_digest"] + expect( + not digest_problems(root, recorded), + "a misspelled digest key was also reported missing", + ) + del recorded["checker_digset"] + expect(bool(digest_problems(root, recorded)), "a missing digest passed") + (root / "WRITING.md").write_text("## Examples\n\n" + shared) + alone = root / "alone" / ".github" + alone.mkdir(parents=True) + section = "## Examples\n\n\n" + (alone / "WRITING.md").write_text(section) + broken = marker_problems(alone.parent) + expect( + bool(broken) and ".github/WRITING.md" in broken[0], + "a missing closing marker passed or named the wrong file", + ) + (alone / "WRITING.md").write_text(section + "x\n\n") + expect(not marker_problems(alone.parent), "balanced markers were reported") + body = " echo " + "x" * 80 + loop = f"```console\n$ for x in a; do\n{body}\ndone\n{'y' * 81}\n```\n" + (root / "loop.md").write_text(loop) + looped, _ = check(root, {"width": 80, "markdown": ["loop.md"]}, ["loop.md"]) + expect( + [finding.number for finding in looped] == [3], + "a wide loop body passed, or printed output was measured", + ) + expect(bool(routing_problems(root)), "a missing AGENTS.md link passed") + (root / "AGENTS.md").write_text("Code examples: WRITING.md#examples\n") + expect(bool(routing_problems(root)), "a bare anchor counted as a link") + (root / "AGENTS.md").write_text("[Examples](docs/WRITING.md#examples)\n") + expect(bool(routing_problems(root)), "a link to a missing file passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#other)\n") + expect(bool(routing_problems(root)), "a link to the wrong heading passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + expect(not routing_problems(root), "a linked section was reported") + (root / "AGENTS.md").write_text("[Examples]()\n") + expect(not routing_problems(root), "an angle-bracket link was reported") + (root / "AGENTS.md").write_text("[Examples][ex]\n\n[ex]: WRITING.md#examples\n") + expect(not routing_problems(root), "a reference-style link was reported") + (root / "AGENTS.md").write_text("[Examples](/WRITING.md#examples)\n") + expect(not routing_problems(root), "a root-relative link was reported") + for hidden_link in ( + "- Code examples:\n\n {}\n", + "```\n{}\n```\n", + "`{}`\n", + "\n", + "!{}\n", + "\\{}\n", + ): + link = "[Examples](WRITING.md#examples)" + (root / "AGENTS.md").write_text(hidden_link.format(link)) + expect(bool(routing_problems(root)), f"a link in {hidden_link!r} passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + (root / "AGENTS.md").unlink() + gone = routing_problems(root) + expect( + bool(gone) and "is missing" in gone[0], "a missing AGENTS.md was unnamed" + ) + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + labels = "\n".join(label + " x" for label in PORT_LABELS) + wide = "```text\n" + "x" * 81 + "\n```" + head = f"### In this repository\n\n{labels}\n\n" + block = head + f"Bad, over 80:\n\n{wide}\n\nGood, x:\n" + expect(not port_block_problems(block), "a complete port block was reported") + twice = block + f"\nBad, over 80:\n\n{wide}\n" + expect(bool(port_block_problems(twice)), "a second Bad example passed") + missing = block.replace(PORT_LABELS[2], "- **Other:**") + expect( + bool(port_block_problems(missing)), "a port block missing a label passed" + ) + fenced = "## Examples\n\n```sh\n# a comment\n```\n\n" + shared + (root / "WRITING.md").write_text(fenced) + expect(not routing_problems(root), "a fenced comment read as a heading") + nested = tomllib.loads( + 'allow_ceiling = 0\n[[exclude]]\nglob = "a"\nreason = "r"\n' + 'doc_comments = ["a"]\n' + ) + nested["markdown"] = ["a"] + expect( + any("doc_comments" in p for p in config_problems(nested, ["a"])), + "a key nested under [[exclude]] passed", + ) + del nested["exclude"][0]["doc_comments"] + expect(not config_problems(nested, ["a"]), "a clean [[exclude]] was reported") + nested["exclude"][0]["reason"] = " " + expect( + any("no reason" in p for p in config_problems(nested, ["a"])), + "an exclusion without a reason passed", + ) + table = {"exclude": {"glob": "a", "reason": "r"}} + expect(bool(type_problems(table)), "[exclude] written as one table passed") + typed = {"width": "80", "markdown": "**/*.md"} + expect(len(type_problems(typed)) == 2, "a string width or glob list passed") + listed = {"allow": [{"path": ["a"], "line": "b", "reason": "c"}]} + expect(bool(type_problems(listed)), "a list where a string belongs passed") + absent = {"copy": "gone.toml", "root": "fmt.toml", "key": "max_width"} + expect( + bool(copy_problems(root, {"formatter_copies": [absent]})), + "a missing formatter copy passed", + ) + expect(not type_problems({"width": 80, "markdown": ["a"]}), "good types failed") + misnamed: dict[str, t.Any] = { + "markdown": ["README.md"], + "exclude": [{"path": "README.md", "reason": "r"}], + "allow": [{"file": "README.md", "line": wide, "reason": "r"}], + "formatter_copies": [{"copy": "copy.toml", "root": "fmt.toml"}], + "allow_ceiling": 1, + } + found = " ".join(config_problems(misnamed, ["README.md"])) + expect( + all(f"has no {key}" in found for key in ("glob", "path", "key")), + "an entry missing a key the check reads passed", + ) + try: + check(root, misnamed, ["README.md"]) + copy_problems(root, misnamed) + except KeyError as error: + expect(False, f"an entry missing {error} crashed the check") + entry = {"path": "a", "line": "b", "reason": "c"} + ceiling: dict[str, t.Any] = {"allow": [entry, entry], "allow_ceiling": 1} + grown = config_problems(ceiling, ["a"]) + expect( + bool(grown) and "delete the new entry" in grown[0], + "a grown allow list passed or was not told to delete the new entry", + ) + ceiling = {"allow": [entry], "allow_ceiling": 2} + expect(bool(config_problems(ceiling, ["a"])), "a shrunk list kept its ceiling") + ceiling = {"allow": [entry], "allow_ceiling": 1} + expect(not config_problems(ceiling, ["a"]), "an exact ceiling was reported") + with tempfile.TemporaryDirectory() as scratch: + top = pathlib.Path(scratch) + git = ["git", "-C", scratch, "-c", "init.defaultBranch=main"] + subprocess.run([*git, "init", "-q"], check=True) + (top / ".github").mkdir() + (top / "README.md").write_text("x\n") + subprocess.run([*git, "add", "README.md"], check=True) + (top / "LINKED.md").symlink_to("README.md") + subprocess.run([*git, "add", "LINKED.md"], check=True) + expect( + tracked_files(top) == ["README.md"], + "a tracked symbolic link was read beside its target", + ) + + def run(config_text: str) -> str: + (top / CONFIG).write_text(config_text) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = main(["--root", scratch]) + expect(code == 1, "a broken config passed") + return out.getvalue() + + expect( + "fix the config first" in run('width = "80"\n'), + "a string width reached the width pass", + ) + expect( + "cannot read the config" in run("width = [\n"), + "a config that is not TOML went unreported", + ) + misspelled = ( + 'markdwn = ["*.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "x"\nreason = "r"\n' + ) + expect( + "0 stale allow entries" in run(misspelled), + "a misspelled glob key made an allow entry look stale", + ) + allowed = "print(total, " + "x" * 80 + ")" + edited = allowed.replace("total", "totals") + (top / "README.md").write_text(f"```py\n{'y' * 90}\n{edited}\n```\n") + config_text = ( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + advice = run(config_text) + expect( + advice.count(f'line = "{edited}"') == 1 + and f'[[allow]]\npath = "README.md"\nline = "{"y" * 90}"' in advice, + "an edited allowed line was paired with another wide line, or the " + "new wide line got no entry", + ) + expect( + "\nREADME.md: an allowed line changed; its entry says why it stays" + in "\n" + advice + and "give it an entry of its own" in advice + and "Fix a wide line by changing the code" in advice, + "an edited allowed line was told to change its code", + ) + (top / "README.md").write_text(f"```go\n// Output: {'o' * 80}\n```\n") + marked = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + marked.split("\n[[allow]]")[0].count("[[allow]]") == 1 + and "#what-never-breaks); add the line to [[allow]]" in marked, + "a marker line's advice named [[allow]] twice", + ) + (top / "README.md").write_text(f"```py\nprint({'w' * 80})\n```\n") + plain = "\n" + run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "\nFix a wide line by changing the code" in plain + and "\nIf no change keeps the example clear, add the line to" in plain, + "a wide line's advice and its allow fallback were not on lines of " + "their own", + ) + with tempfile.TemporaryDirectory() as other: + repo = pathlib.Path(other) + sub = ["git", "-C", other, "-c", "user.name=t", "-c", "user.email=t@t"] + subprocess.run([*sub, "init", "-q"], check=True) + allowed_line = "print(total, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{allowed_line}\n```\n") + subprocess.run([*sub, "add", "README.md"], check=True) + subprocess.run([*sub, "commit", "-qm", "x"], check=True) + # The allowed line is shortened to fit, and a new wide line that + # resembles it is added: the entry goes, the new line gets its own. + fits = "print(total, " + "x" * 40 + ")" + new = "print(totals, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{fits}\n{new}\n```\n") + (repo / ".github").mkdir() + (repo / CONFIG).write_text( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed_line}"\nreason = "r"\n' + ) + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + told = out.getvalue() + expect( + "an allowed line changed" not in told + and f"names a line shortened to fit in 80: '{allowed_line}'" in told + and "Delete the stale allow entry for README.md." in told + and "Set allow_ceiling to 0 plus one" in told, + "a new wide line beside a shortened allowed line was taken for " + "its edit", + ) + # A short similar line already committed is not a shortening: an + # edit to the allowed line still takes its entry. + (repo / "README.md").write_text(f"```py\n{fits}\n{allowed_line}\n```\n") + subprocess.run([*sub, "commit", "-qam", "y"], check=True) + edited_line = allowed_line.replace("total", "totals") + (repo / "README.md").write_text(f"```py\n{fits}\n{edited_line}\n```\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + expect( + "an allowed line changed" in out.getvalue(), + "an edited allowed line beside a committed short line lost its entry", + ) + (top / "lib.rs").write_text("/// ```console\n/// $ ls `\n/// -l\n/// ```\n") + subprocess.run([*git, "add", "lib.rs"], check=True) + shells = run('doc_comments = ["lib.rs"]\nallow_ceiling = 0\n') + expect( + "lib.rs:2: this `$ ` command is continued with a backtick" in shells, + "a doc-comment console fence continued the other shell's way passed", + ) + subprocess.run([*git, "rm", "-q", "--cached", "lib.rs"], check=True) + (top / "lib.rs").unlink() + labelled = f"session.name(); // → {'d' * 80}" + (top / "README.md").write_text(f"```java\n{labelled}\n```\n") + told = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "Keep the code and its marker on one line" in told + and told.split("\n[[allow]]")[0].count("[[allow]]") == 1, + "a trailing marker line's advice named [[allow]] twice", + ) + output = f"// Output: {'o' * 80}" + (top / "README.md").write_text(f"```go\n{edited}\n{output}\n```\n") + marked = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "#what-never-breaks); add the first other wide line to [[allow]]" in marked, + "a marker line after an edited allowed line named [[allow]] twice", + ) + second = "print(count, " + "z" * 80 + ")" + moved = second.replace("count", "counts") + (top / "README.md").write_text(f"```py\n{edited}\n{moved}\n```\n") + both = run( + 'markdown = ["README.md"]\nallow_ceiling = 2\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + f'[[allow]]\npath = "README.md"\nline = "{second}"\nreason = "r"\n' + ) + expect( + "[[allow]]" not in both + and f'line = "{moved}"' in both + and "allow_ceiling stays 2" in both, + "two allowed lines edited in one file were not both paired", + ) + (top / "notes.md").write_text("x\n") + subprocess.run([*git, "add", "notes.md"], check=True) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + unread = run( + 'markdown = ["notes.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "no longer reads it as wide" in unread and "Delete" not in unread, + "an allowed line a glob change hid was offered for deletion", + ) + (top / "README.md").write_text("```py\nprint(1)\n```\n") + fits = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "print(1)"\nreason = "r"\n' + ) + expect( + "Delete the stale allow entry for README.md" in fits + and "names a line that now fits in 80" in fits + and "no longer reads" not in fits, + "an allowed line that now fits was not offered for deletion", + ) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + (top / "examples").mkdir() + (top / "examples" / "new.txt").write_text("x\n") + expect( + "examples/new.txt: untracked, under examples/ but selected by no glob; " + '`git add` it, then add "examples/new.txt" to sources' + in run('markdown = ["README.md"]\nallow_ceiling = 0\nwidth = 1\n'), + "an untracked, unselected example file went unnamed", + ) + (top / "README.md").write_text("```console\n$ ls `\n -l\n```\n") + expect( + "is continued with a backtick" + in run('markdown = ["README.md"]\nallow_ceiling = 0\n'), + "a run left a `$ ` command continued with a backtick unreported", + ) + print("self-test:", "failed" if failures else "ok") + return 1 if failures else 0 + + +def main(argv: list[str] | None = None) -> int: + """Check the repository, or run the self-test.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--self-test", action="store_true", help="prove the checker can fail" + ) + parser.add_argument( + "--digest", + action="store_true", + help="print digests of this checker and the shared WRITING.md section", + ) + parser.add_argument( + "--root", type=pathlib.Path, help="repository root (default: git toplevel)" + ) + args = parser.parse_args(argv) + if args.self_test: + return self_test() + root = args.root or pathlib.Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + if args.digest: + for line in digests(root): + print(line) + return 0 + try: + config = tomllib.loads((root / CONFIG).read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + print(f"{CONFIG}: cannot read the config: {error}") + print("1 setup problem; fix the config first.") + return 1 + wrong = type_problems(config) + if wrong: + for problem in wrong: + print(f"{CONFIG}: {problem}") + print(f"{plural(len(wrong), 'setup problem')}; fix the config first.") + return 1 + files = tracked_files(root) + writing = writing_path(root) + # Each problem names the file to change; routing problems name their own. + setup = config_problems(config, files) + problems = ( + [f"{CONFIG}: {problem}" for problem in setup] + + copy_problems(root, config) + + digest_problems(root, config) + + marker_problems(root) + + routing_problems(root) + ) + new = untracked_matches(config, tracked_files(root, others=True)) + chosen = [path for path in new if selected(config, path)] + shown = ", ".join(chosen[:3]) + (", ..." if len(chosen) > 3 else "") + notice = ( + f"Not checked until `git add`: {plural(len(chosen), 'untracked file')} " + f"the config selects ({shown})." + if chosen + else "" + ) + # An untracked file does not fail the run, but its wide lines are shown + # so a new example can be fixed before it is added. + pending, _ = check(root, config, chosen) if chosen and not setup else ([], []) + if pending: + they = "it fails" if len(chosen) == 1 else "they fail" + notice += f" Once added, {they} on {plural(len(pending), 'wide line')}: " + ( + "; ".join(str(finding) for finding in pending[:3]) + ) + notice = "\n".join( + [notice] * bool(notice) + + [ + f"{path}: untracked, under examples/ but selected by no glob; `git add` " + "it, then " + select_advice(path) + for path in new + if path not in chosen + ] + ) + bad_path = None + if writing: + where = writing.relative_to(root).as_posix() + text = writing.read_text(encoding="utf-8") + block = port_block_problems(text) + problems += [f"{where}: {problem}" for problem in block] + bad_path = where if any("Bad example fits" in p for p in block) else None + # A config problem such as a misspelled glob key would make allow entries + # look stale, so the width pass waits until the config is right. + findings, stale = check(root, config, files) if not setup else ([], []) + problems += prompt_problems(root, config, files) if not setup else [] + annotate = os.environ.get("GITHUB_ACTIONS") == "true" + for problem in problems: + print(problem) + for finding in findings: + print(finding) + if annotate: + print( + f"::error file={finding.path},line={finding.number}::" + f"{finding.columns} columns > {finding.width}" + ) + # A line still in its file is hidden only while it is still wide; one that + # now fits needs its entry deleted. + width, tab_width = config.get("width", 80), config.get("tab_width", 4) + present = [entry for entry in stale if entry["line"] in source_lines(root, entry)] + hidden = [ + entry + for entry in present + if columns(entry["line"].expandtabs(tab_width)) > width + ] + absent = [entry for entry in stale if entry not in present] + shortened = shortened_entries(root, absent, width, tab_width) if absent else [] + for entry in stale: + how = ( + "names a line the check no longer reads" + if entry in hidden + else f"names a line that now fits in {width}" + if entry in present + else f"names a line shortened to fit in {width}" + if entry in shortened + else "matches no line" + ) + print( + f"{CONFIG}: allow entry for {entry['path']} {how}: {quoted(entry['line'])}" + ) + if problems or findings or stale: + page = writing or writing_file(root) + rules = page.relative_to(root).as_posix() if page else "WRITING.md" + print( + f"{plural(len(problems), 'problem')}, " + f"{plural(len(findings), 'wide line')}, " + f"{plural(len(stale), 'stale allow entry', 'stale allow entries')}." + ) + if setup: + print(f"The width check runs once the problems in {CONFIG} are fixed.") + edited = "" + # Name an entry by its line when its file has two allow entries. + allowed = [e for e in config.get("allow", []) if isinstance(e, dict)] + if findings: + first = findings[0] + many = len(findings) > 1 + pairs = edit_pairs(findings, stale, bad_path, shortened) + if pairs: + # An allowed line changed: its entry already says why it stays + # whole, so it takes the new text. + first, edited = pairs[0][0], pairs[0][0].path + named = entry_name(pairs[0][1], allowed) + print( + f"{edited}: an allowed line changed; its entry says why it stays " + f"whole. Put its new text in the stale entry for {named} (if the " + "line is new instead, give it an entry of its own):" + ) + else: + lead = ( + f"The first of {len(findings)}, as an entry:" + if many + else "As an entry:" + ) + advice = fix_advice(first, rules) + if "[[allow]]" in advice and not advice.startswith(kept_whole(rules)): + # The advice already says when to allow the line. + print(advice) + print(lead) + elif advice.startswith(kept_whole(rules)): + # The rule itself says to allow it; say [[allow]] once. + print( + f"{kept_whole(rules)}; add the line to [[allow]] in " + f"{CONFIG} with its reason. {lead}" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the line to " + f"[[allow]] in {CONFIG} with a reason. {lead}" + ) + hint = allow_hint(first) + print(hint.splitlines()[2] if edited else hint) + for finding, entry in pairs[1:]: + print(f"In the stale entry for {entry_name(entry, allowed)}, put:") + print(allow_hint(finding).splitlines()[2]) + paired = [finding for finding, _ in pairs] + others = [finding for finding in findings if finding not in paired] + if edited and others: + advice = fix_advice(others[0], rules) + if advice.startswith(kept_whole(rules)): + print( + f"{kept_whole(rules)}; add the first other wide line to " + f"[[allow]] in {CONFIG} with its reason:" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the first " + f"other wide line to [[allow]] in {CONFIG}:" + ) + print(allow_hint(others[0])) + if findings or stale: + count = len(config.get("allow", [])) + for hint in ceiling_hints( + findings, stale, count, bad_path, hidden, allowed, shortened + ): + if not (edited and "an allowed line changed" in hint): + print(hint) + if notice: + print(notice) + return 1 + read = [g for key in GLOB_KEYS for g in config.get(key, [])] + skip = [entry["glob"] for entry in entries(config, "exclude")] + count = sum(matches(path, read) and not matches(path, skip) for path in files) + print(f"{count} files checked; no example is over {config.get('width', 80)}.") + if notice: + print(notice) + return 0 + + +if __name__ == "__main__": + sys.exit(main())