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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ production.

### Documented

- **Kotlin and Scala getting-started guides include complete programs.** Each
selects a private socket, creates tmux objects, and stops its owned daemon.
The documentation tests execute their entry points, and Scala installation
examples name an exact package version.

- **`SplitSpec.Builder.running`, `WindowSpec.Builder.running` and
`SessionSpec.Builder.running` say that tmux reports the pane before the
command has started.** Wait for the pane's command with `Pane.await` rather
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import org.gradle.api.tasks.TaskAction
* cannot stop compiling or running unnoticed.
*
* Each fence carries a directive on the line before it, `<!-- snippet: scala-MODE: id | detail -->`:
* `sync` runs the code against a fresh tmux, `io` runs a Cats `IO` to completion within the
* `sync` runs the code against a fresh tmux, `main` invokes a standalone `Main.main`,
* and `io` runs a Cats `IO` to completion within the
* 15-second deadline every documented snippet has, `reject` asserts the code fails to compile with
* `detail` in the error, and `build` marks an sbt build fragment the consumer checks exercise
* instead. An unclassified fence fails the build, and so
Expand Down Expand Up @@ -87,7 +88,7 @@ abstract class GenerateScalaDocumentationSuite : DefaultTask() {
val beforeIndex = (start - 1 downTo 0).firstOrNull { lines[it].isNotBlank() }
val before = beforeIndex?.let { lines[it].trim() }.orEmpty()
val directive = requireNotNull(DIRECTIVE.matchEntire(before)) {
"$where has an unclassified Scala fence; use a snippet: scala-sync, scala-io, scala-reject or scala-build directive"
"$where has an unclassified Scala fence; use a snippet: scala-sync, scala-main, scala-io, scala-reject or scala-build directive"
}
unusedDirectives.remove(beforeIndex)
val (mode, id, detail) = directive.destructured
Expand All @@ -111,7 +112,7 @@ abstract class GenerateScalaDocumentationSuite : DefaultTask() {
}
snippets
}
require(found.any { it.mode == "sync" || it.mode == "io" }) { "no runnable Scala documentation fences were discovered" }
require(found.any { it.mode in setOf("sync", "main", "io") }) { "no runnable Scala documentation fences were discovered" }
val duplicates = found.groupBy { it.id }.filterValues { it.size > 1 }.keys.sorted()
require(duplicates.isEmpty()) { "duplicate Scala documentation ids: $duplicates" }
return found
Expand All @@ -123,14 +124,19 @@ abstract class GenerateScalaDocumentationSuite : DefaultTask() {
.joinToString(",\n") { quoted(relative(root, it)) + " -> " + quoted(digest(it.readBytes())) }
val names = runnable.joinToString(",\n") { quoted(it.name(root)) }
val declarations = runnable.filterNot { it.mode == "reject" }.joinToString("\n") { snippet ->
val body = if (snippet.mode == "sync") "DocumentationRuntime.requireUnit {\n${snippet.code}\n}" else "{\n${snippet.code}\n}"
val result = if (snippet.mode == "sync") "Unit" else "_root_.cats.effect.IO[Unit]"
"private[docs] object ${snippet.objectName} {\ndef run(config: io.github.libtmux.ServerConfig): $result = $body\n}\n"
if (snippet.mode == "main") {
"private[docs] object ${snippet.objectName} {\n${snippet.code}\ndef run(): Unit = Main.main(Array.empty[String])\n}\n"
} else {
val body = if (snippet.mode == "sync") "DocumentationRuntime.requireUnit {\n${snippet.code}\n}" else "{\n${snippet.code}\n}"
val result = if (snippet.mode == "sync") "Unit" else "_root_.cats.effect.IO[Unit]"
"private[docs] object ${snippet.objectName} {\ndef run(config: io.github.libtmux.ServerConfig): $result = $body\n}\n"
}
}
val cases = runnable.joinToString("\n") { snippet ->
val invoke = snippet.objectName + ".run(fixture.config)"
val body = when (snippet.mode) {
"sync" -> "io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture => $invoke }"
"main" -> "${snippet.objectName}.run()"
"io" ->
"io.github.libtmux.scaladsl.fixture.OwnedTmux.use { fixture =>\n" +
"val evaluated = new java.util.concurrent.atomic.AtomicBoolean(false)\n" +
Expand Down Expand Up @@ -175,7 +181,7 @@ $cases
val IGNORED = setOf(".git", ".gradle", ".bsp", ".metals", ".idea", "target", "build", "node_modules")
val OPENING = Regex("^ {0,3}(`{3,}|~{3,})[ \\t]*([^\\s`]*).*$")
val UNSUPPORTED_SCALA_FENCE = Regex("(?i)^[ \\t>]*(?:`{3,}|~{3,})[ \\t]*(?:scala\\S*|sbt)(?:[ \\t].*)?$")
val DIRECTIVE = Regex("^<!--\\s*snippet:\\s*scala-(sync|io|reject|build):\\s*([a-z0-9][a-z0-9-]*)(?:\\s*\\|\\s*(.+?))?\\s*-->$")
val DIRECTIVE = Regex("^<!--\\s*snippet:\\s*scala-(sync|main|io|reject|build):\\s*([a-z0-9][a-z0-9-]*)(?:\\s*\\|\\s*(.+?))?\\s*-->$")

fun relative(root: File, file: File): String = root.toPath().relativize(file.toPath()).toString().replace('\\', '/')

Expand Down
9 changes: 9 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ the task. Only one direction is checked: a Kotlin fence that uses a name it did
not ask for fails to compile, but one that asks for a name it never reads is not
caught, because the generated file suppresses unused-variable warnings.

A complete Kotlin program uses `<!-- snippet: main -->`; the generated test
compiles it as a separate file, without fixture imports, and calls its
`main()` function. A complete Scala program uses
`<!-- snippet: scala-main: unique-id -->` and defines `object Main` with
`def main(args: Array[String]): Unit`. Its generated test invokes that entry
point without supplying fixture variables. These programs supply their own
configuration and must clean up their
private tmux servers.

### Claims that are not code

A snippet is executed, so it cannot lie. A version in an install block, or a
Expand Down
24 changes: 9 additions & 15 deletions docs/guide/kotlin.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Kotlin

Start with the [complete program](../../libtmux-kotlin/README.md#create-and-clean-up-a-private-session)
for configuration and cleanup. This guide explains coroutine behavior and DSL
scopes when using those same handles.

## Wrapper classes over the Java handles

`Server`, `Session`, `Window`, `Pane`, `Client`, and `ControlClient` in
Expand Down Expand Up @@ -81,7 +85,9 @@ leaving it running past the point nothing is listening for its result.
- `ControlClient.output`/`events` hold no thread at all; only collecting the
returned `Flow` reads.

## The DSL, and the compile error the first draft had
<a id="the-dsl-and-the-compile-error-the-first-draft-had"></a>

## Scope nested session builders

`@DslMarker` marks `SessionBuilder`, `WindowBuilder`, and `SplitBuilder` so an
inner block cannot reach an outer block's receiver by accident. Directory is
Expand All @@ -105,26 +111,14 @@ withServer(config) { server ->
}
```

tmux's `SplitSpec.Builder` spells direction and size as method calls —
`below()`/`above()`/`toRight()`/`toLeft()`, `cells(n)`/`percent(n)` — not an
enum. The DSL forwards to that real vocabulary directly rather than inventing
a parallel `SplitDirection`/`PaneSize` type.
Use `below()`/`above()`/`toRight()`/`toLeft()` to choose split direction and
`cells(n)`/`percent(n)` to choose its size.

`env(name, value)` is on `SessionBuilder`, `WindowBuilder`, and `SplitBuilder`
alike, matching the Java `SessionSpec.Builder`/`WindowSpec.Builder`/
`SplitSpec.Builder` each has, and sets a variable in the environment the new
session, window, or pane's process starts with.

## Why nothing in Java may depend on this

Nothing written in Java may depend on `libtmux-kotlin`, and the build fails if
it does. Per the JSpecify specification a class carrying `@kotlin.Metadata` is
*not* null-marked, because the Kotlin compiler does not yet emit full
nullness into binaries
([KT-47417](https://youtrack.jetbrains.com/projects/KT/issues/KT-47417/Emit-jspecify-annotations-for-types-in-Kotlin-binaries)).
A Kotlin-authored API would therefore be worse for a Java caller and invisible
to NullAway. The dependency runs one way only.

See the [module README](../../libtmux-kotlin/README.md) for the full call-site
tour, including the query DSL, the exhaustive `when` over sealed failures, and
`StateFlow`.
4 changes: 2 additions & 2 deletions docs/guide/scala/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ operations are written by hand, for their cancellation or resource scoping:
Both facades generate from the same catalog through the same owner-and-kind
branching, and the [generator's tests][generator-tests] hold the two to it: a
captured operation forwards purely on both, and a mutation is effect-wrapped on
the Cats side only. Nothing Scala has shipped yet, so there is no earlier
release for MiMa or `tasty-mima` to compare against.
the Cats side only. These alpha artifacts have no binary compatibility
guarantee; pin an exact version when adding them to an application.

## Inherited feature boundaries

Expand Down
127 changes: 58 additions & 69 deletions docs/guide/scala/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,113 +14,102 @@ They are on Maven Central and release with the Java artifacts.
- **`libtmux-scala-cats_3`** — Cats Effect resources and FS2 observations.
- **`libtmux-scala-ox_3`** — an Ox `Flow` over subscriptions and live views.

For a new sbt project, save this as `build.sbt`:

<!-- snippet: scala-build: install-core -->
```sbt
libraryDependencies += "io.github.libtmux" %% "libtmux-scala" % "<version>"
scalaVersion := "3.9.0"
scalacOptions += "-release:25"
libraryDependencies += "io.github.libtmux" %% "libtmux-scala" % "0.0.1-alpha.17"
```

For Cats Effect and FS2, or for Ox, add the matching module:

<!-- snippet: scala-build: install-cats -->
```sbt
libraryDependencies += "io.github.libtmux" %% "libtmux-scala-cats" % "<version>"
libraryDependencies += "io.github.libtmux" %% "libtmux-scala-cats" % "0.0.1-alpha.17"
```

<!-- snippet: scala-build: install-ox -->
```sbt
libraryDependencies += "io.github.libtmux" %% "libtmux-scala-ox" % "<version>"
libraryDependencies += "io.github.libtmux" %% "libtmux-scala-ox" % "0.0.1-alpha.17"
```

From Gradle or Maven, name the suffixed artifact directly:
`io.github.libtmux:libtmux-scala_3:<version>`. The core facade depends on
`io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17`. The core facade depends on
`libtmux` and the Scala 3 library, and on nothing else.

## A first client

The function below takes three caller-supplied values and constructs a Java
`ServerConfig` before opening the Scala client:

| Parameter | Value to supply |
| --- | --- |
| `binary` | Absolute path to your tmux executable |
| `socket` | An isolated socket you own under `/tmp/libtmux-java-dev/` |
| `configFile` | Your tmux configuration file, or `/dev/null` for none |

For example, choose `/tmp/libtmux-java-dev/scala-start/socket`; the function
creates its parent directory. Opening the client does not create tmux;
`newSession` does. The operation creates a session, splits its window, verifies
the resulting panes and kills that session. Closing the client is separate
from session cleanup. An existing server's other sessions remain running;
tmux normally exits when its final session closes.
Save this complete program as `src/main/scala/Main.scala`. It selects a fresh
socket under `/tmp/libtmux-java-dev/`, creates a session with two panes, checks
the captured layout, and stops its private daemon before closing the client.
It reads tmux from `PATH`; set `LIBTMUX_TMUX` to select another binary.

Call `firstClient` with your three values from your application.
The final call in this tested snippet takes those inputs from the owned test
fixture's `config`; the function builds and uses its own configuration.

<!-- snippet: scala-sync: getting-started-session -->
<!-- snippet: scala-main: getting-started-session -->
```scala
import io.github.libtmux.{
Layout, ServerConfig, ServerEndpoint, SessionSpec, SplitSpec
}
import io.github.libtmux.scaladsl.{config => _, *}
import io.github.libtmux.scaladsl.*
import java.nio.file.{Files, Path}
import java.time.Duration
import scala.util.Using

def firstClient(binary: String, socket: Path, configFile: Path): Unit = {
require(Path.of(binary).isAbsolute, "supply an absolute tmux executable")
val ownedSocket = socket.toAbsolutePath.normalize()
require(
ownedSocket.startsWith(Path.of("/tmp/libtmux-java-dev")) ||
ownedSocket.startsWith(Path.of("/tmp/libtmux-java-test")),
"choose a socket under an owned libtmux Java directory"
)
Files.createDirectories(ownedSocket.getParent)
val selected = ServerConfig.builder()
.binary(binary)
.endpoint(ServerEndpoint.socketPath(ownedSocket))
.configFile(configFile)
.defaultTimeout(Duration.ofMillis(800))
.build()

Using.resource(Server.open(selected)) { server =>
val session = server.newSession(
SessionSpec.builder().named("scala-start").running("cat", "-").build()
)
object Main {
def main(args: Array[String]): Unit = {
val root = Files.createDirectories(Path.of("/tmp/libtmux-java-dev"))
val directory = Files.createTempDirectory(root, "scala-start-")
val socket = directory.resolve("s")
val config = ServerConfig.builder()
.binary(sys.env.getOrElse("LIBTMUX_TMUX", "tmux"))
.endpoint(ServerEndpoint.socketPath(socket))
.configFile(Path.of("/dev/null"))
.build()
try {
val window = session.windows.head
val second = window.split(
SplitSpec.builder().running("cat", "-").build()
)
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.
val panes = window.refresh().panes
assert(panes.size == 2)
assert(panes.exists(_.info.id().value() == second.info.id().value()))
} finally session.kill()
Using.resource(Server.open(config)) { server =>
try {
val session = server.newSession(
SessionSpec.builder().named("scala-start").running("cat", "-").build()
)
val window = session.windows.head
val second = window.split(
SplitSpec.builder().running("cat", "-").build()
)
window.selectLayout(Layout.EVEN_HORIZONTAL)
second.select()
val panes = window.refresh().panes
assert(panes.size == 2)
assert(panes.exists(_.info.id().value() == second.info.id().value()))
println(s"created ${panes.size} panes")
} finally server.killServer()
}
} finally {
Files.deleteIfExists(socket)
Files.deleteIfExists(directory)
}
}
}
```

config.endpoint() match {
case endpoint: ServerEndpoint.SocketPath =>
firstClient(
config.binaryPath(),
endpoint.path(),
config.configFile().orElse(Path.of("/dev/null"))
)
case _ => throw new IllegalArgumentException("an explicit socket is required")
}
Run it with sbt:

```console
$ sbt run
```

The output includes `created 2 panes`. `window.refresh()` captures the pane
list after the split; the original window's captured list still has one pane.
`Using.resource` closes the library client. The separate `killServer` call
stops the daemon because this program created and owns it. When connecting to
an existing daemon, leave its lifetime with its owner and remove only sessions
your application created.

Timeouts are `scala.concurrent.duration.FiniteDuration` at every public entry
point, converted once at the boundary: `pane.awaitText("$", 5.seconds)`
never surfaces `java.time.Duration` to the caller.

Follow with [queries](query.md), [ownership](ownership.md), then
[execution](execution.md). For immediate access without the facade, use the
[direct Java guide](../scala.md).
[execution](execution.md). The [API reference][server] documents the direct-style server.

## Build from source

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,8 @@ void theReadmeNamesTheEndsOfTheMatrixItClaims() {
* Every sbt install line the documentation shows is one the sbt consumer build resolves.
*
* <p>That build fetches each artifact from the staged repository exactly as a reader's would, so a
* documented line it does not carry is a line nothing has shown to work. Versions are compared
* as a placeholder: the documents say {@code "<version>"} or the release, the build says
* {@code libtmuxVersion}.
* documented line it does not carry is a line nothing has shown to work. Documents name the
* release; the consumer receives that version through {@code libtmuxVersion}.
*/
@Test
void everyDocumentedSbtLineIsOneTheSbtConsumerResolves() {
Expand All @@ -198,6 +197,7 @@ void everyDocumentedSbtLineIsOneTheSbtConsumerResolves() {
for (String document : readerFacing()) {
Matcher found = fence.matcher(read(document));
while (found.find()) {
assertFalse(found.group(2).contains("\"<version>\""), document + " needs an installable version");
for (String line : sbtLines(found.group(2))) {
if (!consumer.contains(line)) {
unresolved.add(document + " (" + found.group(1) + "): " + line);
Expand Down
Loading
Loading