From 3b890559a7643f2721ad904d811c80607c374e21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:51:59 -0500 Subject: [PATCH 01/59] Examples(style[RunACommand]): Extract report why: 12 lines ran past 80 columns, the widest at 114: the exit-report switch, the Javadoc prose and the run signature. A formatter rejoins any line that fits 120, so only a change of shape keeps them short. what: - Move the outcome switch into a private report(ran) helper so its cases sit at one indent level - Name the joined output lines once instead of rebuilding them in two cases - Make run package-private, which the suite in the same package still calls, so its signature and throws clause fit on one line - Hold the socket default in DEMO and describe the parameters in @param tags - Shorten the timed-out and server-gone messages and rewrap comments at 80 --- .../github/libtmux/examples/RunACommand.java | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/examples/src/main/java/io/github/libtmux/examples/RunACommand.java b/examples/src/main/java/io/github/libtmux/examples/RunACommand.java index db34c3af0..5bafb4dd6 100644 --- a/examples/src/main/java/io/github/libtmux/examples/RunACommand.java +++ b/examples/src/main/java/io/github/libtmux/examples/RunACommand.java @@ -9,7 +9,8 @@ import java.time.Duration; /** - * Runs a command in a pane and reads what it exited with, rather than what the screen shows. + * Runs a command in a pane and reads what it exited with, rather than what the + * screen shows. * *
{@code
  * java RunACommand.java /tmp/libtmux-java-dev/demo/s 'make test'
@@ -17,21 +18,28 @@
  */
 public final class RunACommand {
 
+    private static final String DEMO = "/tmp/libtmux-java-dev/demo/s";
+
     private RunACommand() {}
 
     public static void main(String[] args) throws InterruptedException {
-        Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s");
+        Path socket = Path.of(args.length > 0 ? args[0] : DEMO);
         String command = args.length > 1 ? args[1] : "printf 'built\\n'";
         System.out.println(run(socket, command));
     }
 
     /**
-     * Separated from {@code main} so the suite can run exactly what a reader runs.
+     * Separated from {@code main} so the suite can run exactly what a reader
+     * runs.
+     *
+     * 

{@code InterruptedException} is declared rather than swallowed: the + * wait is tmux's, and cancelling the thread that is waiting is how a caller + * stops waiting for it. * - *

{@code InterruptedException} is declared rather than swallowed: the wait is tmux's, and - * cancelling the thread that is waiting is how a caller stops waiting for it. + * @param socket the tmux socket of the server to run in + * @param command the command line to type into the first pane */ - public static String run(Path socket, String command) throws InterruptedException { + static String run(Path socket, String command) throws InterruptedException { ServerConfig config = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(socket)) .build(); @@ -39,23 +47,29 @@ public static String run(Path socket, String command) throws InterruptedExceptio try (Server server = Server.open(config)) { Pane pane = server.panes().get(0); - // The status is the shell's and the wait is tmux's own: nothing here is inferred from - // what the pane happens to be showing, and the output is the command's alone — not the - // line that was typed, not the prompt after it. + // The status is the shell's and the wait is tmux's own: nothing + // here is inferred from what the pane happens to be showing, and + // the output is the command's alone: not the line that was typed, + // not the prompt after it. PaneRun ran = pane.run(command, Duration.ofMinutes(2)); - - return switch (ran.outcome()) { - case FINISHED -> - "exit %d, %d line(s): %s" - .formatted( - ran.exitStatus().orElseThrow(), - ran.output().size(), - String.join(" / ", ran.output())); - // Still running at the deadline. What it printed so far is worth having, and the - // pane is left alone rather than interrupted on the caller's behalf. - case TIMED_OUT -> "still running after the deadline, so far: " + String.join(" / ", ran.output()); - case SERVER_GONE -> "the tmux server went away while the command ran"; - }; + return report(ran); } } + + private static String report(PaneRun ran) { + String lines = String.join(" / ", ran.output()); + + return switch (ran.outcome()) { + case FINISHED -> { + int status = ran.exitStatus().orElseThrow(); + int count = ran.output().size(); + yield "exit %d, %d line(s): %s".formatted(status, count, lines); + } + // Still running at the deadline. What it printed so far is worth + // having, and the pane is left alone rather than interrupted on + // the caller's behalf. + case TIMED_OUT -> "still running at the deadline, so far: " + lines; + case SERVER_GONE -> "the tmux server went away mid-command"; + }; + } } From 1e82be871e6d2679df4520debb593d5fb2ddf8d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:51:59 -0500 Subject: [PATCH 02/59] Examples(style[WatchPaneOutput]): Split run apart why: 17 lines ran past 80 columns, the widest at 103. Four nested try levels put the subscription, the deadline timer and the read loop deep enough that no line break could hold them, since the formatter rejoins what fits 120. what: - Split run into watch, closeAfter and collect so each body sits at one or two levels - Name the target session, the timer executor and the control-sequence pattern - Bind the subscription with var and import NANOSECONDS statically - Rewrap Javadoc prose and comments at 80 --- .../libtmux/examples/WatchPaneOutput.java | 104 ++++++++++++------ 1 file changed, 68 insertions(+), 36 deletions(-) diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java index d7f3bebed..ca434a80a 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java @@ -1,5 +1,7 @@ package io.github.libtmux.examples; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; @@ -14,8 +16,9 @@ import java.util.Iterator; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executor; import java.util.function.Consumer; +import java.util.function.UnaryOperator; import java.util.stream.Stream; /** @@ -27,69 +30,98 @@ */ public final class WatchPaneOutput { + private static final String DEMO = "/tmp/libtmux-java-dev/demo/s"; + private WatchPaneOutput() {} public static void main(String[] args) { - Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s"); - run(socket, Duration.ofSeconds(10), output -> System.out.print(output.data())); + Path socket = Path.of(args.length > 0 ? args[0] : DEMO); + Duration limit = Duration.ofSeconds(10); + run(socket, limit, output -> System.out.print(output.data())); } /** - * Separated from {@code main} so the suite can run exactly what a reader runs. A - * {@link Delivery.Gap} fails the watch: this reader wants every output, and a gap means some - * was discarded. + * Separated from {@code main} so the suite can run exactly what a reader + * runs. A {@link Delivery.Gap} fails the watch: this reader wants every + * output, and a gap means some was discarded. * * @return everything seen before the deadline */ - public static List run(Path socket, Duration watchFor, Consumer onOutput) { + public static List run( + Path socket, + Duration watchFor, + // Called with each output as it arrives. + Consumer onOutput) { ServerConfig config = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(socket)) .build(); - List seen = new ArrayList<>(); try (Server server = Server.open(config)) { - Session session = server.sessions().get(0); + return watch(server, watchFor, onOutput); + } + } - // Attaching is what makes tmux push %output at all. A client that never attaches hears - // about command replies and nothing else. - try (ControlClient client = server.control(session); - EventSubscription output = client.subscribeOutput(32)) { - client.send("send-keys", "-t", session.id().value(), "echo watched", "Enter"); + private static List watch( + Server server, + Duration watchFor, + // Called with each output as it arrives. + Consumer onOutput) { + Session session = server.sessions().get(0); + String target = session.id().value(); - // Closing the subscription at the deadline ends the stream if the echo never comes. - CompletableFuture.delayedExecutor(watchFor.toNanos(), TimeUnit.NANOSECONDS) - .execute(output::close); - try (Stream> steps = output.stream()) { - Iterator outputs = steps.map(Delivery::kept).iterator(); - while (!sawTheEcho(seen) && outputs.hasNext()) { - PaneOutput arrived = outputs.next(); - seen.add(arrived); - onOutput.accept(arrived); - } - } + // Attaching is what makes tmux push %output at all. A client that + // never attaches hears about command replies and nothing else. + try (ControlClient client = server.control(session); + var feed = client.subscribeOutput(32)) { + client.send("send-keys", "-t", target, "echo watched", "Enter"); + closeAfter(watchFor, feed); + return collect(feed, onOutput); + } + } + + // Closing the subscription at the deadline ends the stream if the echo + // never comes. + private static void closeAfter(Duration wait, EventSubscription feed) { + long nanos = wait.toNanos(); + Executor timer = CompletableFuture.delayedExecutor(nanos, NANOSECONDS); + timer.execute(feed::close); + } + + private static List collect( + EventSubscription feed, + // Called with each output as it arrives. + Consumer onOutput) { + List seen = new ArrayList<>(); + try (Stream> steps = feed.stream()) { + Iterator outputs = steps.map(Delivery::kept).iterator(); + while (!sawTheEcho(seen) && outputs.hasNext()) { + PaneOutput arrived = outputs.next(); + seen.add(arrived); + onOutput.accept(arrived); } } return List.copyOf(seen); } /** - * Whether the output holds the line {@code echo} printed, not only the keys typed to run it. The - * typed line reads {@code echo watched}, so the printed one is {@code watched} on a line of its - * own. + * Whether the output holds the line {@code echo} printed, not only the keys + * typed to run it. The typed line reads {@code echo watched}, so the + * printed one is {@code watched} on a line of its own. */ public static boolean sawTheEcho(List seen) { - String all = seen.stream().map(PaneOutput::data).reduce("", String::concat); - return printedLine(all, "watched"); + Stream chunks = seen.stream().map(PaneOutput::data); + return printedLine(chunks.reduce("", String::concat), "watched"); } /** - * Whether the output has a line that is exactly {@code text}, once terminal control sequences - * are removed. A shell can put a mode switch and a carriage return in front of a printed line, - * so the line cannot be matched as the raw bytes after a newline. + * Whether the output has a line that is exactly {@code text}, once terminal + * control sequences are removed. A shell can put a mode switch and a + * carriage return in front of a printed line, so the line cannot be matched + * as the raw bytes after a newline. */ public static boolean printedLine(String output, String text) { - return output.lines() - .map(line -> line.replaceAll("\u001B\\[[0-9;?]*[A-Za-z]", "").strip()) - .anyMatch(text::equals); + String control = "\u001B\\[[0-9;?]*[A-Za-z]"; + UnaryOperator plain = s -> s.replaceAll(control, "").strip(); + return output.lines().map(plain).anyMatch(text::equals); } } From d1c69b47457b2e63ce24480ea6c11708f7d98c05 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:51:59 -0500 Subject: [PATCH 03/59] Examples(style[WatchWhatChanges]): Split the loop why: 12 lines ran past 80 columns, the widest at 109. The read loop sat five levels deep, which pushed the event wait and the Delivery unwrap out of range, and the formatter rejoins any break that fits 120. what: - Split run into watch and collect, and name the remaining time as left - Name the watched window once in NEW_WINDOW and test events through a named helper - Add an own-line comment on the callback parameter so the signature breaks at the outermost level - Rewrap Javadoc prose and comments at 80 --- .../libtmux/examples/WatchWhatChanges.java | 93 ++++++++++++------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java index 51c776d53..d48510c8c 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java @@ -17,9 +17,10 @@ /** * Watches a whole server for change, without asking it anything. * - *

tmux re-expands a watched format on its own one-second timer and reports only when the value - * differs, so nothing here runs between changes. That is the difference between watching a server - * and polling one, and it is what lets an agent hold a terminal open cheaply. + *

tmux re-expands a watched format on its own one-second timer and reports + * only when the value differs, so nothing here runs between changes. That is + * the difference between watching a server and polling one, and it is what lets + * an agent hold a terminal open cheaply. * *

{@code
  * java WatchWhatChanges.java /tmp/libtmux-java-dev/demo/s
@@ -27,51 +28,77 @@
  */
 public final class WatchWhatChanges {
 
+    private static final String NEW_WINDOW = "watched-into-existence";
+    private static final String DEMO = "/tmp/libtmux-java-dev/demo/s";
+
     private WatchWhatChanges() {}
 
     public static void main(String[] args) {
-        Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s");
-        run(socket, Duration.ofSeconds(10), event -> System.out.println(describe(event)));
+        Path socket = Path.of(args.length > 0 ? args[0] : DEMO);
+        Duration limit = Duration.ofSeconds(10);
+        run(socket, limit, event -> System.out.println(describe(event)));
     }
 
     /**
-     * Separated from {@code main} so the suite can run exactly what a reader runs.
+     * Separated from {@code main} so the suite can run exactly what a reader
+     * runs.
      *
      * @return every change seen before the deadline
      */
-    public static List run(Path socket, Duration watchFor, Consumer onChange) {
+    public static List run(
+            Path socket,
+            Duration watchFor,
+            // Called with each change as it arrives.
+            Consumer onChange) {
         ServerConfig config = ServerConfig.builder()
                 .endpoint(ServerEndpoint.socketPath(socket))
                 .build();
 
-        List seen = new ArrayList<>();
         try (Server server = Server.open(config)) {
-            Session session = server.sessions().get(0);
+            return watch(server, watchFor, onChange);
+        }
+    }
+
+    private static List watch(
+            Server server,
+            Duration watchFor,
+            // Called with each change as it arrives.
+            Consumer onChange) {
+        Session session = server.sessions().get(0);
 
-            try (ControlClient client = server.control(session);
-                    EventSubscription events = client.subscribeEvents(32)) {
+        try (ControlClient client = server.control(session);
+                var events = client.subscribeEvents(32)) {
+            // Every window's name, reported whenever one of them changes. The
+            // comparison happens inside tmux; this client is idle until
+            // something is different.
+            client.watch("names", "@*", "#{window_name}");
 
-                // Every window's name, reported whenever one of them changes. The comparison happens
-                // inside tmux; this client is idle until something is different.
-                client.watch("names", "@*", "#{window_name}");
+            session.newWindow(NEW_WINDOW);
 
-                session.newWindow("watched-into-existence");
+            return collect(events, watchFor, onChange);
+        }
+    }
 
-                long deadline = System.nanoTime() + watchFor.toNanos();
-                while (System.nanoTime() < deadline && !sawTheNewWindow(seen)) {
-                    try {
-                        var next = events.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime())));
-                        if (next.isEmpty()) {
-                            break;
-                        }
-                        ControlEvent arrived = Delivery.kept(next.orElseThrow());
-                        seen.add(arrived);
-                        onChange.accept(arrived);
-                    } catch (InterruptedException e) {
-                        Thread.currentThread().interrupt();
-                        break;
-                    }
+    private static List collect(
+            EventSubscription events,
+            Duration watchFor,
+            // Called with each change as it arrives.
+            Consumer onChange) {
+        List seen = new ArrayList<>();
+        long deadline = System.nanoTime() + watchFor.toNanos();
+        while (System.nanoTime() < deadline && !sawTheNewWindow(seen)) {
+            try {
+                long left = Math.max(0L, deadline - System.nanoTime());
+                var next = events.next(Duration.ofNanos(left));
+                if (next.isEmpty()) {
+                    break;
                 }
+                ControlEvent arrived = Delivery.kept(next.orElseThrow());
+                seen.add(arrived);
+                onChange.accept(arrived);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
             }
         }
         return List.copyOf(seen);
@@ -79,9 +106,11 @@ public static List run(Path socket, Duration watchFor, Consumer seen) {
-        return seen.stream()
-                .anyMatch(event ->
-                        event.value().filter("watched-into-existence"::equals).isPresent());
+        return seen.stream().anyMatch(event -> named(event, NEW_WINDOW));
+    }
+
+    private static boolean named(ControlEvent event, String name) {
+        return event.value().filter(name::equals).isPresent();
     }
 
     private static String describe(ControlEvent event) {

From a9597acf44547c38e9dc31eb52769f2c7aaa6ecf Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:51:59 -0500
Subject: [PATCH 04/59] Examples(style[BuildAWorkspace]): Lift the layout

why: 8 lines ran past 80 columns, the widest at 101. The session lookup
and the window builder calls sat inside two nested blocks, and the
formatter rejoins any break that fits 120.

what:
- Lift the lookup into sessionNamed and the layout steps into layOut
- Use a short lambda parameter for the window spec
- Name the window count before building the report string
- Rewrap Javadoc prose and comments at 80
---
 .../libtmux/examples/BuildAWorkspace.java     | 63 ++++++++++++-------
 1 file changed, 40 insertions(+), 23 deletions(-)

diff --git a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
index 708229a71..63143860a 100644
--- a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
+++ b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
@@ -12,7 +12,8 @@
 import java.util.Optional;
 
 /**
- * Lays out a session the way you would set one up by hand before starting work.
+ * Lays out a session the way you would set one up by hand before starting
+ * work.
  *
  * 
{@code
  * java BuildAWorkspace.java /tmp/libtmux-java-dev/demo/s
@@ -20,40 +21,56 @@
  */
 public final class BuildAWorkspace {
 
+    private static final String DEMO = "/tmp/libtmux-java-dev/demo/s";
+
     private BuildAWorkspace() {}
 
     public static void main(String[] args) {
-        System.out.println(run(Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s")));
+        Path socket = Path.of(args.length > 0 ? args[0] : DEMO);
+        System.out.println(run(socket));
     }
 
-    /** Separated from {@code main} so the suite can run exactly what a reader runs. */
+    /**
+     * Separated from {@code main} so the suite can run exactly what a reader
+     * runs.
+     */
     public static String run(Path socket) {
         ServerConfig config = ServerConfig.builder()
                 .endpoint(ServerEndpoint.socketPath(socket))
                 .configFile(Path.of("/dev/null"))
                 .build();
 
-        // Closing a server closes this client. The tmux server, and the session, outlive the program
-        // — which is the whole point of tmux and the reason nothing here kills it.
+        // Closing a server closes this client. The tmux server, and the
+        // session, outlive the program: which is the whole point of tmux and
+        // the reason nothing here kills it.
         try (Server server = Server.open(config)) {
-            // One read decides and answers. An absent daemon means the name cannot be taken, so
-            // that specific failure is the other way this resolves to "not found".
-            Optional existing;
-            try {
-                existing = server.session("work");
-            } catch (ServerUnavailableException absent) {
-                existing = Optional.empty();
-            }
-            Session session = existing.orElseGet(() -> server.newSession("work"));
-
-            Window editor = session.newWindow(window -> window.named("editor").detached());
-            Pane shell = editor.split(split -> split.toRight());
-            shell.sendLine("git status --short");
-
-            editor.selectLayout(Layout.MAIN_VERTICAL);
-
-            return "session " + session.name() + " has "
-                    + session.refresh().windows().size() + " windows";
+            return layOut(server);
+        }
+    }
+
+    private static String layOut(Server server) {
+        Session session = sessionNamed(server, "work");
+
+        Window editor = session.newWindow(w -> w.named("editor").detached());
+        Pane shell = editor.split(split -> split.toRight());
+        shell.sendLine("git status --short");
+
+        editor.selectLayout(Layout.MAIN_VERTICAL);
+
+        int windows = session.refresh().windows().size();
+        return "session " + session.name() + " has " + windows + " windows";
+    }
+
+    // One read decides and answers. An absent daemon means the name cannot be
+    // taken, so that specific failure is the other way this resolves to "not
+    // found".
+    private static Session sessionNamed(Server server, String name) {
+        Optional existing;
+        try {
+            existing = server.session(name);
+        } catch (ServerUnavailableException absent) {
+            existing = Optional.empty();
         }
+        return existing.orElseGet(() -> server.newSession(name));
     }
 }

From a37c022e376662936efa5319b3167e8cd9f9df4b Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:51:59 -0500
Subject: [PATCH 05/59] Examples(style[FindPanesRunning]): Use a for loop

why: 4 lines ran past 80 columns, the widest at 115. The forEach lambda
that printed each pane and the socket default carried the width, and a
formatter break would be rejoined at 120.

what:
- Print each pane in a for loop with the id named first
- Hold the socket default in DEMO
- Rewrap Javadoc prose and comments at 80
---
 .../libtmux/examples/FindPanesRunning.java    | 22 ++++++++++++++-----
 1 file changed, 16 insertions(+), 6 deletions(-)

diff --git a/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java b/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java
index dd1d58ed2..f3949d1cf 100644
--- a/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java
+++ b/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java
@@ -9,7 +9,8 @@
 import java.util.List;
 
 /**
- * Finds the panes running a given command, without asking tmux more than once.
+ * Finds the panes running a given command, without asking tmux more than
+ * once.
  *
  * 
{@code
  * java FindPanesRunning.java /tmp/libtmux-java-dev/demo/s vim
@@ -17,23 +18,32 @@
  */
 public final class FindPanesRunning {
 
+    private static final String DEMO = "/tmp/libtmux-java-dev/demo/s";
+
     private FindPanesRunning() {}
 
     public static void main(String[] args) {
-        Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s");
+        Path socket = Path.of(args.length > 0 ? args[0] : DEMO);
         String command = args.length > 1 ? args[1] : "vim";
-        run(socket, command).forEach(pane -> System.out.println(pane.id().value() + "  " + pane.currentCommand()));
+        for (Pane pane : run(socket, command)) {
+            String id = pane.id().value();
+            System.out.println(id + "  " + pane.currentCommand());
+        }
     }
 
-    /** Separated from {@code main} so the suite can run exactly what a reader runs. */
+    /**
+     * Separated from {@code main} so the suite can run exactly what a reader
+     * runs.
+     */
     public static List run(Path socket, String command) {
         ServerConfig config = ServerConfig.builder()
                 .endpoint(ServerEndpoint.socketPath(socket))
                 .build();
 
         try (Server server = Server.open(config)) {
-            // server.panes() reads tmux once. The filter runs over what that read returned, so
-            // narrowing costs nothing and cannot see a half-changed server.
+            // server.panes() reads tmux once. The filter runs over what that
+            // read returned, so narrowing costs nothing and cannot see a
+            // half-changed server.
             return server.panes().stream()
                     .filter(Pane_.command().startsWith(command))
                     .toList();

From 83eb50b7d2c5473f802d94fa8319d455620ab4ac Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:51:59 -0500
Subject: [PATCH 06/59] Examples(style[ServeTmuxOverMcp]): Extract setup

why: 13 lines ran past 80 columns, the widest at 103. Building the stdio
transport inline made one statement far wider than 80, and the formatter
would rejoin any break that fits 120.

what:
- Move the server setup into toolNames and name the mapper and the two
  streams
- Name the tool list before streaming it
- Hold the socket default in DEMO and rewrap prose and comments at 80
---
 .../libtmux/examples/ServeTmuxOverMcp.java    | 64 +++++++++++--------
 1 file changed, 38 insertions(+), 26 deletions(-)

diff --git a/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java
index 18677cf10..798f0b359 100644
--- a/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java
+++ b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java
@@ -15,54 +15,66 @@
 import java.util.List;
 
 /**
- * Serves a tmux server over MCP and reports the tool surface an agent would see.
+ * Serves a tmux server over MCP and reports the tool surface an agent would
+ * see.
  *
  * 
{@code
  * java ServeTmuxOverMcp.java /tmp/libtmux-java-dev/demo/s
  * }
* - *

The surface is fixed: one catalog decides which tools exist, and {@code LIBTMUX_TOOLSETS} and - * {@code LIBTMUX_TOOLS} decide which of them this process offers. Reading it back is how you check - * what a given configuration actually exposes, without attaching an agent to find out. + *

The surface is fixed: one catalog decides which tools exist, and + * {@code LIBTMUX_TOOLSETS} and {@code LIBTMUX_TOOLS} decide which of them this + * process offers. Reading it back is how you check what a given configuration + * actually exposes, without attaching an agent to find out. * - *

Expect fewer tools than the catalog holds when the socket already has a server on it: teardown - * is enabled by default only for a minimal daemon this process started, so attaching to somebody - * else's tmux does not hand an agent the tools that end it. + *

Expect fewer tools than the catalog holds when the socket already has a + * server on it: teardown is enabled by default only for a minimal daemon this + * process started, so attaching to somebody else's tmux does not hand an agent + * the tools that end it. */ public final class ServeTmuxOverMcp { + private static final String DEMO = "/tmp/libtmux-java-dev/demo/s"; + private ServeTmuxOverMcp() {} public static void main(String[] args) { - Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s"); + Path socket = Path.of(args.length > 0 ? args[0] : DEMO); run(socket).forEach(System.out::println); } - /** Separated from {@code main} so the suite can run exactly what a reader runs. */ + /** + * Separated from {@code main} so the suite can run exactly what a reader + * runs. + */ public static List run(Path socket) { ServerConfig config = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(socket)) .build(); try (Server server = Server.open(config)) { - // A real client speaks over this process's stdin and stdout, which TmuxMcpServer.overStdio - // wires up. Here the streams are empty and discarded: the point is the surface, not a - // conversation, and an example that wrote JSON-RPC to stdout could not also print. - StdioServerTransportProvider transport = new StdioServerTransportProvider( - new JacksonMcpJsonMapper(new ObjectMapper()), - InputStream.nullInputStream(), - OutputStream.nullOutputStream()); + return toolNames(server); + } + } + + private static List toolNames(Server server) { + // A real client speaks over this process's stdin and stdout, which + // TmuxMcpServer.overStdio wires up. Here the streams are empty and + // discarded: the point is the surface, not a conversation, and an + // example that wrote JSON-RPC to stdout could not also print. + var json = new JacksonMcpJsonMapper(new ObjectMapper()); + InputStream in = InputStream.nullInputStream(); + OutputStream out = OutputStream.nullOutputStream(); + var transport = new StdioServerTransportProvider(json, in, out); - // Serving hands the transport over; closing the returned server closes it. - McpSyncServer mcp = TmuxMcpServer.serving(server, transport); - try { - return mcp.listTools().stream() - .map(McpSchema.Tool::name) - .sorted() - .toList(); - } finally { - mcp.close(); - } + // Serving hands the transport over; closing the returned server closes + // it. + McpSyncServer mcp = TmuxMcpServer.serving(server, transport); + try { + var tools = mcp.listTools(); + return tools.stream().map(McpSchema.Tool::name).sorted().toList(); + } finally { + mcp.close(); } } } From 3ac502d029a5a88aaa522ed425c561d35b77a502 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:51:59 -0500 Subject: [PATCH 07/59] Examples(style[java]): Move bodies into run() why: Eight Java API examples ran past 80 columns. Each threw its usage error inside a nested try, at 100 columns, and kept its interrupt guard at 90 on one line, which the formatter will not break while it fits the repository's 120. A private run method puts the work one level shallower: - Connect.java: lines 15 (100) and 26 (90) - ListPanes.java: lines 15 (100) and 32 (90) - NewSession.java: lines 16 (100) and 31 (90) - ListSessions.java, ListWindows.java and Query.java: 2 lines each, the widest at 100 - NewWindow.java: 2 lines, the widest at 100; after the move its WindowSpec builder call was still 90 wide - Capture.java: 8 lines, the widest at 100: the usage error, two timed waits with their timeout messages, the capture comparison and the interrupt guard what: - Move each program's body into a private run method - Name the usage message and put braces on the interrupt guard - In ListSessions.java, ListWindows.java, NewWindow.java and Query.java, name the sorted names and join them with a static import - Name the half-built WindowSpec in NewWindow.java and finish it in the call - Extract await for Capture.java's two timed waits and compare its captured lines against a stripped list --- .../libtmux/examples/api/java/Capture.java | 73 +++++++++++-------- .../libtmux/examples/api/java/Connect.java | 31 +++++--- .../libtmux/examples/api/java/ListPanes.java | 43 ++++++----- .../examples/api/java/ListSessions.java | 39 ++++++---- .../examples/api/java/ListWindows.java | 51 +++++++------ .../libtmux/examples/api/java/NewSession.java | 39 ++++++---- .../libtmux/examples/api/java/NewWindow.java | 51 +++++++------ .../libtmux/examples/api/java/Query.java | 41 ++++++----- 8 files changed, 212 insertions(+), 156 deletions(-) diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/Capture.java b/examples/src/main/java/io/github/libtmux/examples/api/java/Capture.java index 2201d41a9..271b49b45 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/Capture.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/Capture.java @@ -1,5 +1,6 @@ package io.github.libtmux.examples.api.java; +import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; @@ -15,40 +16,50 @@ private Capture() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - var session = server.newSession(SessionSpec.builder() - .named("api-capture") - .running("/bin/sh") - .build()); - var pane = session.windows().getFirst().panes().getFirst(); - pane.sendKeys(List.of("printf 'api-%s\\n' keys", "Enter")); - if (pane.awaitText("api-keys", Duration.ofSeconds(5)) == TextOutcome.TIMED_OUT) { - throw new IllegalStateException("timed out waiting for keys output"); - } - pane.sendLine("printf 'api-%s\\n' line"); - if (pane.awaitText("api-line", Duration.ofSeconds(5)) == TextOutcome.TIMED_OUT) { - throw new IllegalStateException("timed out waiting for line output"); - } - var lines = pane.capture(); - for (String expected : List.of("api-keys", "api-line")) { - if (lines.stream().noneMatch(line -> line.strip().equals(expected))) { - throw new IllegalStateException("capture is missing " + expected); - } - System.out.println(expected); - } - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + var session = server.newSession(SessionSpec.builder() + .named("api-capture") + .running("/bin/sh") + .build()); + var pane = session.windows().getFirst().panes().getFirst(); + pane.sendKeys(List.of("printf 'api-%s\\n' keys", "Enter")); + await(pane, "api-keys"); + pane.sendLine("printf 'api-%s\\n' line"); + await(pane, "api-line"); + var lines = pane.capture().stream().map(String::strip).toList(); + for (String want : List.of("api-keys", "api-line")) { + if (!lines.contains(want)) { + throw new IllegalStateException("capture lacks " + want); + } + System.out.println(want); + } + } + } + + private static void await(Pane pane, String text) throws Exception { + var outcome = pane.awaitText(text, Duration.ofSeconds(5)); + if (outcome == TextOutcome.TIMED_OUT) { + throw new IllegalStateException("timed out waiting for " + text); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/Connect.java b/examples/src/main/java/io/github/libtmux/examples/api/java/Connect.java index 3d2aab5c7..59f7fea29 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/Connect.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/Connect.java @@ -11,21 +11,28 @@ private Connect() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - System.out.println("connected=" + server.isAlive()); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + System.out.println("connected=" + server.isAlive()); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/ListPanes.java b/examples/src/main/java/io/github/libtmux/examples/api/java/ListPanes.java index 2c2f38962..bc0b471a0 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/ListPanes.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/ListPanes.java @@ -11,27 +11,34 @@ private ListPanes() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - System.out.println("panes=" + server.panes().size()); - var session = server.session("work-one").orElseThrow(); - var window = session.windows().stream() - .filter(item -> item.name().equals("editor")) - .findFirst() - .orElseThrow(); - System.out.println("window-panes=" + window.panes().size()); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + System.out.println("panes=" + server.panes().size()); + var session = server.session("work-one").orElseThrow(); + var window = session.windows().stream() + .filter(item -> item.name().equals("editor")) + .findFirst() + .orElseThrow(); + System.out.println("window-panes=" + window.panes().size()); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/ListSessions.java b/examples/src/main/java/io/github/libtmux/examples/api/java/ListSessions.java index 665229511..874457e17 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/ListSessions.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/ListSessions.java @@ -12,25 +12,32 @@ private ListSessions() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - System.out.println("sessions=" - + server.sessions().stream() - .map(session -> session.name()) - .sorted() - .collect(Collectors.joining(","))); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + System.out.println("sessions=" + + server.sessions().stream() + .map(session -> session.name()) + .sorted() + .collect(Collectors.joining(","))); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/ListWindows.java b/examples/src/main/java/io/github/libtmux/examples/api/java/ListWindows.java index 821674687..3bc4447d0 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/ListWindows.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/ListWindows.java @@ -12,31 +12,38 @@ private ListWindows() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - System.out.println("windows=" - + server.windows().stream() - .map(window -> window.name()) - .sorted() - .collect(Collectors.joining(","))); - var session = server.session("work-one").orElseThrow(); - System.out.println("session-windows=" - + session.windows().stream() - .map(window -> window.name()) - .sorted() - .collect(Collectors.joining(","))); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + System.out.println("windows=" + + server.windows().stream() + .map(window -> window.name()) + .sorted() + .collect(Collectors.joining(","))); + var session = server.session("work-one").orElseThrow(); + System.out.println("session-windows=" + + session.windows().stream() + .map(window -> window.name()) + .sorted() + .collect(Collectors.joining(","))); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/NewSession.java b/examples/src/main/java/io/github/libtmux/examples/api/java/NewSession.java index 19246202b..e6d8f6026 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/NewSession.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/NewSession.java @@ -12,25 +12,32 @@ private NewSession() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - var session = server.newSession(SessionSpec.builder() - .named("api-session") - .running("/bin/cat") - .build()); - System.out.println("created=" + session.name()); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + var session = server.newSession(SessionSpec.builder() + .named("api-session") + .running("/bin/cat") + .build()); + System.out.println("created=" + session.name()); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/NewWindow.java b/examples/src/main/java/io/github/libtmux/examples/api/java/NewWindow.java index 26c721eb9..1ba15dfa1 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/NewWindow.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/NewWindow.java @@ -13,32 +13,37 @@ private NewWindow() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - var session = server.session("work-one").orElseThrow(); - var window = session.newWindow(WindowSpec.builder() - .named("api-window") - .running("/bin/cat") - .build()); - System.out.println("created=" + window.name()); - var refreshed = server.session("work-one").orElseThrow(); - System.out.println("windows=" - + refreshed.windows().stream() - .map(item -> item.name()) - .sorted() - .collect(Collectors.joining(","))); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + var session = server.session("work-one").orElseThrow(); + var spec = WindowSpec.builder().named("api-window"); + var window = session.newWindow(spec.running("/bin/cat").build()); + System.out.println("created=" + window.name()); + var refreshed = server.session("work-one").orElseThrow(); + System.out.println("windows=" + + refreshed.windows().stream() + .map(item -> item.name()) + .sorted() + .collect(Collectors.joining(","))); + } + } } diff --git a/examples/src/main/java/io/github/libtmux/examples/api/java/Query.java b/examples/src/main/java/io/github/libtmux/examples/api/java/Query.java index bb95da20c..79bf14086 100644 --- a/examples/src/main/java/io/github/libtmux/examples/api/java/Query.java +++ b/examples/src/main/java/io/github/libtmux/examples/api/java/Query.java @@ -1,11 +1,12 @@ package io.github.libtmux.examples.api.java; +import static java.util.stream.Collectors.joining; + import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session_; import java.nio.file.Path; -import java.util.stream.Collectors; /** Select sessions with a typed name filter. */ public final class Query { @@ -13,26 +14,30 @@ private Query() {} public static void main(String[] args) { try { - if (args.length != 3) { - throw new IllegalArgumentException("expected: tmux-binary socket-path config-file"); - } - var config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - try (Server server = Server.open(config)) { - var matches = server.sessions(Session_.name().is("work-one")); - System.out.println("matches=" - + matches.stream() - .map(session -> session.name()) - .sorted() - .collect(Collectors.joining(","))); - } + run(args); } catch (Exception error) { - if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } System.err.println("Example failed: " + error.getMessage()); System.exit(1); } } + + private static void run(String[] args) throws Exception { + String usage = "expected: tmux-binary socket-path config-file"; + if (args.length != 3) { + throw new IllegalArgumentException(usage); + } + var config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build(); + try (Server server = Server.open(config)) { + var matches = server.sessions(Session_.name().is("work-one")); + var names = matches.stream().map(s -> s.name()).sorted(); + System.out.println("matches=" + names.collect(joining(","))); + } + } } From 113cade37a539ffe4e4c9b9799d833ee4a84a6d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 08/59] Examples(style[WatchWithFlow]): Name the callbacks why: 10 lines ran past 80 columns, the widest at 115: the Flow pipeline, the send callback, the cancelled wait and the KDoc prose. Kotlin has no formatter, so the source is edited directly. libtmux-kotlin/README.md shows a similar Flow snippet, fitted in its own commit. what: - Name the send callback, the target session and the cancelled wait - Put one Flow operator per line and import printedLine - Hold the socket default in DEMO and rewrap KDoc at 80 --- .../github/libtmux/examples/WatchWithFlow.kt | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt b/examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt index 781db793b..2c7332229 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt @@ -3,6 +3,7 @@ package io.github.libtmux.examples import io.github.libtmux.ServerConfig import io.github.libtmux.ServerEndpoint import io.github.libtmux.control.Delivery +import io.github.libtmux.examples.WatchPaneOutput.printedLine import io.github.libtmux.kotlin.Server import io.github.libtmux.kotlin.await import io.github.libtmux.kotlin.send @@ -22,43 +23,56 @@ import kotlinx.coroutines.withTimeoutOrNull * Reads a pane's output as a `Flow`, then gives up on a wait by cancelling it. * * ``` - * java -cp ... io.github.libtmux.examples.WatchWithFlowKt /tmp/libtmux-java-dev/demo/s + * java -cp ... io.github.libtmux.examples.WatchWithFlowKt \ + * /tmp/libtmux-java-dev/demo/s * ``` */ fun main(args: Array) { - val socket = Path.of(args.getOrElse(0) { "/tmp/libtmux-java-dev/demo/s" }) + val socket = Path.of(args.getOrElse(0) { DEMO }) runBlocking { println(watchWithFlow(socket, 10.seconds)) } } +private const val DEMO = "/tmp/libtmux-java-dev/demo/s" + /** - * Collects output until the line `echo` printed arrives, then starts a channel wait that nothing - * will signal and cancels it after a moment. Cancelling ends the coroutine at once and is not a - * timeout; the tmux client behind the wait is stopped with it. + * Collects output until the line `echo` printed arrives, then starts a channel + * wait that nothing will signal and cancels it after a moment. Cancelling ends + * the coroutine at once and is not a timeout; the tmux client behind the wait + * is stopped with it. * - * @return one line for each: whether the echo arrived, and whether the wait was cancelled + * @return one line for each: whether the echo arrived, and whether the wait was + * cancelled */ suspend fun watchWithFlow(socket: Path, deadline: Duration): String { - val config = ServerConfig.builder().endpoint(ServerEndpoint.socketPath(socket)).build() + val endpoint = ServerEndpoint.socketPath(socket) + val config = ServerConfig.builder().endpoint(endpoint).build() return withServer(config) { server: Server -> val session = server.sessions().first() withControl(server, session) { control -> - // control.output(...) is a cold Flow: the subscription it opens on first collection - // closes when collection ends, is cancelled, or throws — nothing to close by hand. The - // command goes in onSubscribed: sent before collecting, its output could arrive before - // there was a subscription to hold it. + // control.output(...) is a cold Flow: the subscription it opens on + // first collection closes when collection ends, is cancelled, or + // throws, so there is nothing to close by hand. The command goes + // in onSubscribed: sent before collecting, its output could arrive + // before there was a subscription to hold it. val seen = StringBuilder() + val target = session.name + val sendEcho: suspend () -> Unit = { + control.send("send-keys", "-t", target, "echo flowed", "Enter") + } val echoed = withTimeoutOrNull(deadline) { - control.output(capacity = 32) { - control.send("send-keys", "-t", session.name, "echo flowed", "Enter") - }.map { Delivery.kept(it).data }.first { chunk -> - seen.append(chunk) - WatchPaneOutput.printedLine(seen.toString(), "flowed") - } + control.output(capacity = 32, sendEcho) + .map { Delivery.kept(it).data } + .first { chunk -> + seen.append(chunk) + printedLine(seen.toString(), "flowed") + } } != null - val cancelled = - withTimeoutOrNull(200.milliseconds) { server.channel("never-signalled").await(30.seconds) } == null + val never = server.channel("never-signalled") + val grace = 200.milliseconds + val waited = withTimeoutOrNull(grace) { never.await(30.seconds) } + val cancelled = waited == null "echoed=$echoed\ncancelled=$cancelled" } From 66b6b1d1499399e93fcc53eafecc74e700a384ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 09/59] Examples(style[kotlin/Capture]): Add awaitShown why: In Capture.kt, 5 lines ran past 80 columns, the widest at 97: the usage check, two timed waits, the capture comparison and the SessionSpec builder. Kotlin has no formatter, so only a change of shape fits it. what: - Move the body into a private execute function - Put braces on the interrupt guard - Extract awaitShown for the two timed waits and compare trimmed lines with the wanted ones - Break the SessionSpec builder one call per line --- .../libtmux/examples/api/kotlin/Capture.kt | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Capture.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Capture.kt index 98bb782e6..bf7005935 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Capture.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Capture.kt @@ -13,36 +13,46 @@ import kotlin.time.Duration.Companion.seconds /** Send input and wait for output before capturing the pane. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - val session = server.newSession( - SessionSpec.builder().named("api-capture").running("/bin/sh").build() - ) - val pane = session.windows.first().panes.first() - pane.sendKeys(listOf("printf 'api-%s\\n' keys", "Enter")) - check(pane.awaitText("api-keys", timeout = 5.seconds) != TextOutcome.TIMED_OUT) { - "timed out waiting for keys output" - } - pane.sendLine("printf 'api-%s\\n' line") - check(pane.awaitText("api-line", timeout = 5.seconds) != TextOutcome.TIMED_OUT) { - "timed out waiting for line output" - } - val lines = pane.capture() - for (expected in listOf("api-keys", "api-line")) { - check(lines.any { it.trim() == expected }) { "capture is missing $expected" } - println(expected) - } - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val session = server.newSession( + SessionSpec.builder() + .named("api-capture") + .running("/bin/sh") + .build() + ) + val pane = session.windows.first().panes.first() + pane.sendKeys(listOf("printf 'api-%s\\n' keys", "Enter")) + pane.awaitShown("api-keys") + pane.sendLine("printf 'api-%s\\n' line") + pane.awaitShown("api-line") + val lines = pane.capture().map { it.trim() } + for (want in listOf("api-keys", "api-line")) { + check(want in lines) { "capture lacks $want" } + println(want) + } + } + } +} + +private suspend fun Pane.awaitShown(text: String) { + val outcome = awaitText(text, timeout = 5.seconds) + check(outcome != TextOutcome.TIMED_OUT) { "timed out waiting for $text" } +} From 91812b704810b27ed1152eef74899ed3c9dfe2ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 10/59] Examples(style[kotlin]): Move bodies into execute why: Seven Kotlin API examples ran past 80 columns, and Kotlin has no formatter here, so only a change of shape fits them. Each nested its usage check inside try and runBlocking; most also chained map, sorted and joinToString on one output line or built a spec in one call: - Connect.kt: line 13, 83 columns - ListPanes.kt: line 14, 83 columns - ListSessions.kt: 2 lines, the widest at 99 - ListWindows.kt: 3 lines, the widest at 104 - NewSession.kt: 2 lines, the widest at 90 - NewWindow.kt: 3 lines, the widest at 98 - Query.kt: 2 lines, the widest at 88 what: - Move each program's body into a private execute function and put braces on its interrupt guard - Name the sorted names before joining them in ListSessions.kt, ListWindows.kt, NewWindow.kt and Query.kt - Break the SessionSpec builder in NewSession.kt one call per line - Name the half-built WindowSpec in NewWindow.kt and finish it in the call --- .../libtmux/examples/api/kotlin/Connect.kt | 30 +++++++----- .../libtmux/examples/api/kotlin/ListPanes.kt | 36 +++++++++------ .../examples/api/kotlin/ListSessions.kt | 31 ++++++++----- .../examples/api/kotlin/ListWindows.kt | 36 +++++++++------ .../libtmux/examples/api/kotlin/NewSession.kt | 39 ++++++++++------ .../libtmux/examples/api/kotlin/NewWindow.kt | 46 +++++++++++-------- .../libtmux/examples/api/kotlin/Query.kt | 33 +++++++------ 7 files changed, 152 insertions(+), 99 deletions(-) diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Connect.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Connect.kt index 1a1afba4a..c1f2fbe86 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Connect.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Connect.kt @@ -10,20 +10,26 @@ import kotlinx.coroutines.runBlocking /** Connect to a private tmux server. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - println("connected=${server.isAlive()}") - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + println("connected=${server.isAlive()}") + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListPanes.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListPanes.kt index 794cbc159..7bc1d8af3 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListPanes.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListPanes.kt @@ -11,23 +11,29 @@ import kotlinx.coroutines.runBlocking /** List all panes, then the panes captured for one window. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - println("panes=${server.panes().size}") - val session = server.session(Session.name eq "work-one") - val window = session.windows.single { it.name == "editor" } - println("window-panes=${window.panes.size}") - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + println("panes=${server.panes().size}") + val session = server.session(Session.name eq "work-one") + val window = session.windows.single { it.name == "editor" } + println("window-panes=${window.panes.size}") + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListSessions.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListSessions.kt index 9cf2c778a..15aefff47 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListSessions.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListSessions.kt @@ -10,20 +10,27 @@ import kotlinx.coroutines.runBlocking /** List the sessions returned by a server read. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - println("sessions=" + server.sessions().map { it.name }.sorted().joinToString(",")) - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val names = server.sessions().map { it.name }.sorted() + println("sessions=" + names.joinToString(",")) + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListWindows.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListWindows.kt index 265993dbc..161580c68 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListWindows.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/ListWindows.kt @@ -11,22 +11,30 @@ import kotlinx.coroutines.runBlocking /** List all windows, then the windows captured for one session. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - println("windows=" + server.windows().map { it.name }.sorted().joinToString(",")) - val session = server.session(Session.name eq "work-one") - println("session-windows=" + session.windows.map { it.name }.sorted().joinToString(",")) - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val names = server.windows().map { it.name }.sorted() + println("windows=" + names.joinToString(",")) + val session = server.session(Session.name eq "work-one") + val inSession = session.windows.map { it.name }.sorted() + println("session-windows=" + inSession.joinToString(",")) + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewSession.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewSession.kt index f3860562e..30e2ed43c 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewSession.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewSession.kt @@ -11,23 +11,32 @@ import kotlinx.coroutines.runBlocking /** Create a session running cat. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - val session = server.newSession( - SessionSpec.builder().named("api-session").running("/bin/cat").build() - ) - println("created=${session.name}") - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val session = server.newSession( + SessionSpec.builder() + .named("api-session") + .running("/bin/cat") + .build() + ) + println("created=${session.name}") + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewWindow.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewWindow.kt index ed8a56c86..5e5804082 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewWindow.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/NewWindow.kt @@ -12,26 +12,36 @@ import kotlinx.coroutines.runBlocking /** Create a window and read the updated session. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - val session = server.session(Session.name eq "work-one") - val window = session.newWindow( - WindowSpec.builder().named("api-window").running("/bin/cat").build() - ) - println("created=${window.name}") - val refreshed = server.session(Session.name eq "work-one") - println("windows=" + refreshed.windows.map { it.name }.sorted().joinToString(",")) - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val session = server.session(Session.name eq "work-one") + val window = session.newWindow( + WindowSpec.builder() + .named("api-window") + .running("/bin/cat") + .build() + ) + println("created=${window.name}") + val refreshed = server.session(Session.name eq "work-one") + val names = refreshed.windows.map { it.name }.sorted() + println("windows=" + names.joinToString(",")) + } + } +} diff --git a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Query.kt b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Query.kt index 776435c50..8f6714e56 100644 --- a/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Query.kt +++ b/examples/src/main/kotlin/io/github/libtmux/examples/api/kotlin/Query.kt @@ -11,21 +11,28 @@ import kotlinx.coroutines.runBlocking /** Select sessions with a typed name filter. */ fun main(args: Array) { try { - require(args.size == 3) { "expected: tmux-binary socket-path config-file" } - val config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build() - runBlocking { - withServer(config) { server -> - val matches = server.sessions(Session.name eq "work-one") - println("matches=" + matches.map { it.name }.sorted().joinToString(",")) - } - } + execute(args) } catch (error: Exception) { - if (error is InterruptedException) Thread.currentThread().interrupt() + if (error is InterruptedException) { + Thread.currentThread().interrupt() + } System.err.println("Example failed: ${error.message}") exitProcess(1) } } + +private fun execute(args: Array) { + require(args.size == 3) { "expected: tmux-binary socket-path config-file" } + val config = ServerConfig.builder() + .binary(args[0]) + .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) + .configFile(Path.of(args[2])) + .build() + runBlocking { + withServer(config) { server -> + val matches = server.sessions(Session.name eq "work-one") + val names = matches.map { it.name }.sorted() + println("matches=" + names.joinToString(",")) + } + } +} From d6e041d584db3a2e01aa3aaf85806b67b6524fc2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 11/59] Examples(style[BlockingWorkspace]): Wrap comments why: 4 lines ran past 80 columns, the widest at 100, all in comments: the wildcard-import note and the typed-field note. Scalafmt holds code to its maxColumn of 80 but does not reflow comments. what: - Rewrap the wildcard import comment and the typed-field comment at 80 --- .../scaladsl/examples/BlockingWorkspace.scala | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/BlockingWorkspace.scala b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/BlockingWorkspace.scala index af123141b..20f480c00 100644 --- a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/BlockingWorkspace.scala +++ b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/BlockingWorkspace.scala @@ -1,9 +1,11 @@ package io.github.libtmux.scaladsl.examples import io.github.libtmux.{Layout, ServerConfig, SessionSpec, SplitSpec} -// Wildcards, not named imports: examples lives beside io.github.libtmux.scaladsl, not inside it, so -// its generated and handwritten extension methods (isAlive, newSession, session, ...) need an -// explicit import rather than the enclosing-package visibility a nested package would get for free. +// Wildcards, not named imports: examples lives beside +// io.github.libtmux.scaladsl, not inside it, so its generated and handwritten +// extension methods (isAlive, newSession, session, ...) need an explicit import +// rather than the enclosing-package visibility a nested package would get for +// free. import io.github.libtmux.scaladsl.* import io.github.libtmux.scaladsl.query.* import scala.concurrent.duration._ @@ -43,8 +45,9 @@ object BlockingWorkspace { val screen = shell.capture() assert(screen.exists(_.contains("line-submitted"))) - // The typed field DSL: Pane.command/.active on the handle's own companion, .matching as a - // local filter, exactlyOne for strict cardinality. + // The typed field DSL: Pane.command/.active on the handle's own + // companion, .matching as a local filter, exactlyOne for strict + // cardinality. val panes = Vector(shell, second) assert( panes From 5ba8c1e68991c84cf1776ebb0c7b30581d4fb79b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 12/59] Examples(style[CaptureConcurrently]): Wrap note why: In CaptureConcurrently.scala, line 7, the wildcard-import note, ran to 98 columns. Scalafmt does not reflow comments. what: - In CaptureConcurrently.scala, rewrap the wildcard import comment at 80 --- .../libtmux/scaladsl/examples/CaptureConcurrently.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/CaptureConcurrently.scala b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/CaptureConcurrently.scala index 5ffd85167..a425781f6 100644 --- a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/CaptureConcurrently.scala +++ b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/CaptureConcurrently.scala @@ -4,8 +4,9 @@ import _root_.cats.effect.{ExitCode, IO, IOApp, Resource} import _root_.cats.syntax.all._ import fs2.Stream import io.github.libtmux.{ServerConfig, SessionSpec, SplitSpec} -// Wildcard, not a named import: examples lives beside io.github.libtmux.scaladsl.cats, not inside -// it, so its generated extension methods need an explicit import. +// Wildcard, not a named import: examples lives beside +// io.github.libtmux.scaladsl.cats, not inside it, so its generated extension +// methods need an explicit import. import io.github.libtmux.scaladsl.cats.* import scala.concurrent.duration._ From 9b3de6ec67c33b5464574792d132a0c98aa24e40 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 13/59] Examples(style[ObserveChanges]): Wrap import note why: 2 lines ran past 80 columns: the wildcard-import note at 98, which scalafmt does not reflow, and a 83 column assertion message. what: - Rewrap the import comment at 80 - Shorten the poller assertion message --- .../github/libtmux/scaladsl/examples/ObserveChanges.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ObserveChanges.scala b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ObserveChanges.scala index d9a035088..5ae7d1aa1 100644 --- a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ObserveChanges.scala +++ b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ObserveChanges.scala @@ -4,8 +4,9 @@ import _root_.cats.effect.{ExitCode, IO, IOApp, Resource} import _root_.cats.syntax.all._ import io.github.libtmux.{ServerConfig, SessionSpec} import scala.jdk.OptionConverters._ -// Wildcard, not a named import: examples lives beside io.github.libtmux.scaladsl.cats, not inside -// it, so its generated extension methods need an explicit import. +// Wildcard, not a named import: examples lives beside +// io.github.libtmux.scaladsl.cats, not inside it, so its generated extension +// methods need an explicit import. import io.github.libtmux.scaladsl.cats.* import scala.concurrent.duration._ @@ -62,7 +63,7 @@ object ObserveChanges extends IOApp { ) assert( failure.isEmpty, - "the background poller must not have failed while still in use" + "the background poller failed while still in use" ) } } yield () From 6c8863156689c7f7977579f17bd0ae8e75647f92 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 14/59] Examples(style[ResourceBoundaries]): Wrap import why: In ResourceBoundaries.scala, line 6, the wildcard-import note, ran to 98 columns. Scalafmt does not reflow comments. what: - In ResourceBoundaries.scala, rewrap the wildcard import comment at 80 --- .../libtmux/scaladsl/examples/ResourceBoundaries.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ResourceBoundaries.scala b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ResourceBoundaries.scala index 55953423f..958b746f3 100644 --- a/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ResourceBoundaries.scala +++ b/examples/src/main/scala/io/github/libtmux/scaladsl/examples/ResourceBoundaries.scala @@ -3,8 +3,9 @@ package io.github.libtmux.scaladsl.examples import _root_.cats.effect.{ExitCode, IO, IOApp, Outcome, Resource} import _root_.cats.syntax.all._ import io.github.libtmux.{Server => JavaServer, ServerConfig, SessionSpec} -// Wildcard, not a named import: examples lives beside io.github.libtmux.scaladsl.cats, not inside -// it, so its generated extension methods need an explicit import. +// Wildcard, not a named import: examples lives beside +// io.github.libtmux.scaladsl.cats, not inside it, so its generated extension +// methods need an explicit import. import io.github.libtmux.scaladsl.cats.* import scala.concurrent.duration._ From da6cc3c27b56c04390f892d2eaa14cb989e89bde Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 15/59] Migration(style[fences]): Break long switch cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The recovery switch had cases up to 112 columns and the CRLF strip used a ternary at 87. Fences are never formatted, and the docs module compiles and runs them, so each rewrite keeps its // → assertions. what: - Strip the carriage return with stripTrailing instead of a ternary - Give the Recovery switch one outermost break per long case and shorter bindings - Put the ServerSnapshot arguments one per line - Move trailing and long comments onto their own wrapped lines --- MIGRATION.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 7b89dc28f..88f063a68 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,7 +21,7 @@ first: // Given: Server server CommandResult result = server.cmd("list-sessions", "-F", "#{session_name}"); List lines = result.stdout().stream() - .map(line -> line.endsWith("\r") ? line.substring(0, line.length() - 1) : line) + .map(line -> line.stripTrailing()) .toList(); ``` @@ -204,14 +204,19 @@ final class Recovery { return switch (failure) { case TargetGoneException gone -> "look the handle up again"; case ServerUnavailableException down -> "start a server"; - case CommandRejectedException refused -> "change the request: " + refused.errorLines(); - case DispatchException failed -> failed.safeToRetry() ? "send it again" : "read tmux's state first"; - case ControlEndedException ended -> "attach again and take a snapshot"; - case UnsupportedFeatureException unsupported -> "do without it: " + unsupported.getMessage(); - case UnencodableTextException unencodable -> "start the JVM in a UTF-8 locale"; - case MalformedResponseException malformed -> "report it: " + malformed.getMessage(); + case CommandRejectedException e -> + "change the request: " + e.errorLines(); + case DispatchException e -> + e.safeToRetry() ? "send it again" : "read state first"; + case ControlEndedException e -> "attach again and take a snapshot"; + case UnsupportedFeatureException e -> + "do without it: " + e.getMessage(); + case UnencodableTextException e -> + "start the JVM in a UTF-8 locale"; + case MalformedResponseException e -> "report it: " + e.getMessage(); case CardinalityException.NoMatch none -> "nothing matched"; - case CardinalityException.MultipleMatches many -> many.atLeast() + " matched"; + case CardinalityException.MultipleMatches m -> + m.atLeast() + " matched"; }; } } @@ -324,7 +329,10 @@ io.github.libtmux.snapshot.ServerSnapshot.of( java.time.Instant.now(), server.snapshot().serverPid().orElseThrow(), server.version(), - java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of()); + java.util.List.of(), + java.util.List.of(), + java.util.List.of(), + java.util.List.of()); ``` ### Key bindings are a view: `server.keys()` @@ -386,7 +394,9 @@ or catch it and restore the flag. ```java // Given: Server server Channel ready = server.channel("ready"); -ready.signal(); // tmux keeps a signal nobody was waiting for, so this wait returns at once +// tmux keeps a signal nobody was waiting for, so the wait below returns +// at once. +ready.signal(); try { ready.await(java.time.Duration.ofSeconds(30)); } catch (InterruptedException cancelled) { @@ -453,7 +463,8 @@ them. // Given: Pane pane pane.sendLine("./build --target release"); -// Answered by what the build prints, not by the echo of the line that started it. +// Answered by what the build prints, not by the echo of the line that +// started it. pane.awaitText("release", java.time.Duration.ofSeconds(60)); ``` From f610a7c32d95af33983021d2745e87e33a344532 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 16/59] README(style[fences]): Break chains, spread map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The window-name stream, the session lookup and the TMUX environment map ran past 80 columns in the root README fences. Fences are never formatted, and the docs module runs them, so each rewrite keeps its // → assertions. what: - Break the window-name stream and the session lookup one call per line - Spread the TMUX environment map one entry per line - Close the describe() assertion gap to one space - Name the session stream before the exactlyOne filter --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1883ddf2b..7843552b1 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,10 @@ traversal cannot see a half-changed server. // Given: Server server Window editor = server.newSession("demo").newWindow("editor"); -List names = server.windows().stream().map(Window::name).sorted().toList(); +List names = server.windows().stream() + .map(Window::name) + .sorted() + .toList(); names.contains("editor"); // → true editor.session().name(); // → demo @@ -164,7 +167,7 @@ editors.get(0).name(); // → editor An expression is a value, so it can also say what it is — which a lambda cannot: ```java -Window_.name().startsWith("edit").describe(); // → window_name starts-with edit +Window_.name().startsWith("edit").describe(); // → window_name starts-with edit ``` ### Find one thing @@ -184,7 +187,8 @@ Which is how "this session, or a new one" stays a single read: ```java // Given: Server server -Session work = server.session("work").orElseGet(() -> server.newSession("work")); +Session work = server.session("work") + .orElseGet(() -> server.newSession("work")); work.name(); // → work ``` @@ -195,8 +199,9 @@ work.name(); // → work // Given: Server server server.newSession("build"); +var sessions = server.sessions().stream(); Session build = Selections.exactlyOne( - server.sessions().stream().filter(Session_.name().is("build")).toList()); + sessions.filter(Session_.name().is("build")).toList()); build.name(); // → build ``` @@ -256,7 +261,9 @@ and `TmuxEnvironment` reads them back: ```java // Given: Path socket -Map inside = Map.of("TMUX", socket + ",1,$0", "TMUX_PANE", "%0"); +Map inside = Map.of( + "TMUX", socket + ",1,$0", + "TMUX_PANE", "%0"); TmuxEnvironment here = TmuxEnvironment.of(inside).orElseThrow(); From 1fd6eb9b526e5eac2e7fafe53bd74addf4f5b2ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 17/59] Docs(style[concurrency]): Shorten fan-out lambda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The virtual-thread fan-out in the concurrency guide ran past 80 columns on its lambda. The docs module runs the fence, so the rewrite keeps its // → assertions. what: - Shorten the lambda parameter in the virtual-thread fan-out --- docs/guide/concurrency.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/concurrency.md b/docs/guide/concurrency.md index 74240a996..81ca3c844 100644 --- a/docs/guide/concurrency.md +++ b/docs/guide/concurrency.md @@ -23,7 +23,7 @@ server.newSession("right"); int windows = 0; try (ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor()) { List> counts = server.sessions().stream() - .map(session -> threads.submit(() -> session.refresh().windows().size())) + .map(s -> threads.submit(() -> s.refresh().windows().size())) .toList(); for (Future count : counts) { windows += count.get(); From 3994ca0a0fa83d7698202689d59487dcc2445508 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 18/59] Docs(style[filtering]): Close assertion gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A describe() line in the filtering guide ran past 80 columns because of the spaces that aligned its // → assertion. The docs module runs the fence, so the assertion stays. what: - Close the describe() assertion gap to one space --- docs/guide/filtering.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 56dfbf17c..993598768 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -82,7 +82,7 @@ windows are zoomed". ```java var busy = Window_.panes().any(Pane_.command().startsWith("nv")); -busy.describe(); // → panes any (pane_current_command starts-with nv) +busy.describe(); // → panes any (pane_current_command starts-with nv) ``` This is the half a lambda cannot do, and the reason the AST is a sealed tree of From 0331f1083fc3da0dc75e1ace6745847e47b4bde8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 19/59] Docs(style[getting-started]): Break outer call why: The getting-started fences ran to 92 columns on the newSession lambda, the assertThrows call, the tmux.conf write and the frames comment. what: - Break the newSession lambda and the assertThrows arguments at the outermost level - Name the tmux.conf path before writing it - Rewrap the frames comment - Name the frame before appending its data in the watch loop --- docs/guide/getting-started.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 79da1dfbd..5902fdd53 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -58,7 +58,8 @@ it with a lambda, or hand it a description you built earlier. ```java // Given: Server server -Session build = server.newSession(s -> s.named("build").firstWindowNamed("editor")); +Session build = server.newSession( + s -> s.named("build").firstWindowNamed("editor")); Window logs = build.newWindow(w -> w.named("logs").running("sleep", "30")); build.name(); // → build @@ -111,7 +112,9 @@ older server: ```java // Given: Server server, Pane pane if (!server.version().atLeast(new TmuxVersion(3, 7, ""))) { - assertThrows(UnsupportedFeatureException.class, () -> pane.split(s -> s.empty())); + assertThrows( + UnsupportedFeatureException.class, + () -> pane.split(s -> s.empty())); } ``` @@ -220,10 +223,12 @@ try (ControlClient client = server.control(session); client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); - // Output arrives in frames as tmux flushes it, so one line can span several. + // Output arrives in frames as tmux flushes it, so one line can span + // several. StringBuilder seen = new StringBuilder(); while (seen.indexOf("streamed") < 0) { - seen.append(Delivery.kept(output.next(Duration.ofSeconds(5)).orElseThrow()).data()); + var frame = output.next(Duration.ofSeconds(5)).orElseThrow(); + seen.append(Delivery.kept(frame).data()); } seen.indexOf("streamed") >= 0; // → true } @@ -244,7 +249,8 @@ can predict. Pin one: ```java // Given: Path directory -Path tmuxConf = Files.writeString(directory.resolve("tmux.conf"), "set -g base-index 5\n"); +Path conf = directory.resolve("tmux.conf"); +Path tmuxConf = Files.writeString(conf, "set -g base-index 5\n"); ServerConfig pinned = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(directory.resolve("pinned"))) From 630e597e93b4de0c8e5d4b8fb2778e512a72e9d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 20/59] Docs(style[mcp]): Name the wait; tag output text why: The mcp guide read an event with an inline two-second wait that ran past 80 columns. Its framed-command listing is output, but an untagged fence counts as code, so that 227-column line counted as an example. what: - Name the two-second wait before reading the event - Tag the framed-command output fence text --- docs/guide/mcp.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index befa29af2..2345fa80a 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -93,7 +93,7 @@ others. So the command is framed instead. It is bracketed by two lines that print a random nonce, and only lines strictly between them are returned: -``` +```text ( \trap '/usr/bin/tmux -S /tmp/tmux.sock display-message -p lt3fa9-e:"$?"; /usr/bin/tmux -S /tmp/tmux.sock wait-for -S ch_lt3fa9; \exit 0' 0; /usr/bin/tmux -S /tmp/tmux.sock display-message -p lt3fa9-s; ( \eval 'pytest -q' ) ) ``` @@ -185,7 +185,8 @@ try (ControlClient client = server.control(session); EventSubscription events = client.subscribeEvents(32)) { client.watch("names", "@*", "#{window_name}"); - Delivery step = events.next(Duration.ofSeconds(2)).orElseThrow(); + Duration wait = Duration.ofSeconds(2); + Delivery step = events.next(wait).orElseThrow(); ControlEvent event = Delivery.kept(step); event.subscription(); // which watch this came from event.windowId(); // which window, when the watch is over windows From 5d673e3f3b52d8fc66c7bc40bd9f484bb76ccf37 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 21/59] Docs(style[operating-a-service]): Name config why: 2 lines in the operating-a-service guide ran past 80 columns, the widest at 102: the observed config and the traced builder chain. what: - Name the observed config and break the traced builder one call per line --- docs/guide/operating-a-service.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/guide/operating-a-service.md b/docs/guide/operating-a-service.md index e7a7c3d7f..9c81bd60f 100644 --- a/docs/guide/operating-a-service.md +++ b/docs/guide/operating-a-service.md @@ -46,7 +46,8 @@ ends, on the thread that ran it: ```java // Given: ServerConfig config List reports = new CopyOnWriteArrayList<>(); -try (Server observed = Server.open(config.toBuilder().observer(reports::add).build())) { +ServerConfig watched = config.toBuilder().observer(reports::add).build(); +try (Server observed = Server.open(watched)) { observed.cmd("display-message", "-p", "hi"); } OperationReport last = reports.get(reports.size() - 1); @@ -73,8 +74,9 @@ an OpenTelemetry context, a thread local - is there to read: // Given: ServerConfig config ThreadLocal request = new ThreadLocal<>(); Map seen = new ConcurrentHashMap<>(); -ServerConfig traced = - config.toBuilder().observer(report -> seen.put(report.verbs().get(0), request.get())).build(); +ServerConfig traced = config.toBuilder() + .observer(report -> seen.put(report.verbs().get(0), request.get())) + .build(); try (Server server = Server.open(traced)) { request.set("req-42"); server.cmd("display-message", "-p", "hi"); From b829f052a43e7d5f5782bdae90262603154dd080 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 22/59] Docs(style[options-and-hooks]): Shorten hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Two hook values and their trailing comments pushed the options-and-hooks fence to 97 columns, and a // → assertion sat past 80. The docs module runs the fence, so the assertion stays. what: - Shorten the hook values so the // → assertion fits - Move the two trailing hook comments above their lines --- docs/guide/options-and-hooks.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/guide/options-and-hooks.md b/docs/guide/options-and-hooks.md index 0ad64f6f4..b3d96b889 100644 --- a/docs/guide/options-and-hooks.md +++ b/docs/guide/options-and-hooks.md @@ -84,10 +84,10 @@ of one rather than a special case: // Given: Session session Hooks hooks = session.hooks(); -hooks.set("after-new-window", "display-message one"); -hooks.append("after-new-window", "display-message two"); +hooks.set("after-new-window", "display-message a"); +hooks.append("after-new-window", "display-message b"); -hooks.all().get("after-new-window"); // → [display-message one, display-message two] +hooks.all().get("after-new-window"); // → [display-message a, display-message b] ``` `set` replaces the whole array; `append` adds to it. `run(event)` runs what is @@ -100,8 +100,10 @@ silently discarded** — no error, on any supported release: ```java // Given: Window window -window.hooks().set("pane-focus-in", "display-message belongs-here"); // a window hook -window.hooks().set("alert-bell", "display-message does-not"); // a session hook +// A window hook. +window.hooks().set("pane-focus-in", "display-message belongs-here"); +// A session hook. +window.hooks().set("alert-bell", "display-message does-not"); window.hooks().all().containsKey("pane-focus-in"); // → true window.hooks().all().containsKey("alert-bell"); // → false From b9f3036a1f7c8fd9716c1cae3befce3ca8a876c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 23/59] Docs(style[scala-getting-started]): Wrap comment why: The Scala getting-started page ran to 96 columns on the CAPTURED comment and the gradlew command. Fences are never formatted, so both were rewrapped by hand. what: - Rewrap the CAPTURED comment - Split the gradlew command one task per continuation line --- docs/guide/scala/getting-started.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/guide/scala/getting-started.md b/docs/guide/scala/getting-started.md index 7a2697970..f30b65b3a 100644 --- a/docs/guide/scala/getting-started.md +++ b/docs/guide/scala/getting-started.md @@ -94,8 +94,9 @@ def firstClient(binary: String, socket: Path, configFile: Path): Unit = { ) window.selectLayout(Layout.EVEN_HORIZONTAL) second.select() - // window.panes is CAPTURED: it answers from window's own frozen capture, taken before the - // split, so this refreshes first rather than reading stale data. + // window.panes is CAPTURED: it answers from window's own frozen + // capture, taken before the split, so this refreshes first rather than + // reading stale data. val panes = window.refresh().panes assert(panes.size == 2) assert(panes.exists(_.info.id().value() == second.info.id().value())) @@ -127,7 +128,10 @@ Follow with [queries](query.md), [ownership](ownership.md), then The facades build with the rest of the repository, from its root: ```console -$ ./gradlew :libtmux-scala:check :libtmux-scala-cats:check :libtmux-scala-ox:check +$ ./gradlew \ + :libtmux-scala:check \ + :libtmux-scala-cats:check \ + :libtmux-scala-ox:check ``` The real-tmux suites run with the Java ones in `integration-tests`: From afac24873a98f3181c06dacc050c103efbd26371 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 24/59] Docs(style[scala-query]): Break the filter lambda why: The native filter lambda in the Scala query page ran to 96 columns on one line. what: - Break the native filter lambda onto its own lines --- docs/guide/scala/query.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/scala/query.md b/docs/guide/scala/query.md index 28607f43e..6b9c6c8a6 100644 --- a/docs/guide/scala/query.md +++ b/docs/guide/scala/query.md @@ -68,7 +68,9 @@ Using.resource(Server.open(config)) { server => val panes = server.panes() val expression = Pane.command.is("cat") && Pane.width.atLeast(1) val selected = panes.matching(expression) - val native = panes.filter(p => p.info.currentCommand() == "cat" && p.info.size().width() >= 1) + val native = panes.filter { p => + p.info.currentCommand() == "cat" && p.info.size().width() >= 1 + } assert(selected == native) } ``` From d4766705fd64791533fd01df42a55dcb91163315 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 25/59] Docs(style[snapshots-and-handles]): Wrap a comment why: The history comment in the snapshots-and-handles fence ran past 80 columns. Fences are never formatted, so the comment was rewrapped by hand. what: - Rewrap the history comment --- docs/guide/snapshots-and-handles.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guide/snapshots-and-handles.md b/docs/guide/snapshots-and-handles.md index 0d530855e..0a6a83a14 100644 --- a/docs/guide/snapshots-and-handles.md +++ b/docs/guide/snapshots-and-handles.md @@ -161,7 +161,8 @@ pane.sendLine("echo captured"); List everything = pane.capture(c -> c.fromStartOfHistory()); List recent = pane.capture(c -> c.from(-10)); -// The history contains at least what is on screen, whatever the shell has printed. +// The history contains at least what is on screen, whatever the shell has +// printed. recent.size() <= everything.size(); // → true ``` From 32d7e535d56bf37963589d3e61437dd8465a4857 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 26/59] Docs(style[streaming]): Name the streaming steps why: 8 lines in the streaming guide ran past 80 columns, the widest at 107: the ready limit, the signal command, the timer executor, the next event and the mirror wait loop. what: - Name the ready limit, the signal command, the timer executor and the next event - Shorten the lambda and pattern bindings - Break the mirror wait loop at the outermost call - Name the frame before appending its data in the output loop --- docs/guide/streaming.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index de6f0d67d..b802d44f3 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -23,12 +23,14 @@ done.drain(); // Keys sent to a shell that is not reading yet are swallowed, and tmux hands // back a pane id the moment it forks the pane — before the prompt is drawn. -pane.await(ready -> !ready.expand("#{cursor_x},#{cursor_y}").equals("0,0"), Duration.ofSeconds(10)); +Duration limit = Duration.ofSeconds(10); +pane.await(p -> !p.expand("#{cursor_x},#{cursor_y}").equals("0,0"), limit); // Name the server's own tmux and socket rather than trusting the pane's PATH: a // client from a different release than the server is dropped, not served. String tmux = server.config().binaryPath(); -pane.sendLine("sleep 1; " + tmux + " -S " + socket + " wait-for -S build-finished"); +String signal = tmux + " -S " + socket + " wait-for -S build-finished"; +pane.sendLine("sleep 1; " + signal); done.await(Duration.ofSeconds(20)); // SIGNALLED, once the command reaches it ``` @@ -105,7 +107,8 @@ try (ControlClient client = server.control(session); StringBuilder seen = new StringBuilder(); while (seen.indexOf("streamed") < 0) { - seen.append(Delivery.kept(output.next(Duration.ofSeconds(5)).orElseThrow()).data()); + var frame = output.next(Duration.ofSeconds(5)).orElseThrow(); + seen.append(Delivery.kept(frame).data()); } seen.indexOf("streamed") >= 0; // → true } @@ -136,7 +139,8 @@ try (ControlClient client = server.control(session); EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); - CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(output::close); + Executor timer = CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS); + timer.execute(output::close); StringBuilder seen = new StringBuilder(); try (Stream> steps = output.stream()) { @@ -230,9 +234,10 @@ try (ControlClient client = server.control(session); String renamed = null; while (renamed == null) { - Notification seen = Delivery.kept(events.next(Duration.ofSeconds(5)).orElseThrow()).notification(); + var next = events.next(Duration.ofSeconds(5)).orElseThrow(); + Notification seen = Delivery.kept(next).notification(); renamed = switch (seen) { - case Notification.WindowRenamed(var window, var name, var attached) -> name; + case Notification.WindowRenamed(var id, var name, var on) -> name; default -> null; }; } @@ -257,8 +262,10 @@ try (ServerMirror mirror = ServerMirror.open(session)) { session.newWindow("mirrored"); ServerMirror.View view = mirror.current(); - while (view.snapshot().windows().stream().noneMatch(w -> w.name().equals("mirrored"))) { - view = mirror.awaitNewer(view.epoch(), Duration.ofSeconds(5)).orElseThrow(); + Duration wait = Duration.ofSeconds(5); + while (view.snapshot().windows().stream() + .noneMatch(w -> w.name().equals("mirrored"))) { + view = mirror.awaitNewer(view.epoch(), wait).orElseThrow(); } view.epoch() > 0; // → true } From 07e56eac3b9f3a5cd47a9a6111c4cb990b00f1aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 27/59] Examples(style[README]): Split the java command why: The console command in examples/README.md ran past 80 columns because the program path and arguments shared one line. what: - Split the java command so the program path and arguments sit on their own line --- examples/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index c9452dab1..480dfd500 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,7 +43,8 @@ $ ./gradlew :examples:compileJava ```console $ java -cp examples/build/classes/java/main:libtmux/build/classes/java/main \ - io.github.libtmux.examples.FindPanesRunning /tmp/libtmux-java-dev/demo/s nvim + io.github.libtmux.examples.FindPanesRunning \ + /tmp/libtmux-java-dev/demo/s nvim ``` The suite launches each program's own `main` in a fresh JVM and checks what it From c5705a2c01652f5fc25c23f8036157fbd840f2ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 28/59] Examples(style[api-README]): Hold root in $src why: The cp command in the api README named a source path 93 columns wide. what: - Hold the source root in $src so the cp command fits --- examples/api/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/api/README.md b/examples/api/README.md index 6eb46ca6d..fa2275ec7 100644 --- a/examples/api/README.md +++ b/examples/api/README.md @@ -49,7 +49,8 @@ $ git clone https://github.com/libtmux/libtmux-java libtmux-source && \ cp libtmux-source/examples/api/gradle.properties . && \ cp libtmux-source/examples/api/run.sh . && \ mkdir -p src/main/java && \ - cp libtmux-source/examples/src/main/java/io/github/libtmux/examples/api/java/Capture.java \ + src=libtmux-source/examples/src/main/java && \ + cp "$src"/io/github/libtmux/examples/api/java/Capture.java \ src/main/java/ ``` From 7cb9cb7fa0c663bc1da84b6bcc42359794678efb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 29/59] Jackson(style[README]): Name pane, break chain why: The Jackson README fences ran to 93 columns on the pane model, the Fields.text chain and a describe() assertion gap. what: - Name the pane model once and reuse it - Break the Fields.text chain one call per line - Close the describe() assertion gap to one space --- libtmux-jackson/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libtmux-jackson/README.md b/libtmux-jackson/README.md index 26e7130ba..3cfcb5b2e 100644 --- a/libtmux-jackson/README.md +++ b/libtmux-jackson/README.md @@ -52,7 +52,7 @@ String json = FilterJson.writeString( FilterExpr restored = FilterJson.readString(json, LibTmuxModels.pane()); -restored.describe(); // → pane_current_command starts-with nvim +restored.describe(); // → pane_current_command starts-with nvim ``` ## Use it like any other filter @@ -62,8 +62,9 @@ hold — reading it from JSON changes nothing about how it is applied: ```java // Given: Server server -String json = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()); -FilterExpr active = FilterJson.readString(json, LibTmuxModels.pane()); +var model = LibTmuxModels.pane(); +String json = FilterJson.writeString(Pane_.active().isTrue(), model); +FilterExpr active = FilterJson.readString(json, model); server.panes().stream().filter(active).toList().size(); // → 1 ``` @@ -99,7 +100,8 @@ supplied model have wire identity: ```java -FilterExpr mine = Fields.text("session_name", (Session s) -> s.name().toLowerCase()) +FilterExpr mine = Fields + .text("session_name", (Session s) -> s.name().toLowerCase()) .is("demo"); FilterJson.writeString(mine, LibTmuxModels.session()); From fb0cc96a3b5f29ac4ea363e4630fe7a9e35f56f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:00 -0500 Subject: [PATCH 30/59] JUnit5(style[README]): Name capture and event why: The JUnit 5 README fences ran to 94 columns on a trailing fixture comment, the capture and the next event. what: - Move the fixture comment above its line - Name the capture and the next event --- libtmux-junit5/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libtmux-junit5/README.md b/libtmux-junit5/README.md index 43523731f..eb782c992 100644 --- a/libtmux-junit5/README.md +++ b/libtmux-junit5/README.md @@ -41,12 +41,14 @@ class MyToolTest { @Test void itRunsSomethingInAPane(Server server) { - Session session = server.sessions().get(0); // the fixture made one, named "libtmux" + // The fixture made one session, named "libtmux". + Session session = server.sessions().get(0); Pane pane = session.windows().get(0).panes().get(0); pane.sendLine("echo hello"); - assertTrue(pane.capture().stream().anyMatch(line -> line.contains("hello"))); + var lines = pane.capture(); + assertTrue(lines.stream().anyMatch(line -> line.contains("hello"))); } } ``` @@ -170,7 +172,8 @@ try (Server server = tmux.server(); ControlClient client = server.control(server.sessions().getFirst()); EventSubscription output = client.subscribeOutput(8)) { tmux.output(pane, "built"); - Delivery.kept(output.next(Duration.ofSeconds(5)).orElseThrow()).data(); // → built + var step = output.next(Duration.ofSeconds(5)).orElseThrow(); + Delivery.kept(step).data(); // → built } ``` From fcc5cea357e65e515f63250a2fece4b881baab75 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 31/59] Kotlin(style[README]): Break branch, name session why: The Kotlin README fences ran to 98 columns on the DispatchException branch and the control.output callback. what: - Break the DispatchException branch after the arrow - Name the target session before the control.output callback --- libtmux-kotlin/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libtmux-kotlin/README.md b/libtmux-kotlin/README.md index 6c53853f3..44a3b78c8 100644 --- a/libtmux-kotlin/README.md +++ b/libtmux-kotlin/README.md @@ -152,7 +152,8 @@ fun nextStep(failure: LibTmuxException): String = is TargetGoneException -> "look it up again" is ServerUnavailableException -> "start a server" is CommandRejectedException -> "change the request" - is DispatchException -> if (failure.safeToRetry()) "send it again" else "read state first" + is DispatchException -> + if (failure.safeToRetry()) "send it again" else "read state first" is ControlEndedException -> "attach again" is UnsupportedFeatureException -> "do without" is UnencodableTextException -> "use a UTF-8 locale" @@ -178,8 +179,9 @@ import kotlinx.coroutines.flow.first withServer(config) { server -> val session = server.newSession("flow-demo") withControl(server, session) { control -> + val target = session.name val step = control.output(capacity = 64) { - control.send("send-keys", "-t", session.name, "echo flowed", "Enter") + control.send("send-keys", "-t", target, "echo flowed", "Enter") }.first() val outcome = when (step) { // exhaustive, no else is Delivery.Event -> "kept" From f80b8bce63a810a126ec6e814261b806e12cf7ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 32/59] MCP(style[README]): Pretty-print the run result why: The MCP README printed the run result as one JSON line 84 columns wide. The pretty-printed form has the same fields, one per line. what: - Pretty-print the run result JSON, one field per line --- libtmux-mcp/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index c174e6c0d..c7cfca9e6 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -263,7 +263,11 @@ inference: ``` ```json -{"outcome": "SIGNALLED", "exit_status": 1, "output": ["...", "1 failed, 84 passed"]} +{ + "outcome": "SIGNALLED", + "exit_status": 1, + "output": ["...", "1 failed, 84 passed"] +} ``` `outcome` is `SIGNALLED` when the command finished, `TIMED_OUT` when it was still From 1a80e7209818e2d2348de2ee66b22bdbfdb6b146 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 33/59] Cats(style[README]): Break filter at && why: One session-renamed filter in the Cats README ran to 90 columns. what: - Break the session-renamed filter at the && operator --- libtmux-scala-cats/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux-scala-cats/README.md b/libtmux-scala-cats/README.md index 057059eea..7c1e3d903 100644 --- a/libtmux-scala-cats/README.md +++ b/libtmux-scala-cats/README.md @@ -81,7 +81,8 @@ Server.resource[IO](config).use { server => .map(_.notification()) .collect { case event: Notification.SessionRenamed => event } .filter(event => - event.session().value() == session.info.id().value() && event.name() == name + event.session().value() == session.info.id().value() && + event.name() == name ) .take(1) .compile From 2abe4e62254440df2ad521cbe92b48976cff3b32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 34/59] Workspace(style[README]): Name YAML, break text why: The Workspace README ran to 100 columns on an inline YAML document and the invalid-layout text. what: - Name the YAML text before parsing it - Break the invalid-layout YAML at a word boundary --- libtmux-workspace/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libtmux-workspace/README.md b/libtmux-workspace/README.md index 8a02a7447..71300065c 100644 --- a/libtmux-workspace/README.md +++ b/libtmux-workspace/README.md @@ -98,7 +98,8 @@ It adds a session; it does not take over the one you had: ```java // Given: Server server -Workspace workspace = WorkspaceBuilder.parse("session_name: added\nwindows:\n - window_name: w\n"); +String yaml = "session_name: added\nwindows:\n - window_name: w\n"; +Workspace workspace = WorkspaceBuilder.parse(yaml); WorkspaceBuilder.build(server, workspace); @@ -114,7 +115,9 @@ while it is still text**, before a single window exists. ```java -WorkspaceBuilder.parse("session_name: s\nwindows:\n - window_name: w\n layout: sideways"); +String yaml = "session_name: s\nwindows:\n - window_name: w\n" + + " layout: sideways"; +WorkspaceBuilder.parse(yaml); // IllegalArgumentException — tmux has no layout called "sideways" ``` From 387eb6245c1af15a33c2d7694c75332dc7c88550 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 35/59] Library(style[README]): Name the session stream why: The library README ran to 94 columns on the endpoint comment and the session lookup. what: - Move the endpoint comment above its line - Name the session stream before the exactlyOne filter --- libtmux/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libtmux/README.md b/libtmux/README.md index 256625024..569e763eb 100644 --- a/libtmux/README.md +++ b/libtmux/README.md @@ -62,7 +62,8 @@ Needs JDK 25 and a tmux between 3.2a and 3.7c. ```java // Given: Path socket ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) // wherever you want the server to live + // Wherever you want the server to live. + .endpoint(ServerEndpoint.socketPath(socket)) .build(); try (Server server = Server.open(config)) { @@ -144,8 +145,9 @@ go wrong: // Given: Server server server.newSession("build"); +var sessions = server.sessions().stream(); Session build = Selections.exactlyOne( - server.sessions().stream().filter(Session_.name().is("build")).toList()); + sessions.filter(Session_.name().is("build")).toList()); build.name(); // → build ``` From 2ea100ee5f836147d89028ccb782f8732f74395e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 36/59] Pane(style[await]): Name the await limit why: The await example ran to 89 columns. what: - Name the five-second limit and shorten the predicate parameter --- libtmux/src/main/java/io/github/libtmux/Pane.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 7ec230741..44aab3103 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -471,7 +471,8 @@ public PaneRun run(String command, Duration timeout) throws InterruptedException * its deadline rules. * *

{@code
-     * pane.await(fresh -> !fresh.currentCommand().equals("zsh"), Duration.ofSeconds(5));
+     * Duration limit = Duration.ofSeconds(5);
+     * pane.await(p -> !p.currentCommand().equals("zsh"), limit);
      * }
* * @param settled receives this pane as it is now From 99d344c948b8b9849ed9c727de1a52b88cf37a20 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 37/59] Pane(style[expand]): Bind the format string why: The expand example ran to 83 columns on its second format. what: - Bind the second format to a variable and keep #{pane_index}, so the result still names a pane; the call then fits in 80 columns --- libtmux/src/main/java/io/github/libtmux/Pane.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 44aab3103..eaa0ac831 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -723,7 +723,8 @@ private String currentCommandNow() { * *
{@code
      * String title = pane.expand("#{pane_title}");
-     * String where = pane.expand("#{session_name}:#{window_index}.#{pane_index}");
+     * String format = "#{session_name}:#{window_index}.#{pane_index}";
+     * String where = pane.expand(format);
      * }
* * @param format a tmux format, usually of the shape {@code #{name}} From 946a0950ca2248ec27d939f34ccd284f114605c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 38/59] Server(style[newSession]): Name Dimensions why: The newSession example ran to 95 columns with the Dimensions value inline. what: - In Server.java, name the Dimensions value before the call --- libtmux/src/main/java/io/github/libtmux/Server.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index cfe53f25e..b34ead5d1 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -133,7 +133,8 @@ public Session newSession(String name) { * Creates a session as described. * *
{@code
-     * Session build = server.newSession(s -> s.named("build").sized(new Dimensions(120, 40)));
+     * Dimensions size = new Dimensions(120, 40);
+     * Session build = server.newSession(s -> s.named("build").sized(size));
      * }
* * @param configure receives a builder holding tmux's defaults From 23115b5de4afbff26a4c5ae1671a9673a8bc3abf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 39/59] Server(style[paneFields]): Name the field list why: The paneFields example ran to 99 columns with the field list inline. what: - Name the field list before the call --- libtmux/src/main/java/io/github/libtmux/Server.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index b34ead5d1..1eec25985 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -519,7 +519,8 @@ Map variables(List names, Function expan * same instant. * *
{@code
-     * Map> ttys = server.paneFields(List.of("pane_tty", "pane_dead"));
+     * List names = List.of("pane_tty", "pane_dead");
+     * Map> ttys = server.paneFields(names);
      * }
* * @param names tmux format variable names, at most 32 From 21c1263a8828f51d7b248cd893d88b071c3227af Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 40/59] Server(style[within]): Name scoped server why: The within example ran to 92 columns reading the first pane through the scoped server. what: - Name the scoped server before reading its first pane --- libtmux/src/main/java/io/github/libtmux/Server.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 1eec25985..1cf35adf9 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -726,7 +726,8 @@ static Server using(ServerConfig config, TmuxTransport transport, PaneEcho echo) * handle taken from it, gives tmux this long rather than the server's default: * *
{@code
-     * List screen = server.within(Duration.ofMillis(500)).panes().get(0).capture();
+     * Server quick = server.within(Duration.ofMillis(500));
+     * List screen = quick.panes().get(0).capture();
      * }
* *

Shares this server's transport, so it has the same identity and its handles compare equal From 7c98ef0fc56c2de1ac54ec62b29823fde958cbf0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 41/59] Session(style[newWindow]): Run top not journalctl why: The newWindow example ran to 89 columns on the journalctl -f command. what: - Run top instead of journalctl -f, which keeps the window just as long - Name the window top and bind it to top, so the name, the variable and the command agree --- libtmux/src/main/java/io/github/libtmux/Session.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index a90c43a05..7ecb9824f 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -192,7 +192,7 @@ public Window newWindow(String name) { * Creates a window in this session as described. * *

{@code
-     * Window logs = session.newWindow(w -> w.named("logs").running("journalctl", "-f"));
+     * Window top = session.newWindow(w -> w.named("top").running("top"));
      * }
* * @param configure receives a builder holding tmux's defaults From 3c8c489dd74d3f28bcf9f41fc36448378be4e686 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 42/59] WindowSpec(style[example]): Run top in the example why: The class example ran to 85 columns on the journalctl -f command. what: - Run top instead of journalctl -f --- libtmux/src/main/java/io/github/libtmux/WindowSpec.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java index cf54c0c04..c477375f2 100644 --- a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java @@ -18,7 +18,7 @@ * it can be named, reused, and applied wherever it fits. * *
{@code
- * Window logs = session.newWindow(w -> w.named("logs").running("journalctl", "-f"));
+ * Window logs = session.newWindow(w -> w.named("logs").running("top"));
  * }
*/ public final class WindowSpec { From 74b6448860aac5c6699b17476f101535018fdf58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 43/59] SessionSpec(style[example]): Name Dimensions why: The class example ran to 91 columns with the Dimensions value inline. what: - In SessionSpec.java, name the Dimensions value before the call --- libtmux/src/main/java/io/github/libtmux/SessionSpec.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java index b362d1c6b..cd86e3baf 100644 --- a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java @@ -23,7 +23,8 @@ * library usually runs. * *
{@code
- * Session build = server.newSession(s -> s.named("build").sized(new Dimensions(120, 40)));
+ * Dimensions size = new Dimensions(120, 40);
+ * Session build = server.newSession(s -> s.named("build").sized(size));
  * }
*/ public final class SessionSpec { From a0519b5a8c74fba377e3d89c534113585d85c348 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 44/59] EventSubscription(style[stream]): Shorten lambda why: The stream example ran to 88 columns on a long lambda parameter. what: - Shorten the lambda parameter --- .../main/java/io/github/libtmux/control/EventSubscription.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java index 057674d90..390918cf7 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java +++ b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java @@ -356,7 +356,7 @@ private Optional> take() { * *
{@code
      * try (Stream> steps = subscription.stream()) {
-     *     steps.map(Delivery::kept).forEach(output -> System.out.print(output.data()));
+     *     steps.map(Delivery::kept).forEach(o -> System.out.print(o.data()));
      * }
      * }
* From 52b6afcc84d43590b04fc748a134338ed15f58d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 45/59] Notification(style[example]): Shorten bindings why: The switch example ran to 112 columns on its record-pattern bindings. what: - Shorten the record pattern bindings and break after the arrow --- .../src/main/java/io/github/libtmux/control/Notification.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libtmux/src/main/java/io/github/libtmux/control/Notification.java b/libtmux/src/main/java/io/github/libtmux/control/Notification.java index 18b50a4cd..ffb3c440d 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/Notification.java +++ b/libtmux/src/main/java/io/github/libtmux/control/Notification.java @@ -11,7 +11,8 @@ * *
{@code
  * switch (event.notification()) {
- *     case Notification.WindowRenamed(WindowId window, String name, boolean attached) -> relabel(window, name);
+ *     case Notification.WindowRenamed(WindowId id, String name, boolean on) ->
+ *         relabel(id, name);
  *     case Notification.SessionsChanged changed -> refreshSessions();
  *     default -> {}
  * }

From 7b91d1d127a9a643ba3be9a52815f35c7194275d Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:52:01 -0500
Subject: [PATCH 46/59] OptionKey(style[example]): Drop the kept binding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

why: The history-limit example ran to 88 columns on a declaration.

what:
- Drop the kept binding; the // → assertion reads the expression value,
  which a declaration would hide
---
 libtmux/src/main/java/io/github/libtmux/OptionKey.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/libtmux/src/main/java/io/github/libtmux/OptionKey.java b/libtmux/src/main/java/io/github/libtmux/OptionKey.java
index 09c87f3ac..dbea139e9 100644
--- a/libtmux/src/main/java/io/github/libtmux/OptionKey.java
+++ b/libtmux/src/main/java/io/github/libtmux/OptionKey.java
@@ -9,7 +9,7 @@
  *
  * 
{@code
  * session.options().set(OptionKey.HISTORY_LIMIT, 50_000);
- * int kept = session.options().get(OptionKey.HISTORY_LIMIT).orElseThrow();   // → 50000
+ * session.options().get(OptionKey.HISTORY_LIMIT).orElseThrow();   // → 50000
  *
  * OptionKey bell = OptionKey.flag("visual-bell");
  * }
From 1c9dfd732163b6e28c88e01c3958be4ff1c70654 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 47/59] FakeTmux(style[example]): Shorten build line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The capture assertion ran to 89 columns. what: - Show a shorter build line so the // → assertion fits --- .../src/main/java/io/github/libtmux/junit5/FakeTmux.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java index e782da0ea..9da98996b 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/FakeTmux.java @@ -33,11 +33,11 @@ *
{@code
  * FakeTmux tmux = new FakeTmux();
  * PaneId editor = tmux.addSession("work");
- * tmux.show(editor, "$ make", "make: Nothing to be done for 'all'.");
+ * tmux.show(editor, "$ make", "built");
  *
  * try (Server server = tmux.server()) {
  *     Pane pane = server.pane(editor).orElseThrow();
- *     pane.capture();                 // → [$ make, make: Nothing to be done for 'all'.]
+ *     pane.capture();                 // → [$ make, built]
  *     pane.sendLine("make test");
  * }
  *

From 6c27d35c00879af328932a3044244191e830c621 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 09:41:50 -0500
Subject: [PATCH 48/59] Contributing(style[console]): Break the reap loop

why: The pgrep loop in the reaping steps ran to 85 columns.

what:
- Continue the one-line for loop over three lines with backslashes, so
  the block still pastes and runs as one command
---
 .github/CONTRIBUTING.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 79b5d0acf..750ddebfd 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -79,7 +79,9 @@ Then find whose it is, since a socket path exists only in the process's own
 command line:
 
 ```console
-$ for p in $(pgrep tmux); do tr '\0' ' ' < /proc/$p/cmdline | rg -o '\-S [^ ]+'; done
+$ for p in $(pgrep tmux); do \
+    tr '\0' ' ' < /proc/$p/cmdline | rg -o '\-S [^ ]+'; \
+  done
 ```
 
 Kill only sockets under this port's roots. Another port's servers are not yours

From 8a318e6cc7340ecb89196aed39df20bd9cc5eddc Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 09:41:50 -0500
Subject: [PATCH 49/59] Releasing(style[fences]): Break secret, tag output

why: The gh secret set line ran to 85 columns. The version order block
had no language tag, so a reader could not tell output from a command.

what:
- Continue gh secret set before && with a backslash
- Tag the version order block text
---
 RELEASING.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/RELEASING.md b/RELEASING.md
index dbb660353..2ef28c26a 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -7,7 +7,7 @@ are done, so cutting the next release is a tag and someone pressing publish.
 
 `alpha` is the lowest qualifier Maven's comparator knows. Its full order is
 
-```
+```text
 alpha < beta < milestone < rc < snapshot < (release) < sp
 ```
 
@@ -150,7 +150,8 @@ $ gpg --armor --export-secret-keys  > /tmp/libtmux-signing.asc
 | `CENTRAL_PORTAL_PASSWORD` | the token's password half |
 
 ```console
-$ gh secret set SIGNING_KEY < /tmp/libtmux-signing.asc && rm /tmp/libtmux-signing.asc
+$ gh secret set SIGNING_KEY < /tmp/libtmux-signing.asc \
+    && rm /tmp/libtmux-signing.asc
 ```
 
 An unset secret reaches a workflow as an **empty string**, not as nothing, and

From e57563a13636f05f30d94cdd1ef26ff7cdc88de8 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 09:41:51 -0500
Subject: [PATCH 50/59] ModuleTests(style[README]): Break the sbtw run

why: The sbtw command ran to 101 columns.

what:
- Continue the command before the task list with a backslash, keeping
  one pasteable command
---
 module-tests/README.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/module-tests/README.md b/module-tests/README.md
index b04ef4c6c..9a0a343d5 100644
--- a/module-tests/README.md
+++ b/module-tests/README.md
@@ -37,7 +37,8 @@ $ ./gradlew -p module-tests/scala run -PlibtmuxVersion=0.0.1-alpha.17-SNAPSHOT
 ```
 
 ```console
-$ module-tests/sbt/sbtw -Dlibtmux.version=0.0.1-alpha.17-SNAPSHOT core/run cats/run ox/run direct/run
+$ module-tests/sbt/sbtw -Dlibtmux.version=0.0.1-alpha.17-SNAPSHOT \
+    core/run cats/run ox/run direct/run
 ```
 
 ```console

From e6a76ddc4508e4b3413f18c998425aea9cabe23c Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 11:59:07 -0500
Subject: [PATCH 51/59] Examples(style[build.gradle.kts]): Wrap comments

why: The module build file is one a reader opens to see how the examples
are wired. Its comments ran to 108 columns, and a Gradle file has no
formatter to wrap them.

what:
- Wrap the header comment and the libtmux-mcp comment at 80 columns
- Drop the dash that the wrap would have stranded at a line end
---
 examples/build.gradle.kts | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts
index abc7fc7b9..9ad81aa4d 100644
--- a/examples/build.gradle.kts
+++ b/examples/build.gradle.kts
@@ -1,9 +1,11 @@
 // Runnable programs, not snippets. Never published.
 //
-// The README and the guides carry snippets, and `DocumentationSnippetsTest` runs those. These are the other
-// thing: whole programs with a `main`, short enough to read in one go and real enough to run. The
-// suite in this module runs every one of them against a real tmux, so an example cannot quietly stop
-// working — which is the failure mode that makes most projects' examples worthless.
+// The README and the guides carry snippets, and `DocumentationSnippetsTest`
+// runs those. These are the other thing: whole programs with a `main`, short
+// enough to read in one go and real enough to run. The suite in this module
+// runs every one of them against a real tmux, so an example cannot quietly
+// stop working, which is the failure mode that makes most projects' examples
+// worthless.
 plugins {
     // The Scala convention includes Java; Kotlin adds the coroutine examples.
     id("libtmux.scala-library")
@@ -20,7 +22,8 @@ dependencies {
     implementation(project(":libtmux-scala-cats"))
     implementation(project(":libtmux-scala-ox"))
 
-    // Embedding libtmux-mcp means supplying the transport, which means supplying its JSON mapper.
+    // Embedding libtmux-mcp means supplying the transport, which means
+    // supplying its JSON mapper.
     implementation(project(":libtmux-mcp"))
     implementation(libs.jackson.databind)
     implementation(libs.mcp.core)

From 92584c824f488cc6638b76bce1c23fdb013eda9a Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 11:59:07 -0500
Subject: [PATCH 52/59] Examples(style[scala-cats]): Break the dependency

why: The Cats example's build file copied into a reader's project held
one dependency line at 84 columns. The -SNAPSHOT version is part of the
coordinate and cannot be dropped.

what:
- Put the libtmux-scala-cats coordinate on its own line inside
  implementation( ), unchanged
---
 examples/api/scala-cats/build.gradle.kts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/examples/api/scala-cats/build.gradle.kts b/examples/api/scala-cats/build.gradle.kts
index e2a784d63..ce18af466 100644
--- a/examples/api/scala-cats/build.gradle.kts
+++ b/examples/api/scala-cats/build.gradle.kts
@@ -6,7 +6,9 @@ plugins {
 repositories { mavenCentral() }
 
 dependencies {
-    implementation("io.github.libtmux:libtmux-scala-cats_3:0.0.1-alpha.17-SNAPSHOT")
+    implementation(
+        "io.github.libtmux:libtmux-scala-cats_3:0.0.1-alpha.17-SNAPSHOT"
+    )
     implementation("org.scala-lang:scala3-library_3:3.9.0")
 }
 

From 93bb1813dcdf5dc0fc8dc1873cc8f83f4d7cd917 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 11:59:07 -0500
Subject: [PATCH 53/59] Examples(style[api-run]): Break the first session

why: The launcher a reader runs held one tmux call at 81 columns. A
shell script has no formatter to break it.

what:
- Continue the first new-session call after its -f config flag with a
  backslash, leaving the command unchanged
---
 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 83de5e6aa..ea33d5aa9 100644
--- a/examples/api/run.sh
+++ b/examples/api/run.sh
@@ -41,7 +41,8 @@ trap 'exit 1' HUP INT TERM
 
 unset TMUX TMUX_PANE
 printf 'set-option -g default-shell /bin/sh\n' > "$config"
-"$binary" -S "$socket" -f "$config" new-session -d -s work-one -n editor /bin/cat
+"$binary" -S "$socket" -f "$config" \
+    new-session -d -s work-one -n editor /bin/cat
 "$binary" -S "$socket" split-window -h -t '=work-one:editor' /bin/cat
 "$binary" -S "$socket" new-window -d -t '=work-one' -n logs /bin/cat
 "$binary" -S "$socket" new-session -d -s work-two -n editor /bin/cat

From 41bf176872763452b28b9d8b4702c3bd5040cc33 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 11:59:07 -0500
Subject: [PATCH 54/59] Examples(style[ApiProgramsTest]): Name the checks

why: The manifest test and the launcher it drives held 44 lines over 80
columns. palantir-java-format joins a wrapped call back onto one line
whenever it fits 120, so breaking lines by hand did not survive
spotlessApply. Named pieces stay short.

what:
- ApiProgramsTest: lift the program names, source walk and setup files
  into locals and helpers, and give the parameterized case a named
  helper
- MainsTest: put the JVM command in a Launch record, split the timeout
  helpers and process shutdown into methods, and name the fixture
  socket check
- Leave every assertion, expected string and deadline as it was
- ApiProgramsTest calls MainsTest, so both files change together
---
 .../libtmux/examples/ApiProgramsTest.java     | 111 ++++++-----
 .../io/github/libtmux/examples/MainsTest.java | 182 +++++++++++-------
 2 files changed, 181 insertions(+), 112 deletions(-)

diff --git a/examples/src/test/java/io/github/libtmux/examples/ApiProgramsTest.java b/examples/src/test/java/io/github/libtmux/examples/ApiProgramsTest.java
index 43f4616ff..44688585a 100644
--- a/examples/src/test/java/io/github/libtmux/examples/ApiProgramsTest.java
+++ b/examples/src/test/java/io/github/libtmux/examples/ApiProgramsTest.java
@@ -3,7 +3,6 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Named.named;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -11,93 +10,119 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
+import org.junit.jupiter.api.Named;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
-/** Complete common API programs, executed by their displayed {@code api/run.sh} launcher. */
+/**
+ * Complete common API programs, executed by their displayed {@code api/run.sh}
+ * launcher.
+ */
 final class ApiProgramsTest {
     private static final Path ROOT = Path.of("..");
+    private static final String API_PACKAGE = "io/github/libtmux/examples/api";
 
     static Stream programs() throws IOException {
-        return StreamSupport.stream(manifest().required("examples").spliterator(), false);
+        JsonNode examples = manifest().required("examples");
+        return StreamSupport.stream(examples.spliterator(), false);
     }
 
     static Stream cases() throws IOException {
-        return programs()
-                .map(program -> Arguments.of(named(program.required("id").asText(), program)));
+        return programs().map(ApiProgramsTest::named);
+    }
+
+    private static Arguments named(JsonNode program) {
+        String id = program.required("id").asText();
+        return Arguments.of(Named.named(id, program));
     }
 
     private static JsonNode manifest() throws IOException {
-        return new ObjectMapper().readTree(Path.of("api/manifest.json").toFile());
+        Path manifest = Path.of("api/manifest.json");
+        return new ObjectMapper().readTree(manifest.toFile());
     }
 
     @Test
     void manifestCoversEveryWholeProgramAndSetupFile() throws Exception {
         JsonNode catalog = manifest();
         assertEquals(1, catalog.required("schemaVersion").asInt());
-        Set variants = Set.of("java", "kotlin", "scala-direct", "scala-cats");
+        var variants = Set.of("java", "kotlin", "scala-direct", "scala-cats");
+        var listings = Set.of("ListSessions", "ListWindows", "ListPanes");
+        var reads = Set.of("Connect", "Query", "Capture");
+        var creates = Set.of("NewSession", "NewWindow");
         Set expected = new HashSet<>();
         for (String variant : variants) {
-            for (String program : Set.of(
-                    "Connect",
-                    "ListSessions",
-                    "ListWindows",
-                    "ListPanes",
-                    "NewSession",
-                    "NewWindow",
-                    "Query",
-                    "Capture")) {
-                expected.add(variant + "-" + program);
+            for (var group : List.of(listings, reads, creates)) {
+                group.forEach(program -> expected.add(variant + "-" + program));
             }
         }
         Set actual = new HashSet<>();
         Set paths = new HashSet<>();
         for (JsonNode program : programs().toList()) {
-            assertTrue(actual.add(program.required("id").asText()), "duplicate example id");
-            String path = program.required("sourceFile").asText();
-            assertTrue(path.startsWith("examples/src/main/"), path);
-            assertFalse(path.contains(".."), path);
-            assertTrue(paths.add(path), "a whole program must have its own source file");
-            assertTrue(Files.isRegularFile(ROOT.resolve(path)), path);
-            Class.forName(program.required("mainClass").asText()).getMethod("main", String[].class);
+            String id = program.required("id").asText();
+            assertTrue(actual.add(id), "duplicate example id");
+            String source = program.required("sourceFile").asText();
+            assertTrue(source.startsWith("examples/src/main/"), source);
+            assertFalse(source.contains(".."), source);
+            assertTrue(paths.add(source), "one source file per whole program");
+            assertTrue(Files.isRegularFile(ROOT.resolve(source)), source);
+            String main = program.required("mainClass").asText();
+            Class.forName(main).getMethod("main", String[].class);
             assertFalse(program.required("description").asText().isBlank());
             assertFalse(program.required("expectedOutput").asText().isBlank());
             assertFalse(program.required("targets").isEmpty());
-            assertTrue(program.required("path").asText().startsWith("src/main/"));
+            String path = program.required("path").asText();
+            assertTrue(path.startsWith("src/main/"));
         }
         assertEquals(expected, actual);
-        Set sources = new HashSet<>();
-        for (String language : Set.of("java", "kotlin", "scala")) {
-            Path sourceRoot = Path.of("src/main", language, "io/github/libtmux/examples/api");
-            try (Stream files = Files.walk(sourceRoot)) {
-                files.filter(Files::isRegularFile).forEach(path -> sources.add("examples/" + path));
-            }
-        }
-        assertEquals(sources, paths, "every program must be attached and executed");
-        Set describedVariants = new HashSet<>();
-        catalog.required("variants").fieldNames().forEachRemaining(describedVariants::add);
-        assertEquals(variants, describedVariants);
-        for (JsonNode variant : catalog.required("variants")) {
+        assertEquals(sourceFiles(), paths, "every program must be attached");
+        JsonNode described = catalog.required("variants");
+        Set names = new HashSet<>();
+        described.fieldNames().forEachRemaining(names::add);
+        assertEquals(variants, names);
+        for (JsonNode variant : described) {
             Set destinations = new HashSet<>();
             for (JsonNode file : variant.required("files")) {
-                assertTrue(Files.isRegularFile(
-                        ROOT.resolve(file.required("sourceFile").asText())));
-                assertTrue(destinations.add(file.required("path").asText()), "duplicate setup file");
+                String source = file.required("sourceFile").asText();
+                assertTrue(Files.isRegularFile(ROOT.resolve(source)));
+                String path = file.required("path").asText();
+                assertTrue(destinations.add(path), "duplicate setup file");
+            }
+            assertEquals(setupFiles(), destinations);
+        }
+    }
+
+    private static Set setupFiles() {
+        Set files = new HashSet<>();
+        files.addAll(List.of("settings.gradle.kts", "build.gradle.kts"));
+        files.addAll(List.of("gradle.properties", "run.sh"));
+        return files;
+    }
+
+    private static Set sourceFiles() throws IOException {
+        Set sources = new HashSet<>();
+        for (String language : Set.of("java", "kotlin", "scala")) {
+            Path root = Path.of("src/main", language, API_PACKAGE);
+            try (Stream files = Files.walk(root)) {
+                List found = files.filter(Files::isRegularFile).toList();
+                for (Path file : found) {
+                    sources.add("examples/" + file);
+                }
             }
-            assertEquals(
-                    Set.of("settings.gradle.kts", "build.gradle.kts", "gradle.properties", "run.sh"), destinations);
         }
+        return sources;
     }
 
     @ParameterizedTest(name = "{index}: {0}")
     @MethodSource("cases")
     void mainPrintsDocumentedOutput(JsonNode program) throws Exception {
-        String output = MainsTest.launchApiProgram(program.required("mainClass").asText());
+        String main = program.required("mainClass").asText();
+        String output = MainsTest.launchApiProgram(main);
         assertEquals(program.required("expectedOutput").asText(), output);
     }
 }
diff --git a/examples/src/test/java/io/github/libtmux/examples/MainsTest.java b/examples/src/test/java/io/github/libtmux/examples/MainsTest.java
index 2fee3e139..ff69734db 100644
--- a/examples/src/test/java/io/github/libtmux/examples/MainsTest.java
+++ b/examples/src/test/java/io/github/libtmux/examples/MainsTest.java
@@ -11,6 +11,7 @@
 import io.github.libtmux.junit5.TmuxExtension;
 import io.github.libtmux.junit5.TmuxSocketPath;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -22,23 +23,29 @@
 import java.util.stream.Stream;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.function.Executable;
 
 /**
  * Every example's own {@code main}, in its own JVM, as a reader would run it.
  *
- * 

{@link ExamplesRunTest} checks what each {@code run} returns. This checks what a reader sees: - * the program starts from its class name, exits zero, and prints what it says it prints. + *

{@link ExamplesRunTest} checks what each {@code run} returns. This checks + * what a reader sees: the program starts from its class name, exits zero, and + * prints what it says it prints. */ @ExtendWith(TmuxExtension.class) final class MainsTest { + private static final String PACKAGE = "io.github.libtmux.examples"; + private static final String PACKAGE_DIR = PACKAGE.replace('.', '/'); + private static final String HANGS = "MainsTest$Hangs"; + private static final String FIXTURE_DIR = "/tmp/libtmux-java-dev/api."; @Test void everyExampleIsLaunched() throws IOException { - List programs = Stream.concat( - names(Path.of("src/main/java/io/github/libtmux/examples")).stream(), - names(Path.of("src/main/kotlin/io/github/libtmux/examples")).stream()) - .sorted() - .toList(); + List programs = new ArrayList<>(); + for (String language : List.of("java", "kotlin")) { + programs.addAll(names(Path.of("src/main", language, PACKAGE_DIR))); + } + programs.sort(null); assertEquals( List.of( @@ -54,31 +61,34 @@ void everyExampleIsLaunched() throws IOException { } @Test - void buildAWorkspace(Server server, TmuxSocketPath socket) throws Exception { - String out = launch("BuildAWorkspace", socket.path().toString()); + void buildAWorkspace(Server tmux, TmuxSocketPath socket) throws Exception { + String path = socket.path().toString(); + String out = launch("BuildAWorkspace", path); assertTrue(out.startsWith("session work has "), out); - assertTrue(server.hasSession("work")); + assertTrue(tmux.hasSession("work")); } @Test void findPanesRunning(TmuxSocketPath socket) throws Exception { - String out = launch("FindPanesRunning", socket.path().toString(), "no-such-command-anywhere"); + String path = socket.path().toString(); + String out = launch("FindPanesRunning", path, "no-such-command"); assertEquals("", out); } @Test void runACommand(TmuxSocketPath socket) throws Exception { - String out = launch("RunACommand", socket.path().toString(), "printf 'built\\n'; exit 3"); + String path = socket.path().toString(); + String out = launch("RunACommand", path, "printf 'built\\n'; exit 3"); assertEquals("exit 3, 1 line(s): built\n", out); } @Test void serveTmuxOverMcp(TmuxSocketPath socket) throws Exception { - List tools = - launch("ServeTmuxOverMcp", socket.path().toString()).lines().toList(); + String out = launch("ServeTmuxOverMcp", socket.path().toString()); + List tools = out.lines().toList(); assertTrue(tools.contains("capture_pane"), tools.toString()); } @@ -107,7 +117,8 @@ void watchWithFlow(TmuxSocketPath socket) throws Exception { private static List names(Path sources) throws IOException { try (Stream files = Files.list(sources)) { return files.filter(Files::isRegularFile) - .map(file -> file.getFileName().toString().replaceFirst("\\.(java|kt)$", "")) + .map(file -> file.getFileName().toString()) + .map(name -> name.replaceFirst("\\.(java|kt)$", "")) .filter(name -> !name.equals("package-info")) .toList(); } @@ -115,27 +126,40 @@ private static List names(Path sources) throws IOException { @Test void aProgramStillRunningAtItsDeadlineFails() { - AssertionError failure = assertThrows( - AssertionError.class, - () -> assertTimeoutPreemptively( - Duration.ofSeconds(5), () -> launch(Duration.ofMillis(200), "MainsTest$Hangs"))); + Duration deadline = Duration.ofMillis(200); + String message = failureOf(() -> hangsWithin(deadline)); - assertTrue(String.valueOf(failure.getMessage()).contains("did not exit"), failure.getMessage()); + assertTrue(message.contains("did not exit"), message); } @Test void aTimedOutApiProgramStillCleansUpItsServer() { - AssertionError failure = assertThrows( - AssertionError.class, - () -> assertTimeoutPreemptively( - Duration.ofSeconds(10), () -> launchApiProgram(Duration.ofSeconds(1), "MainsTest$Hangs"))); - String output = String.valueOf(failure.getMessage()); - assertTrue(output.contains("did not exit"), output); - String socket = output.lines() - .filter(line -> line.startsWith("/tmp/libtmux-java-dev/api.") && line.endsWith("/tmux.sock")) - .findFirst() - .orElseThrow(); - assertFalse(Files.exists(Path.of(socket).getParent()), "the timed-out fixture must be removed"); + Duration deadline = Duration.ofSeconds(1); + String message = failureOf(() -> hangsInFixture(deadline)); + + assertTrue(message.contains("did not exit"), message); + var lines = message.lines().filter(MainsTest::isFixtureSocket); + Path socket = Path.of(lines.findFirst().orElseThrow()); + assertFalse(Files.exists(socket.getParent()), "fixture removed"); + } + + private static String failureOf(Executable run) { + AssertionError failure = assertThrows(AssertionError.class, run); + return String.valueOf(failure.getMessage()); + } + + private static boolean isFixtureSocket(String line) { + return line.startsWith(FIXTURE_DIR) && line.endsWith("/tmux.sock"); + } + + private static void hangsWithin(Duration deadline) { + Duration limit = Duration.ofSeconds(5); + assertTimeoutPreemptively(limit, () -> run(deadline, plain(HANGS))); + } + + private static void hangsInFixture(Duration deadline) { + Duration limit = Duration.ofSeconds(10); + assertTimeoutPreemptively(limit, () -> api(deadline, HANGS)); } /** Never exits, and never closes its output. */ @@ -146,61 +170,81 @@ public static void main(String[] args) throws InterruptedException { } } - private static String launch(String program, String... args) throws Exception { - return launch(Duration.ofSeconds(60), program, args); + /** + * One JVM start. {@code wrap} is the fixture that runs it, or empty to run + * it directly with its error stream merged into its output. + */ + private record Launch(List wrap, String name, List args) { + List command() { + List command = new ArrayList<>(wrap); + Path java = Path.of(System.getProperty("java.home"), "bin", "java"); + String classpath = System.getProperty("java.class.path"); + command.addAll(List.of(java.toString(), "-cp", classpath)); + command.add(qualified()); + command.addAll(args); + return command; + } + + String qualified() { + return name.contains(".") ? name : PACKAGE + "." + name; + } + } + + private static String launch(String name, String... args) throws Exception { + return run(Duration.ofSeconds(60), plain(name, args)); } - static String launchApiProgram(String program) throws Exception { - return launchApiProgram(Duration.ofSeconds(60), program); + static String launchApiProgram(String name) throws Exception { + return api(Duration.ofSeconds(60), name); } - private static String launchApiProgram(Duration deadline, String program) throws Exception { - List fixture = - List.of("env", "TMUX_BIN=" + System.getProperty("libtmux.tmux", "tmux"), "sh", "api/run.sh"); - return launch(deadline, false, fixture, program); + private static String api(Duration limit, String name) throws Exception { + String tmux = System.getProperty("libtmux.tmux", "tmux"); + String bin = "TMUX_BIN=" + tmux; + List wrap = List.of("env", bin, "sh", "api/run.sh"); + return run(limit, new Launch(wrap, name, List.of())); } - /** Runs the example's {@code main} in a fresh JVM and returns what it printed. */ - private static String launch(Duration deadline, String program, String... args) throws Exception { - return launch(deadline, true, List.of(), program, args); + /** The example's {@code main} in a fresh JVM, with no fixture around it. */ + private static Launch plain(String name, String... args) { + return new Launch(List.of(), name, List.of(args)); } - private static String launch( - Duration deadline, boolean combineError, List prefix, String program, String... args) - throws Exception { - List command = new ArrayList<>(prefix); - command.addAll(List.of( - Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-cp", - System.getProperty("java.class.path"), - program.startsWith("io.github.") ? program : "io.github.libtmux.examples." + program)); - command.addAll(List.of(args)); - ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(combineError); - if (!combineError) builder.redirectError(ProcessBuilder.Redirect.INHERIT); + private static String run(Duration limit, Launch launch) throws Exception { + boolean direct = launch.wrap().isEmpty(); + var builder = new ProcessBuilder(launch.command()); + builder.redirectErrorStream(direct); + if (!direct) builder.redirectError(ProcessBuilder.Redirect.INHERIT); Process process = builder.start(); process.getOutputStream().close(); - // Drained apart from the wait: a program that never exits never closes its output either. - FutureTask printed = - new FutureTask<>(() -> process.getInputStream().readAllBytes()); + // Drained apart from the wait: a program that never exits never + // closes its output either. + InputStream stdout = process.getInputStream(); + FutureTask printed = new FutureTask<>(stdout::readAllBytes); Thread.ofVirtual().start(printed); - if (!process.waitFor(deadline.toMillis(), TimeUnit.MILLISECONDS)) { - process.descendants().forEach(ProcessHandle::destroy); - if (prefix.isEmpty()) process.destroy(); - if (!process.waitFor(5, TimeUnit.SECONDS)) { - process.destroy(); - if (!process.waitFor(1, TimeUnit.SECONDS)) { - process.descendants().forEach(ProcessHandle::destroyForcibly); - process.destroyForcibly().waitFor(); - } - } - fail(program + " did not exit within " + deadline + ", and printed:\n" + text(printed)); + String program = launch.name(); + if (!process.waitFor(limit.toMillis(), TimeUnit.MILLISECONDS)) { + stop(process, direct); + String why = program + " did not exit within " + limit + ":\n"; + fail(why + text(printed)); } String out = text(printed); assertEquals(0, process.exitValue(), program + " printed:\n" + out); return out; } + private static void stop(Process process, boolean direct) throws Exception { + process.descendants().forEach(ProcessHandle::destroy); + if (direct) process.destroy(); + if (process.waitFor(5, TimeUnit.SECONDS)) return; + process.destroy(); + if (process.waitFor(1, TimeUnit.SECONDS)) return; + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly().waitFor(); + } + private static String text(FutureTask printed) throws Exception { - return new String(printed.get(10, TimeUnit.SECONDS), StandardCharsets.UTF_8); + byte[] bytes = printed.get(10, TimeUnit.SECONDS); + return new String(bytes, StandardCharsets.UTF_8); } } From 097cc6b0c347d0de2845f902561c50a7450541ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 11:59:07 -0500 Subject: [PATCH 55/59] Examples(style[ExamplesRunTest]): Shorten checks why: The suite that runs every example held 27 lines over 80 columns. palantir-java-format joins a wrapped call back onto one line whenever it fits 120, so the fix is shorter names. what: - Wrap the class comment and two inline comments at 80 columns - Move the pane command prefix test and the exit status call into helpers that turn InterruptedException into an unchecked failure, so test signatures fit one line - Name the socket path, deadline and failure message in locals - Rename three tests to shorter names; assertions are unchanged --- .../libtmux/examples/ExamplesRunTest.java | 118 +++++++++++------- 1 file changed, 76 insertions(+), 42 deletions(-) diff --git a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java index a43d1c0a9..89af6b3b6 100644 --- a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java +++ b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java @@ -6,10 +6,9 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; -import io.github.libtmux.control.ControlEvent; -import io.github.libtmux.control.PaneOutput; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; +import java.nio.file.Path; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.Test; @@ -18,23 +17,24 @@ /** * Every example, run against a real tmux. * - *

Examples rot silently. They are the part of a project nobody compiles and everybody reads - * first, so an API change breaks them without breaking anything that would say so. These call the - * same method {@code main} calls, which is why each example has one. + *

Examples rot silently. They are the part of a project nobody compiles and + * everybody reads first, so an API change breaks them without breaking anything + * that would say so. These call the same method {@code main} calls, which is + * why each example has one. */ @ExtendWith(TmuxExtension.class) final class ExamplesRunTest { @Test - void buildingAWorkspaceLeavesOneBehind(Server server, TmuxSocketPath socket) { + void aWorkspaceIsLeftRunning(Server server, TmuxSocketPath socket) { String reported = BuildAWorkspace.run(socket.path()); assertTrue(reported.startsWith("session work has "), reported); - assertTrue(server.hasSession("work"), "the example is supposed to leave a session running"); + assertTrue(server.hasSession("work"), "the example leaves a session"); } @Test - void buildingAWorkspaceStartsADaemonWhenAbsent(Server server, TmuxSocketPath socket) { + void aWorkspaceStartsAMissingDaemon(Server server, TmuxSocketPath socket) { server.killServer(); try { String reported = BuildAWorkspace.run(socket.path()); @@ -47,25 +47,28 @@ void buildingAWorkspaceStartsADaemonWhenAbsent(Server server, TmuxSocketPath soc } @Test - void findingPanesSelectsOnWhatIsRunning(Server server, TmuxSocketPath socket) throws InterruptedException { - // The fixture's pane runs a shell, so the shell's own name is the one thing certain to match — - // once it has settled. While the shell starts, tmux reports whatever its startup files are - // running, locale for one, and a name read then matches nothing a moment later. + void findingPanesSelectsOnCommand(Server server, TmuxSocketPath socket) { + // The fixture's pane runs a shell, so the shell's own name is the one + // thing certain to match, once it has settled. While the shell starts, + // tmux reports whatever its startup files are running, locale for one, + // and a name read then matches nothing a moment later. Pane shell = server.panes().get(0); String running = settle(shell); + Path path = socket.path(); - List found = FindPanesRunning.run(socket.path(), running); + List found = FindPanesRunning.run(path, running); assertFalse(found.isEmpty(), "nothing matched '" + running + "'"); - assertTrue(found.stream().allMatch(pane -> pane.currentCommand().startsWith(running))); - assertEquals(List.of(), FindPanesRunning.run(socket.path(), "no-such-command-anywhere")); + assertTrue(found.stream().allMatch(pane -> startsWith(pane, running))); + assertEquals(List.of(), FindPanesRunning.run(path, "no-such-command")); } @Test - void runningACommandReportsTheStatusItExitedWith(Server server, TmuxSocketPath socket) throws InterruptedException { + void aCommandReportsItsExitStatus(Server server, TmuxSocketPath socket) { settle(server.panes().get(0)); + String command = "printf 'built\\n'; exit 3"; - String reported = RunACommand.run(socket.path(), "printf 'built\\n'; exit 3"); + String reported = report(socket.path(), command); assertEquals("exit 3, 1 line(s): built", reported); } @@ -73,31 +76,59 @@ void runningACommandReportsTheStatusItExitedWith(Server server, TmuxSocketPath s /** Bash in CI switched bracketed paste off in front of the printed line. */ @Test void aPrintedLineIsFoundBehindAModeSwitchButNotInTheTypedCommand() { - assertTrue(WatchPaneOutput.printedLine("$ echo watched\r\n\u001B[?2004l\rwatched\r\n", "watched")); - assertFalse(WatchPaneOutput.printedLine("$ echo watched\r\n", "watched")); + String typed = "$ echo watched\r\n"; + String modeSwitch = "\u001B[?2004l\r"; + + String output = typed + modeSwitch + "watched\r\n"; + + assertTrue(WatchPaneOutput.printedLine(output, "watched")); + assertFalse(WatchPaneOutput.printedLine(typed, "watched")); } @Test void watchingAPaneSeesWhatItPrints(TmuxSocketPath socket) { - List seen = WatchPaneOutput.run(socket.path(), Duration.ofSeconds(30), output -> {}); + Duration deadline = Duration.ofSeconds(30); + Path path = socket.path(); + var seen = WatchPaneOutput.run(path, deadline, output -> {}); + String why = "attaching makes tmux push output: " + seen; + + assertTrue(WatchPaneOutput.sawTheEcho(seen), why); + } + + private static String report(Path path, String command) { + try { + return RunACommand.run(path, command); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } - assertTrue(WatchPaneOutput.sawTheEcho(seen), "attaching is what makes tmux push output: " + seen); + private static boolean startsWith(Pane pane, String running) { + return pane.currentCommand().startsWith(running); } /** - * The name of the command a pane is running, once it stops changing. A shell that is still - * starting reports whatever its startup files run — locale for one — and a name read then - * matches nothing a moment later, and is not a shell {@code Pane.run} will type at. + * The name of the command a pane is running, once it stops changing. A + * shell that is still starting reports whatever its startup files run, + * locale for one, and a name read then matches nothing a moment later, and + * is not a shell {@code Pane.run} will type at. */ - private static String settle(Pane shell) throws InterruptedException { + private static String settle(Pane shell) { String[] previous = {shell.currentCommand()}; - shell.await( - fresh -> { - boolean settled = fresh.currentCommand().equals(previous[0]); - previous[0] = fresh.currentCommand(); - return settled; - }, - Duration.ofSeconds(10)); + try { + shell.await( + fresh -> { + String now = fresh.currentCommand(); + boolean same = now.equals(previous[0]); + previous[0] = now; + return same; + }, + Duration.ofSeconds(10)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } return previous[0]; } @@ -105,20 +136,23 @@ private static String settle(Pane shell) throws InterruptedException { void servingOverMcpReportsTheFixedToolSurface(TmuxSocketPath socket) { List tools = ServeTmuxOverMcp.run(socket.path()); - // The catalog holds 45. Teardown is enabled by default only for a daemon this process - // created, and the fixture's server already exists, so its four tools are not offered. - assertEquals(41, tools.size(), tools.toString()); - assertTrue(tools.contains("capture_pane"), tools.toString()); - assertFalse(tools.contains("kill_session"), "teardown reached a server the example did not start: " + tools); - assertEquals(tools.stream().sorted().toList(), tools, "the example reports a stable order"); + // The catalog holds 45. Teardown is enabled by default only for a + // daemon this process created, and the fixture's server already + // exists, so its four tools are not offered. + String listed = tools.toString(); + assertEquals(41, tools.size(), listed); + assertTrue(tools.contains("capture_pane"), listed); + assertFalse(tools.contains("kill_session"), listed); + assertEquals(tools.stream().sorted().toList(), tools, "a stable order"); } @Test void watchingAServerIsToldWhenAWindowAppears(TmuxSocketPath socket) { - List seen = WatchWhatChanges.run(socket.path(), Duration.ofSeconds(30), event -> {}); + Duration deadline = Duration.ofSeconds(30); + Path path = socket.path(); + var seen = WatchWhatChanges.run(path, deadline, event -> {}); + String why = "tmux reports the difference: " + seen; - assertTrue( - WatchWhatChanges.sawTheNewWindow(seen), - "tmux compares a watched format itself and reports the difference: " + seen); + assertTrue(WatchWhatChanges.sawTheNewWindow(seen), why); } } From e9dd5123653b9ab941326d7121767537542aa58f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:02 -0500 Subject: [PATCH 56/59] Docs(docs[WRITING]): Add the Examples section why: Example code had no written rule, so each author chose a width and a break style, and a width check would have had no policy to cite. The section is the shared text, plus how this repository meets it: Spotless runs palantir-java-format for Java and scalafmt for Scala, Kotlin has no formatter, and Spotless leaves Javadoc alone. what: - Add ## Examples after ## Code blocks with Width, Reaching 80 and What never breaks, byte-identical to the shared section in libtmux/docs#101 - Add ### In this repository in the shared four-bullet shape: the hard limit and its config, the surfaces no formatter touches, the throws, does-not-compile, compile-only and skip directives, and the compared output with the test commands - Point at the width config and formatter keys, not their values, so the text cannot drift from them - Show a Bad and Good pair from RunACommand.java: the 90 column socket default against the same line with the default held in a constant --- .github/WRITING.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/.github/WRITING.md b/.github/WRITING.md index 2652b0b9f..9c8fb71ba 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -429,3 +429,92 @@ Bad: # Show the last ten commits as a graph $ git log --max-count=10 --graph --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:** palantir-java-format through Spotless, configured in + `build-logic/conventions/src/main/kotlin/libtmux.java-library.gradle.kts`, + which takes no width key, so Java keeps palantir's fixed 120 columns; scalafmt + reads `maxColumn` in `.scalafmt.conf`, set to the example width. Kotlin has + no formatter. The width check is `python3 tools/check_example_width.py`. +- **Not formatted:** Javadoc `

{@code}` blocks, Kotlin sources, every
+  Markdown fence, the Gradle scripts under `examples/`, and `gradle.properties`
+  and `run.sh` under `examples/api/`; held to the example width by hand. Java
+  examples reach it through named variables and extracted helpers, because
+  palantir rejoins any line that fits its own width.
+- **Runs, compiles, exempt:** a fence in `docs/` or a README compiles and
+  runs against live tmux unless a `` directive on the
+  line above it says otherwise: `throws` (must fail with that exception),
+  `does-not-compile` (the compiler must reject it), `compile-only` or
+  `skip`, each with its reason, as [docs/README.md](../docs/README.md)
+  lists. `// →` states a value, and `// Given:` binds a fixture.
+- **Compared output and copied blocks:** `// →` lines and the text they
+  compare stay on one line. No block is copied from a source file, so there
+  is no sync command; `./gradlew :docs:test` runs the fences and
+  `./gradlew :examples:test` runs the programs.
+
+Bad, over 80:
+
+```java
+        Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s");
+```
+
+Good, hold the default in a constant:
+
+```java
+        Path socket = Path.of(args.length > 0 ? args[0] : DEMO);
+```

From aa8ffcc1d6a8e8b02319ae1f5901e6123ddf4c01 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:52:02 -0500
Subject: [PATCH 57/59] ai(rules[AGENTS]): Route code examples to WRITING

why: An agent changing an example program, Javadoc code or a Markdown
fence would not open WRITING.md from the existing prose-only trigger,
and so would miss the width rule.

what:
- Extend the WRITING.md routing line with any code example (example
  programs, Javadoc code, Markdown code blocks) and link the Examples
  section
---
 AGENTS.md | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index fe3b01b87..64e61fbb4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,8 +26,10 @@ This file routes; it does not restate. Read the policy that governs the change
 being made:
 
 - Documentation and user-facing prose — `README.md`, `CHANGELOG.md`, release
-  notes, commit messages, CLI and help text, API documentation, and source
-  comments: [`.github/WRITING.md`](.github/WRITING.md)
+  notes, commit messages, CLI and help text, API documentation, source comments,
+  and any code example (example programs, Javadoc code, Markdown code blocks):
+  [`.github/WRITING.md`](.github/WRITING.md), and its
+  [Examples](.github/WRITING.md#examples) section for code a reader sees
 - Contribution workflow, building, testing, and pull requests:
   [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md)
 - Security-sensitive changes: [`SECURITY.md`](SECURITY.md)

From 8d2e3bbf878c5d577790f92c30cb5a71be5a02d6 Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Sun, 4 Oct 2026 08:52:01 -0500
Subject: [PATCH 58/59] Examples(chore[width]): Add the width check

why: Java examples have no enforced width. Spotless 8.9.0 runs
palantir-java-format 2.97.0, a 120 column formatter whose Spotless
config takes only a style and formatJavadoc, so it cannot be told to
wrap at 80; Spotless leaves Javadoc alone because DEFAULT_FORMAT_JAVADOC
is false; Kotlin has no formatter; Markdown fences are never formatted.
libtmux.org renders code in a box that scrolls sideways, so 80 columns
is the target a reader sees. Run against origin/master, this checker and
config report 276 lines over 80 in 66 files, across Markdown pages,
example programs, example tests and build files, and Javadoc; the
example commits fix all of them. 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.

Sources:
120 column formatter:
https://github.com/palantir/palantir-java-format/blob/2.97.0/README.md
PalantirJavaFormatConfig: style, formatJavadoc:
https://github.com/diffplug/spotless/blob/gradle/8.9.0/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java#L284-L308
DEFAULT_FORMAT_JAVADOC = false:
https://github.com/diffplug/spotless/blob/gradle/8.9.0/lib/src/main/java/com/diffplug/spotless/java/PalantirJavaFormatStep.java#L33

Rejected: a formatter width, because palantir has none and Kotlin has no
formatter; Checkstyle LineLength, because it cannot read Markdown fences
or Javadoc blocks; and a Gradle task, because the checker is one script
shared across ports.

what:
- Add tools/check_example_width.py, the shared checker, with a
  --self-test that proves it can fail
- .github/example-width.toml checks every Markdown file (**/*.md), every
  Java, Kotlin and Scala file under examples/src (programs and their
  tests), the Gradle build files, gradle.properties and run.sh under
  examples/, and 
{@code} blocks in library sources
- Exclude only examples/api/manifest.json, a machine-read index of
  example paths and expected output that ApiProgramsTest checks against
  the sources; its paths cannot be wrapped
- List one [[allow]] entry, the wide Bad example in WRITING.md, with
  allow_ceiling set to the entry count
- Record checker_digest, which must equal --digest, so an edited
  checker fails until every port changes together
- Record shared_digest, the --digest of WRITING.md's shared section, so
  a local edit to it fails the check
- Run the self-test and the check in CI before the Gradle check; python3
  is on the runner
- Document the command beside the other gates in CONTRIBUTING.md
---
 .github/CONTRIBUTING.md      |    8 +
 .github/example-width.toml   |   23 +
 .github/workflows/ci.yml     |    5 +
 tools/check_example_width.py | 3066 ++++++++++++++++++++++++++++++++++
 4 files changed, 3102 insertions(+)
 create mode 100644 .github/example-width.toml
 create mode 100644 tools/check_example_width.py

diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 750ddebfd..5700c497d 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -138,6 +138,14 @@ How a fence says what it is — the directives, the `// →` assertions, the
 fixtures a snippet may assume — is in [`docs/README.md`](../docs/README.md#how-these-pages-are-tested), beside
 the code that reads them.
 
+Examples, whether programs, Javadoc blocks, or Markdown fences, stay within 80
+columns. Unlike the Gradle gates this one is a script, and it runs in CI before
+them:
+
+```console
+$ python3 tools/check_example_width.py
+```
+
 A green `check` that reported `UP-TO-DATE` for every task verified nothing.
 Force it when that matters:
 
diff --git a/.github/example-width.toml b/.github/example-width.toml
new file mode 100644
index 000000000..fea086b51
--- /dev/null
+++ b/.github/example-width.toml
@@ -0,0 +1,23 @@
+width = 80
+shared_digest = "28b74bae1f7a06731fea5a150cbde4430179b7788b7564ea1644c3951df8531b"
+checker_digest = "eb87afceb3fc8f11ef5a478cd38f901ef3950e89ff94fbe0ca281c0cfc1a0e2e"
+markdown = ["**/*.md"]
+sources = [
+  "examples/src/**/*.java",
+  "examples/src/**/*.kt",
+  "examples/src/**/*.scala",
+  "examples/**/*.gradle.kts",
+  "examples/api/gradle.properties",
+  "examples/api/run.sh",
+]
+doc_comments = ["*/src/main/**/*.java", "*/src/main/**/*.kt", "*/src/main/**/*.scala"]
+allow_ceiling = 1
+
+[[allow]]
+path = ".github/WRITING.md"
+line = "        Path socket = Path.of(args.length > 0 ? args[0] : \"/tmp/libtmux-java-dev/demo/s\");"
+reason = "the Bad example in the port block; it shows the width problem the Good example fixes"
+
+[[exclude]]
+glob = "examples/api/manifest.json"
+reason = "machine-read index of example paths and expected output, which ApiProgramsTest checks against the sources; its long paths cannot be wrapped"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 25314c75f..d118ce16a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -81,6 +81,11 @@ jobs:
           "$RUNNER_TEMP/workspace-python/bin/python" -m pip install 'tmuxp==1.74.0'
           echo "TMUX_WORKSPACE_TEST_PYTHON=$RUNNER_TEMP/workspace-python/bin/python" >> "$GITHUB_ENV"
 
+      - name: Examples stay within 80 columns
+        run: |
+          python3 tools/check_example_width.py --self-test
+          python3 tools/check_example_width.py
+
       # Runs every check task instead of stopping at the first failure, so one run names them all.
       - run: ./gradlew :libtmux-mcp:test check --stacktrace --continue -Plibtmux.testJdk=${{ matrix.java }}
 
diff --git a/tools/check_example_width.py b/tools/check_example_width.py
new file mode 100644
index 000000000..b29949890
--- /dev/null
+++ b/tools/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()) From 34df9b0065860b6a974f106d1621dfb4aaf82341 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:52:01 -0500 Subject: [PATCH 59/59] Docs(test[javadoc]): Run
{@code} blocks

why: A Javadoc 
{@code} block is code a reader copies, but nothing
compiled it. Spotless leaves Javadoc alone (DEFAULT_FORMAT_JAVADOC =
false) and a snippet that stops compiling stays published until a reader
reports it. The docs module already compiles and runs every Markdown
fence through DocumentationSnippetsTest, so the library sources join
that suite instead of a second one. Planting pane.runn( in the
Pane.await Javadoc made :docs:test fail at Pane.java:474 with a compile
error, and the unplanted tree passes. Dropping one block from the
extractor's result made the count test fail, and restoring it made it
pass.

Rejected:
- JEP 413 `{@snippet}`: javadoc compiles none, and an external snippet,
  found through --snippet-path, is a separate source file, so every
  example would leave its Javadoc
- Twin example files: a copy drifts from the Javadoc it mirrors
- A new Gradle sourceSet: the docs module already owns the snippet
  harness and its tmux fixture

Sources: DEFAULT_FORMAT_JAVADOC = false:
https://github.com/diffplug/spotless/blob/gradle/8.9.0/lib/src/main/java/com/diffplug/spotless/java/PalantirJavaFormatStep.java#L33
JEP 413 {@snippet} (includes snippets; does not compile them):
https://openjdk.org/jeps/413

what:
- Documentation.javaSources and snippetsInJavadoc extract each
  
{@code ...}
block from */src/main/java, reuse Snippet, SnippetCompiler and the // Given: fixtures, and feed DocumentationSnippetsTest - A guard test counts every
{@code opening against the blocks
  taken, so a block with a malformed close fails instead of going
  unchecked
- The same test sums the openings across all sources, requires the sum
  to be above zero so a broken source scan fails, and requires
  javadocSnippets() to hold exactly that many entries, so no floor
  goes stale
- Directives reuse the Markdown syntax on the line above 
:
   or compile-only
- A snippet the library refuses with UnsupportedFeatureException on the
  lane's tmux is skipped there with that reason and runs on the lanes
  whose tmux has the feature: on tmux 3.2a the Server and SessionSpec
  examples (a detached session's size needs tmux 3.3) are skipped, and
  on tmux 3.7c none are
- Add // Given: lines and directives to the existing Javadoc blocks, and
  skip the shell command lines in the example program headers
- Say in WRITING.md's port block that Javadoc blocks take the same
  markers above 
---
 .github/WRITING.md                            |  3 +-
 .../io/github/libtmux/docs/Documentation.java | 42 +++++++++++++++++++
 .../docs/DocumentationSnippetsTest.java       | 38 ++++++++++++++++-
 .../libtmux/examples/BuildAWorkspace.java     |  1 +
 .../libtmux/examples/FindPanesRunning.java    |  1 +
 .../github/libtmux/examples/RunACommand.java  |  1 +
 .../libtmux/examples/ServeTmuxOverMcp.java    |  1 +
 .../libtmux/examples/WatchPaneOutput.java     |  1 +
 .../libtmux/examples/WatchWhatChanges.java    |  1 +
 .../main/java/io/github/libtmux/mcp/Main.java |  1 +
 .../java/io/github/libtmux/CaptureSpec.java   |  1 +
 .../src/main/java/io/github/libtmux/Keys.java |  1 +
 .../java/io/github/libtmux/OptionKey.java     |  1 +
 .../src/main/java/io/github/libtmux/Pane.java |  6 +++
 .../main/java/io/github/libtmux/Server.java   |  3 ++
 .../main/java/io/github/libtmux/Session.java  |  1 +
 .../java/io/github/libtmux/SessionSpec.java   |  1 +
 .../java/io/github/libtmux/SplitSpec.java     |  2 +
 .../io/github/libtmux/TmuxEnvironment.java    |  1 +
 .../java/io/github/libtmux/WindowLayout.java  |  1 +
 .../java/io/github/libtmux/WindowSpec.java    |  1 +
 .../libtmux/control/EventSubscription.java    |  2 +
 .../github/libtmux/control/Notification.java  |  1 +
 23 files changed, 109 insertions(+), 3 deletions(-)

diff --git a/.github/WRITING.md b/.github/WRITING.md
index 9c8fb71ba..a5e0f0468 100644
--- a/.github/WRITING.md
+++ b/.github/WRITING.md
@@ -501,7 +501,8 @@ Change it in all of them together.
   line above it says otherwise: `throws` (must fail with that exception),
   `does-not-compile` (the compiler must reject it), `compile-only` or
   `skip`, each with its reason, as [docs/README.md](../docs/README.md)
-  lists. `// →` states a value, and `// Given:` binds a fixture.
+  lists. Javadoc blocks use the same markers above `
`. `// →` states a
+  value, and `// Given:` binds a fixture.
 - **Compared output and copied blocks:** `// →` lines and the text they
   compare stay on one line. No block is copied from a source file, so there
   is no sync command; `./gradlew :docs:test` runs the fences and
diff --git a/docs/src/test/java/io/github/libtmux/docs/Documentation.java b/docs/src/test/java/io/github/libtmux/docs/Documentation.java
index 14b571029..1004e7280 100644
--- a/docs/src/test/java/io/github/libtmux/docs/Documentation.java
+++ b/docs/src/test/java/io/github/libtmux/docs/Documentation.java
@@ -30,8 +30,50 @@ final class Documentation {
                     + "(?:class|record|interface|enum)\\s+\\w",
             Pattern.MULTILINE);
 
+    /** A Javadoc code block in a library source, and the directive comment that may precede it. */
+    private static final Pattern JAVADOC_BLOCK = Pattern.compile(
+            "(?:[ \\t]*\\n[ \\t]*\\*[ \\t]*)?
\\{@code[ \\t]*\\n(.*?)\\n[ \\t]*\\*[ \\t]*\\}
", + Pattern.DOTALL); + private Documentation() {} + /** Every library source whose Javadoc may carry code. */ + static List javaSources(Path root) { + try (Stream walk = Files.walk(root)) { + return walk.filter(file -> file.toString().endsWith(".java")) + .filter(file -> file.toString().contains("/src/main/java/")) + .filter(file -> !file.toString().contains("/build/")) + .sorted() + .toList(); + } catch (IOException e) { + throw new UncheckedIOException("could not walk " + root, e); + } + } + + /** Every Javadoc code block in one source file, comment asterisks removed. */ + static List snippetsInJavadoc(Path root, Path file) { + String text; + try { + text = Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException("could not read " + file, e); + } + List snippets = new ArrayList<>(); + Matcher block = JAVADOC_BLOCK.matcher(text); + while (block.find()) { + String directive = block.group(1); + String code = block.group(2).replaceAll("(?m)^[ \\t]*\\* ?", ""); + snippets.add(new Snippet( + root.relativize(file), + lineOf(text, block.start()), + expectationOf(directive, file), + detailOf(directive), + DECLARES_A_TYPE.matcher(code).find() ? Snippet.Shape.TYPE : Snippet.Shape.STATEMENTS, + code)); + } + return List.copyOf(snippets); + } + /** Every document whose code is meant to work today. */ static List readable(Path root) { List found = new ArrayList<>(); diff --git a/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java b/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java index 3fb1d99ea..c78ac5edf 100644 --- a/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java +++ b/docs/src/test/java/io/github/libtmux/docs/DocumentationSnippetsTest.java @@ -11,6 +11,7 @@ import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.Window; +import io.github.libtmux.exception.UnsupportedFeatureException; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; import java.io.IOException; @@ -23,6 +24,7 @@ import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.extension.ExtendWith; @@ -53,6 +55,33 @@ static List snippets() { return found; } + static List javadocSnippets() { + List found = new ArrayList<>(); + for (Path source : Documentation.javaSources(ROOT)) { + found.addAll(Documentation.snippetsInJavadoc(ROOT, source)); + } + return found; + } + + /** A Javadoc block the extractor skipped would be a snippet nobody checks. */ + @Test + void everyJavadocBlockIsOneTheExtractorTakes() throws IOException { + Pattern opening = Pattern.compile("
\\s*\\{@code");
+        long total = 0;
+        for (Path source : Documentation.javaSources(ROOT)) {
+            long blocks = opening.matcher(Files.readString(source)).results().count();
+            total += blocks;
+            int taken = Documentation.snippetsInJavadoc(ROOT, source).size();
+            assertEquals(
+                    blocks,
+                    taken,
+                    ROOT.relativize(source) + " has " + blocks + " 
{@code blocks, " + taken
+                            + " are checked; close each with a bare ' * }
' line"); + } + assertTrue(total > 0, "no source holds a
{@code block; the scan is broken");
+        assertEquals(total, javadocSnippets().size());
+    }
+
     /**
      * The suite is worthless if it discovers nothing, and a filter or a rename can reduce it to
      * nothing without failing. So the count is asserted before the cases are believed.
@@ -99,7 +128,7 @@ void everyJavaFenceIsOneTheExtractorTakes() throws IOException {
      * about 2 seconds, since a slow runner still has to pass.
      */
     @ParameterizedTest(name = "{0}")
-    @MethodSource("snippets")
+    @MethodSource({"snippets", "javadocSnippets"})
     @Timeout(value = 15, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
     void theSnippetIsWhatItClaimsToBe(Snippet snippet, Server server, TmuxSocketPath socket) throws Throwable {
         if (snippet.expectation() == Snippet.Expectation.SKIPPED) {
@@ -142,7 +171,12 @@ void theSnippetIsWhatItClaimsToBe(Snippet snippet, Server server, TmuxSocketPath
                 return;
             }
             if (snippet.expectation() == Snippet.Expectation.RUNS) {
-                compiler.run(compiled, bindings(server, socket));
+                try {
+                    compiler.run(compiled, bindings(server, socket));
+                } catch (UnsupportedFeatureException refused) {
+                    // The library's own version refusal: the lanes whose tmux has the feature run it.
+                    Assumptions.abort(snippet.where() + " needs a newer tmux: " + refused.getMessage());
+                }
             }
         } finally {
             SnippetCompiler.discard(compiled.classes());
diff --git a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
index 63143860a..fd542ad80 100644
--- a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
+++ b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
@@ -15,6 +15,7 @@
  * Lays out a session the way you would set one up by hand before starting
  * work.
  *
+ * 
  * 
{@code
  * java BuildAWorkspace.java /tmp/libtmux-java-dev/demo/s
  * }
diff --git a/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java b/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java index f3949d1cf..0b67ce209 100644 --- a/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java +++ b/examples/src/main/java/io/github/libtmux/examples/FindPanesRunning.java @@ -12,6 +12,7 @@ * Finds the panes running a given command, without asking tmux more than * once. * + * *
{@code
  * java FindPanesRunning.java /tmp/libtmux-java-dev/demo/s vim
  * }
diff --git a/examples/src/main/java/io/github/libtmux/examples/RunACommand.java b/examples/src/main/java/io/github/libtmux/examples/RunACommand.java index 5bafb4dd6..a5bbd5d1c 100644 --- a/examples/src/main/java/io/github/libtmux/examples/RunACommand.java +++ b/examples/src/main/java/io/github/libtmux/examples/RunACommand.java @@ -12,6 +12,7 @@ * Runs a command in a pane and reads what it exited with, rather than what the * screen shows. * + * *
{@code
  * java RunACommand.java /tmp/libtmux-java-dev/demo/s 'make test'
  * }
diff --git a/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java index 798f0b359..df056f8b1 100644 --- a/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java +++ b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java @@ -18,6 +18,7 @@ * Serves a tmux server over MCP and reports the tool surface an agent would * see. * + * *
{@code
  * java ServeTmuxOverMcp.java /tmp/libtmux-java-dev/demo/s
  * }
diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java index ca434a80a..3a51517ce 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java @@ -24,6 +24,7 @@ /** * Watches what a pane prints, as tmux pushes it, rather than polling for it. * + * *
{@code
  * java WatchPaneOutput.java /tmp/libtmux-java-dev/demo/s
  * }
diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java index d48510c8c..d3c0ac9e4 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java @@ -22,6 +22,7 @@ * the difference between watching a server and polling one, and it is what lets * an agent hold a terminal open cheaply. * + * *
{@code
  * java WatchWhatChanges.java /tmp/libtmux-java-dev/demo/s
  * }
diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java index 8ffeefdb5..96d078555 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java @@ -16,6 +16,7 @@ *

Which tmux server to expose is chosen the same way the library chooses it: a socket path, a * socket name, or tmux's own default. * + * *

{@code
  * libtmux-mcp --socket /run/user/1000/tmux/default
  * libtmux-mcp --socket-name work --tmux /usr/local/bin/tmux
diff --git a/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java b/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java
index 90bd1b4e1..1571f7451 100644
--- a/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java
+++ b/libtmux/src/main/java/io/github/libtmux/CaptureSpec.java
@@ -16,6 +16,7 @@
  * kept.
  *
  * 
{@code
+ * // Given: Pane pane
  * List everything = pane.capture(c -> c.fromStartOfHistory());
  * List lastTen = pane.capture(c -> c.from(-10));
  * }
diff --git a/libtmux/src/main/java/io/github/libtmux/Keys.java b/libtmux/src/main/java/io/github/libtmux/Keys.java index 343cc1a66..6eb562d53 100644 --- a/libtmux/src/main/java/io/github/libtmux/Keys.java +++ b/libtmux/src/main/java/io/github/libtmux/Keys.java @@ -18,6 +18,7 @@ * every table when listing; {@link #in} names one. * *
{@code
+ * // Given: Server server
  * server.keys().bind("F12", List.of("display-message", "hello"));
  * server.keys().in("root").bind("F11", List.of("next-window"));
  * }
diff --git a/libtmux/src/main/java/io/github/libtmux/OptionKey.java b/libtmux/src/main/java/io/github/libtmux/OptionKey.java index dbea139e9..f8ee3e7f5 100644 --- a/libtmux/src/main/java/io/github/libtmux/OptionKey.java +++ b/libtmux/src/main/java/io/github/libtmux/OptionKey.java @@ -8,6 +8,7 @@ * A tmux option and the Java type its value reads as. * *
{@code
+ * // Given: Session session
  * session.options().set(OptionKey.HISTORY_LIMIT, 50_000);
  * session.options().get(OptionKey.HISTORY_LIMIT).orElseThrow();   // → 50000
  *
diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java
index eaa0ac831..ec1ef3767 100644
--- a/libtmux/src/main/java/io/github/libtmux/Pane.java
+++ b/libtmux/src/main/java/io/github/libtmux/Pane.java
@@ -414,6 +414,7 @@ public TextOutcome awaitText(String text, Duration timeout, Duration every) thro
      * not the prompt — cut out between two markers the plumbing prints around it.
      *
      * 
{@code
+     * // Given: Pane pane
      * PaneRun built = pane.run("make test", Duration.ofMinutes(5));
      * if (!built.succeeded()) {
      *     System.err.println(String.join("\n", built.output()));
@@ -471,6 +472,7 @@ public PaneRun run(String command, Duration timeout) throws InterruptedException
      * its deadline rules.
      *
      * 
{@code
+     * // Given: Pane pane
      * Duration limit = Duration.ofSeconds(5);
      * pane.await(p -> !p.currentCommand().equals("zsh"), limit);
      * }
@@ -508,6 +510,7 @@ Pane through(Server via) { * Reads part of this pane, described by a lambda. * *
{@code
+     * // Given: Pane pane
      * List everything = pane.capture(c -> c.fromStartOfHistory());
      * List lastTen = pane.capture(c -> c.from(-10));
      * }
@@ -722,6 +725,7 @@ private String currentCommandNow() { * everything else tmux knows, including fields added by a release this code has never heard of. * *
{@code
+     * // Given: Pane pane
      * String title = pane.expand("#{pane_title}");
      * String format = "#{session_name}:#{window_index}.#{pane_index}";
      * String where = pane.expand(format);
@@ -862,7 +866,9 @@ public Pane split() {
     /**
      * Splits this pane as described.
      *
+     * 
      * 
{@code
+     * // Given: Pane pane, Path project
      * Pane side = pane.split(s -> s.toRight().percent(30));
      * Pane app = pane.split(s -> s.running("htop").in(project));
      * }
diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 1cf35adf9..054d31a18 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -133,6 +133,7 @@ public Session newSession(String name) { * Creates a session as described. * *
{@code
+     * // Given: Server server
      * Dimensions size = new Dimensions(120, 40);
      * Session build = server.newSession(s -> s.named("build").sized(size));
      * }
@@ -519,6 +520,7 @@ Map variables(List names, Function expan * same instant. * *
{@code
+     * // Given: Server server
      * List names = List.of("pane_tty", "pane_dead");
      * Map> ttys = server.paneFields(names);
      * }
@@ -726,6 +728,7 @@ static Server using(ServerConfig config, TmuxTransport transport, PaneEcho echo) * handle taken from it, gives tmux this long rather than the server's default: * *
{@code
+     * // Given: Server server
      * Server quick = server.within(Duration.ofMillis(500));
      * List screen = quick.panes().get(0).capture();
      * }
diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index 7ecb9824f..f53db3c52 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -192,6 +192,7 @@ public Window newWindow(String name) { * Creates a window in this session as described. * *
{@code
+     * // Given: Session session
      * Window top = session.newWindow(w -> w.named("top").running("top"));
      * }
* diff --git a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java index cd86e3baf..830ddc066 100644 --- a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java @@ -23,6 +23,7 @@ * library usually runs. * *
{@code
+ * // Given: Server server
  * Dimensions size = new Dimensions(120, 40);
  * Session build = server.newSession(s -> s.named("build").sized(size));
  * }
diff --git a/libtmux/src/main/java/io/github/libtmux/SplitSpec.java b/libtmux/src/main/java/io/github/libtmux/SplitSpec.java index 5740b27d3..4eef1c3db 100644 --- a/libtmux/src/main/java/io/github/libtmux/SplitSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/SplitSpec.java @@ -24,7 +24,9 @@ * record's canonical constructor is public API, and tmux keeps adding flags to {@code split-window} * — six of them in 3.7 alone. * + * *
{@code
+ * // Given: Pane pane, Pane left, Pane right
  * Pane side = pane.split(s -> s.toRight().percent(30));
  *
  * SplitSpec sidebar = SplitSpec.builder().toRight().percent(25).build();
diff --git a/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java b/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java
index a35b4c0f0..3f8ce8a65 100644
--- a/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java
+++ b/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java
@@ -12,6 +12,7 @@
  * the pane's id. That is how a process discovers the server it is already inside, without being told
  * where to look.
  *
+ * 
  * 
{@code
  * TmuxEnvironment here = TmuxEnvironment.current().orElseThrow();
  *
diff --git a/libtmux/src/main/java/io/github/libtmux/WindowLayout.java b/libtmux/src/main/java/io/github/libtmux/WindowLayout.java
index c6f4393b0..9831c8ca0 100644
--- a/libtmux/src/main/java/io/github/libtmux/WindowLayout.java
+++ b/libtmux/src/main/java/io/github/libtmux/WindowLayout.java
@@ -10,6 +10,7 @@
  * therefore reports a differently shaped string depending on the server, and a caller that parses it
  * needs to know which it has. This says, so the compiler can carry what used to be a comment:
  *
+ * 
  * 
{@code
  * switch (window.layout()) {
  *     case WindowLayout.Json json -> restorePanes(json.value());
diff --git a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java
index c477375f2..760d98f5c 100644
--- a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java
+++ b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java
@@ -18,6 +18,7 @@
  * it can be named, reused, and applied wherever it fits.
  *
  * 
{@code
+ * // Given: Session session
  * Window logs = session.newWindow(w -> w.named("logs").running("top"));
  * }
*/ diff --git a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java index 390918cf7..47f11f964 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java +++ b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java @@ -354,7 +354,9 @@ private Optional> take() { * it, the stream fails with {@link #cause()}. Closing the stream closes this subscription, and * closing this subscription from another thread ends a stream that is waiting. * + * *
{@code
+     * // Given: EventSubscription subscription
      * try (Stream> steps = subscription.stream()) {
      *     steps.map(Delivery::kept).forEach(o -> System.out.print(o.data()));
      * }
diff --git a/libtmux/src/main/java/io/github/libtmux/control/Notification.java b/libtmux/src/main/java/io/github/libtmux/control/Notification.java
index ffb3c440d..a644f5e78 100644
--- a/libtmux/src/main/java/io/github/libtmux/control/Notification.java
+++ b/libtmux/src/main/java/io/github/libtmux/control/Notification.java
@@ -9,6 +9,7 @@
 /**
  * What a control-mode notification says, typed, for matching on.
  *
+ * 
  * 
{@code
  * switch (event.notification()) {
  *     case Notification.WindowRenamed(WindowId id, String name, boolean on) ->