From 14dda743fc2d7a08b374eb56d74d4cacaacf8bac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:31 -0500 Subject: [PATCH 01/18] Examples(style): Format examples at 80 columns why: Examples render in fixed-width boxes that scroll sideways, so StyLua formats them at 80 columns while the rest of the source keeps 100. StyLua 2.4.0 (the version ci.yml installs) takes the width from the nearest config: it starts in the directory of the file being formatted and searches upward until it reaches the directory it was run from, so an `examples/stylua.toml` overrides the root file for examples only and the existing `stylua --check` command needs no new flags. https://github.com/JohnnyMorganz/StyLua/blob/v2.4.0/README.md#L272-L274 https://github.com/JohnnyMorganz/StyLua/blob/v2.4.0/README.md#L314 `column_width` is a guide, not a hard limit ("lines may fall under or over the limit"), so the limit needs a separate width check. Alternatives: a root column_width of 80 would reflow lua/ and packages/ for no reader benefit; a `--config-path` flag on one CI step would split the format gate in two and leave local runs unconfigured; a `.editorconfig` is only read when no stylua.toml is found. what: - Add examples/stylua.toml, a copy of the root config with column_width = 80 - Apply the formatter to examples/**/*.lua Reproduce with: $ mise exec -- stylua --syntax Lua51 examples A method chain inside a call argument in examples/quickstart.lua does not reach a fixed point on the first pass: the second pass rewrites the first pass's output. Flags and config were identical on both passes. Run the command a second time until `--check` is clean; the other nine files converge in one pass. --- examples/api/capture.lua | 19 +++++++++++---- examples/api/connect.lua | 14 +++++++---- examples/api/new_session.lua | 14 +++++++---- examples/api/new_window.lua | 15 ++++++++---- examples/api/query.lua | 14 +++++++---- examples/api/send_keys.lua | 19 +++++++++++---- examples/api/snapshot.lua | 15 ++++++++---- examples/native_query.lua | 3 ++- examples/quickstart.lua | 46 ++++++++++++++++++++++++++---------- examples/snapshot.lua | 10 ++++++-- examples/stylua.toml | 5 ++++ 11 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 examples/stylua.toml diff --git a/examples/api/capture.lua b/examples/api/capture.lua index 86c88c3..44f55ce 100644 --- a/examples/api/capture.lua +++ b/examples/api/capture.lua @@ -7,12 +7,17 @@ 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("'", "'\\''") .. "'" @@ -26,7 +31,11 @@ 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()) + must( + server + :command({ "wait-for", "example-ready" }, { 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..4f049af 100644 --- a/examples/api/new_session.lua +++ b/examples/api/new_session.lua @@ -7,11 +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 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 created = must(server :new_session({ name = "demo", @@ -19,7 +22,10 @@ must(adapter.run(function(runtime) argv = { "/bin/cat" }, }) :await()) - assert(created.session and created.window and created.pane, "creation handles are missing") + 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..0f53311 100644 --- a/examples/api/new_window.lua +++ b/examples/api/new_window.lua @@ -7,11 +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 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 created = must(server :new_session({ name = "demo", @@ -19,7 +22,11 @@ must(adapter.run(function(runtime) argv = { "/bin/cat" }, }) :await()) - local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):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..027218c 100644 --- a/examples/api/query.lua +++ b/examples/api/query.lua @@ -7,11 +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 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()) must(server :new_session({ name = "demo", @@ -28,7 +31,10 @@ must(adapter.run(function(runtime) }) :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/send_keys.lua b/examples/api/send_keys.lua index cad5c28..f7dc181 100644 --- a/examples/api/send_keys.lua +++ b/examples/api/send_keys.lua @@ -7,12 +7,17 @@ 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("'", "'\\''") .. "'" @@ -26,7 +31,11 @@ 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()) + must( + server + :command({ "wait-for", "example-ready" }, { 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..f066377 100644 --- a/examples/api/snapshot.lua +++ b/examples/api/snapshot.lua @@ -7,11 +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 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()) must(server :new_session({ name = "demo", @@ -29,7 +32,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..e9d93e0 100644 --- a/examples/quickstart.lua +++ b/examples/quickstart.lua @@ -7,33 +7,55 @@ 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 created = must(server + :new_session({ + name = "quickstart", + window_name = "main", + argv = { "/bin/sh" }, + }) + :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..51f5073 100644 --- a/examples/snapshot.lua +++ b/examples/snapshot.lua @@ -10,8 +10,14 @@ end 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"), + 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 snapshot = must(server:snapshot({ strict = true }):await()) 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" From fa4ecde88e2585e4fdf89ae2c34fc3fcc250e209 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:51 -0500 Subject: [PATCH 02/18] Examples(style[capture]): Name the wait command why: In capture.lua, the comment on line 26 ran to 81 columns, and the formatter spread the wait-for command below it across lines 27 to 31, so the step that makes the example deterministic read as nesting. what: - In capture.lua, hoist the wait-for argv into a `wait` local so the call fits on one line - Rewrap the completion comment onto two lines --- examples/api/capture.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/api/capture.lua b/examples/api/capture.lua index 44f55ce..5f6f6c6 100644 --- a/examples/api/capture.lua +++ b/examples/api/capture.lua @@ -23,7 +23,8 @@ must(adapter.run(function(runtime) 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 " @@ -31,11 +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 From 82b07a2cf8e050ee1bd28c6c8f84a4b9f61f23a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:52 -0500 Subject: [PATCH 03/18] Examples(style[send_keys]): Name the wait command why: In send_keys.lua, the comment on line 26 ran to 81 columns, and the formatter spread the wait-for command below it across lines 27 to 31, so the step that makes the example deterministic read as nesting. what: - In send_keys.lua, hoist the wait-for argv into a `wait` local so the call fits on one line - Rewrap the completion comment onto two lines --- examples/api/send_keys.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/api/send_keys.lua b/examples/api/send_keys.lua index f7dc181..78cd131 100644 --- a/examples/api/send_keys.lua +++ b/examples/api/send_keys.lua @@ -23,7 +23,8 @@ must(adapter.run(function(runtime) 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 " @@ -31,11 +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 From 4cf9532c53d3ea0ceda93703c8df3354901dcdf2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:57 -0500 Subject: [PATCH 04/18] Examples(style[new_session]): Name the options why: In new_session.lua, lines 18 to 24 are one new_session call the formatter broke into a chain around its options table. what: - In new_session.lua, hoist the session options into a `session_options` local - Call `server:new_session(session_options)` on one line --- examples/api/new_session.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/examples/api/new_session.lua b/examples/api/new_session.lua index 4f049af..8b925a8 100644 --- a/examples/api/new_session.lua +++ b/examples/api/new_session.lua @@ -15,13 +15,12 @@ local 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 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" From cf99266b16b9e982ef47eda957b49cbb0331ad09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:57 -0500 Subject: [PATCH 05/18] Examples(style[new_window]): Name session options why: In new_window.lua, lines 18 to 24 are one new_session call the formatter broke into a chain around its options table. what: - In new_window.lua, hoist the session options into a `session_options` local - Call `server:new_session(session_options)` on one line --- examples/api/new_window.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/examples/api/new_window.lua b/examples/api/new_window.lua index 0f53311..679837e 100644 --- a/examples/api/new_window.lua +++ b/examples/api/new_window.lua @@ -15,13 +15,12 @@ local 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 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" } }) From 860db6e90fcec3722e83e2f41caf2b51ad9736a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:57 -0500 Subject: [PATCH 06/18] Examples(style[snapshot]): Name session options why: In snapshot.lua, lines 18 to 24 are one new_session call the formatter broke into a chain around its options table. what: - In snapshot.lua, hoist the session options into a `session_options` local - Call `server:new_session(session_options)` on one line --- examples/api/snapshot.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/examples/api/snapshot.lua b/examples/api/snapshot.lua index f066377..6241077 100644 --- a/examples/api/snapshot.lua +++ b/examples/api/snapshot.lua @@ -15,13 +15,12 @@ local 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 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 From 9860c947b3026ef9e52776191ed9230494c4cdfd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:57 -0500 Subject: [PATCH 07/18] Examples(style[query]): Name the query options why: The formatter broke both the session setup and the query call into chains that bury the options table between `:new_session(` or `:query(` and `:await()`. what: - Hoist the session options into a `session_options` local - Hoist the query kind, filter and snapshot mode into `query_options` - Call each method on one line --- examples/api/query.lua | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/api/query.lua b/examples/api/query.lua index 027218c..fe03132 100644 --- a/examples/api/query.lua +++ b/examples/api/query.lua @@ -15,21 +15,19 @@ local 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 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", From 1949af02813864cdff7f369cdd2ab697e994d3cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:41:57 -0500 Subject: [PATCH 08/18] Examples(style[quickstart]): Name session options why: The formatter breaks the new_session call inside the `docs:begin main` region into a seven-line chain, so the first call a reader copies from the quickstart leads with punctuation, not the options. what: - In quickstart.lua, hoist the session options into a `session_options` local - Call `server:new_session(session_options)` on one line --- examples/quickstart.lua | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/examples/quickstart.lua b/examples/quickstart.lua index e9d93e0..f2b29a5 100644 --- a/examples/quickstart.lua +++ b/examples/quickstart.lua @@ -17,13 +17,12 @@ local result = must(adapter.run(function(runtime) 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" }, - }) - :await()) + 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" } }) From 7b068da8da941d1b28b06c39b9ac0d5c89098dd7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:42:17 -0500 Subject: [PATCH 09/18] Examples(style[snapshot]): Read the env first why: The formatter broke the connect call into a multi-line chain, with two long `assert(os.getenv(...))` calls inside the options table. The README prints this program in full, so readers met the most nested code on the page first. The api examples already read the binary and socket into locals ahead of the runtime body. what: - Hoist `TMUX_BIN` and `TMUX_SOCKET` into `binary` and `socket` locals above the runtime body - Connect with `{ binary = binary, socket_path = socket }` on one line - Re-sync the README snapshot fence with examples/snapshot.lua --- README.md | 13 +++++++------ examples/snapshot.lua | 19 +++++++------------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e938572..925d089 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,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/snapshot.lua b/examples/snapshot.lua index 51f5073..5d62cd8 100644 --- a/examples/snapshot.lua +++ b/examples/snapshot.lua @@ -7,19 +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") From 50dc9ddbc63a5dd68c292eda61f078abad841a14 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:42:56 -0500 Subject: [PATCH 10/18] Docs(docs[query]): Name the encode_json input why: The `encode_json` call was 82 columns, and nesting the criteria inside the call hid the shape the codec receives. what: - Hoist the criteria table into an `input` local --- docs/query.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/query.md b/docs/query.md index 2f058cf..1419b35 100644 --- a/docs/query.md +++ b/docs/query.md @@ -75,7 +75,8 @@ trusted synchronous code; the library does not load a codec automatically. 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)) ``` From 3712a4bcd8482bba4aeac8f90faf75475ade397a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:42:56 -0500 Subject: [PATCH 11/18] Docs(docs[environment]): Name the inherited lookup why: The inherited `get_environment` lookup was 93 columns. Splitting it by hand mid-call would read worse than naming its parts. what: - Hoist the `{ inherit = true }` options into an `inherit` local and the Request into a `request` local, then await it --- docs/environment.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/environment.md b/docs/environment.md index 082d332..54c9d74 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -18,7 +18,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)) From 7113aaf522bee6a09d9d9af906bcb7fe2c049a2f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:42:56 -0500 Subject: [PATCH 12/18] Docs(docs[snapshots]): Split the example command why: The one-line command that runs examples/snapshot.lua was 85 columns. The repository rule for shell blocks is one flag per continuation line. what: - Put `TMUX_BIN`, `TMUX_SOCKET` and the program on separate continuation lines --- docs/snapshots.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From c8280480afca693fec0461ce8c0bea4be5688c4c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:42:56 -0500 Subject: [PATCH 13/18] Docs(docs[README]): Name the split options why: The README split line was 84 columns. Wrapping it mid-call would hide the direction and size behind formatting. what: - Hoist `{ direction = "right", percent = 40 }` into a `right` local and call `editor.pane:split(right)` --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 925d089..16a5a96 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,8 @@ 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()) From 16c86ad9f6c3076d947a7beade89e766d38332fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 11:47:56 -0500 Subject: [PATCH 14/18] Examples(style[run]): Wrap the bootstrap command why: The bootstrap new-session line was 85 columns, and a shell line has no formatter to wrap it. what: - Break the `new-session` call in examples/api/run.sh after the `-f /dev/null` options so each line fits in 80 columns --- examples/api/run.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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' From 0c57d523b094fee01418edc13e6391616b5bc6d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:43:18 -0500 Subject: [PATCH 15/18] Scripts(feat): Run the Lua fences in docs why: A `lua` fence in the README or docs was prose until a reader pasted it. Nothing ran them, so a renamed method such as `pane:capturee` would pass every gate. what: - Add scripts/check_doc_fences.py. Each fence carries `` above it and an unclassified fence fails - `run` executes a whole program; `fragment` runs inside a live owned server with server, session, window, pane, snapshot and must bound; `compile-only` must parse and states why it cannot run - Classify the eleven existing fences: one run, seven fragments, three compile-only - Add the `fences` gate to the outer phase of scripts/check.py and name it in CONTRIBUTING.md A planted `pane:capturee` in docs/panes.md fails the gate with "attempt to call a nil value (method 'capturee')"; the clean tree passes. The gate needs TMUX_BIN at the 3.7c toolchain the repository bootstraps; libtmux-lua does not support tmux 3.7d yet. --- .github/CONTRIBUTING.md | 9 ++++ README.md | 4 ++ docs/buffers.md | 1 + docs/clients.md | 1 + docs/environment.md | 1 + docs/panes.md | 2 + docs/query.md | 1 + docs/topology.md | 1 + scripts/check.py | 6 ++- scripts/check_doc_fences.py | 102 ++++++++++++++++++++++++++++++++++++ 10 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 scripts/check_doc_fences.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fb33abd..c5f228b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 16a5a96..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") @@ -135,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" } }, @@ -166,6 +168,7 @@ $ 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()) @@ -193,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 54c9d74..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) 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 1419b35..c565b56 100644 --- a/docs/query.md +++ b/docs/query.md @@ -71,6 +71,7 @@ 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") 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/scripts/check.py b/scripts/check.py index c790ce3..c59e7d3 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", "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,8 @@ def execute(gate): run([executable, "lua", "packages", "tests", "examples"], env=lint_env, timeout=10) elif gate == "docs": docs() + 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) @@ -148,7 +150,7 @@ def execute(gate): for item in ("unit", "format", "lint", "generated", "docs"): 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()) From b9c14c65a6fd6fd15c399e79c2260700d8b71a85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:43:26 -0500 Subject: [PATCH 16/18] Docs(docs[WRITING]): Add the Examples section why: Example width and shape were unwritten, so each example was formatted by whatever its tool did. The shared section states the rule; the repository block names StyLua and the check. what: - Add `## Examples` after `## Markdown and code blocks`, byte-identical to the shared section in libtmux/docs#101 - Add a `### Code blocks` heading so the shared section's `#code-blocks` link resolves; this file's own heading is the longer `## Markdown and code blocks` - Add `### In this repository`: the StyLua config and the width check, the surfaces StyLua skips, which examples run, and the copies that have no sync tool - Show the README split line as Bad and its named-option form as Good --- .github/WRITING.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) 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 From 82d42833e843b548806cc2c691c46667899f0f8a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:43:26 -0500 Subject: [PATCH 17/18] Repo(ai[rules]): Route code examples to WRITING why: An agent adding an example has no reason to open WRITING.md, where the width rule now lives. what: - Add "any code example (example programs, Markdown code blocks)" to the WRITING.md routing line and link its Examples section --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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. From a286a0b25ed9d9364a63510ff46a9a278634d2f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:43:10 -0500 Subject: [PATCH 18/18] Scripts(feat): Check example width in the mid gate why: Nothing stopped a README fence or an example from growing past 80 columns. StyLua treats `column_width` as a guide rather than a limit and does not wrap comments, and it never sees Markdown, so a check has to read both. A new file under examples/ would also go unread unless something forces a decision about it. A libtmux.org code block holds 78 columns at 1024 px (Firefox, WebKit, CI's Chrome) and 99 at 1440 px (Firefox, WebKit); libtmux/docs#101 sizes the code font so 80 columns fit from 1024 px up. what: - Add scripts/check_example_width.py, the shared checker, and .github/example-width.toml naming the Markdown and example files - Add the `width` gate to scripts/check.py: it runs the checker's --self-test, then the check, inside `mid` - Document the command in CONTRIBUTING.md beside the other gates - Check every `**/*.md` page with no exclusions, so WRITING.md and AGENTS.md, which CLAUDE.md links to, are held to the width too - Check every Lua program under examples/ and the launcher examples/api/run.sh as `sources`, since a reader opens and copies them - Require every tracked file under examples/ to be checked or excluded with a reason: exclude examples/api/manifest.json (a machine-read index whose JSON strings cannot wrap) and examples/stylua.toml - Check that examples/stylua.toml equals stylua.toml except for `column_width`, through a `[[formatter_copies]]` entry - Point a wide line at Reaching 80, and a wide console command at its shell's continuation - Allow one line, with a reason: the Bad example in WRITING.md's port block, which shows the width problem the Good example fixes; allow_ceiling equals that one entry - Record shared_digest, the --digest of WRITING.md's shared section, so a local edit to it fails the check - Record checker_digest, the --digest of this checker, so a local edit to it fails until every port changes together --- .github/CONTRIBUTING.md | 12 +- .github/example-width.toml | 24 + scripts/check.py | 7 +- scripts/check_example_width.py | 3066 ++++++++++++++++++++++++++++++++ 4 files changed, 3105 insertions(+), 4 deletions(-) create mode 100644 .github/example-width.toml create mode 100644 scripts/check_example_width.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c5f228b..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 @@ -186,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/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/scripts/check.py b/scripts/check.py index c59e7d3..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", "fences", "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,9 @@ 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": @@ -147,7 +150,7 @@ 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", "fences", "integration", "package", "types"): 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())