Skip to content
Merged
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
13 changes: 13 additions & 0 deletions WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ Record its commands, source revision, result, and content hash in the review.
Tests that add a hidden prelude or execute a larger source file do not verify
the copied example. Preserve collected examples when changing formatting.

Repeat a complete capture example with its native tools and tmux on `PATH`:

```console
$ python3 scripts/check-capture-prose.py \
--port kotlin \
--output-dir /tmp/capture-kotlin-proof
```

The output directory must be new. The runner extracts the displayed files,
checks their recorded hashes, executes the displayed setup, and saves logs
and a result. Dependency downloads and native builds are separate from the
routine site tests. A changed hash needs a new native run before review.

Put explanatory comments on separate lines above the code they describe.
Wrap example comments at 80 columns, including indentation. Put long source
links and attribution in prose outside the code block.
Expand Down
82 changes: 82 additions & 0 deletions scripts/check-capture-prose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Run a complete capture example exactly as displayed, including its setup.

Use --port to choose a language and --output-dir for a new evidence directory.
The selected language's native tools, Git, and tmux must already be on PATH.
This downloads and builds dependencies; it belongs outside routine site tests.
"""

import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import time


def main():
repo = Path(__file__).resolve().parent.parent
manifest = json.loads((repo / 'site/test/fixtures/capture-examples.json').read_text())
examples = {item['port']: item for item in manifest['examples']}
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', required=True, choices=examples)
parser.add_argument('--output-dir', required=True, type=Path)
args = parser.parse_args()
example = examples[args.port]
page = repo / 'site/src/content/docs' / (example['page'] + '.md')
content = page.read_text()
blocks = list(re.finditer(r'^```(\S+)([^\n]*)\n(.*?)^```', content, re.M | re.S))
files = {}
for item in example['files']:
name = item['name']
path = Path(item.get('path', name))
if path.is_absolute() or '..' in path.parts:
raise ValueError(f'Invalid example filename: {name}')
matches = [block[3] for block in blocks if f'title="{name}"' in block[2]]
if len(matches) != 1:
raise ValueError(f'Expected one displayed file: {name}')
code = matches[0]
if hashlib.sha256(code.encode()).hexdigest() != item['sha256']:
raise ValueError(f'Changed example bytes: {name}; review its verification record')
files[str(path)] = code
commands = [re.sub(r'^\$ ', '', block[3], flags=re.M).strip()
for block in blocks if block[1] == 'console']
if not commands or commands != example['shellRecipe']:
raise ValueError('Setup commands differ from the verification record')

output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=False)
for name, code in files.items():
target = output / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(code)
env = dict(os.environ)
env.pop('TMUX', None)
env.pop('TMUX_PANE', None)
results = []
for index, command in enumerate(commands):
start = time.monotonic()
log = output / f'run-{index + 1}.log'
print(f'Running {args.port}; log: {log}', flush=True)
with log.open('w') as stream:
result = subprocess.run(['sh', '-eu', '-c', command], cwd=output,
env=env, stdout=stream, stderr=subprocess.STDOUT)
results.append({'command': command, 'exit': result.returncode,
'seconds': round(time.monotonic() - start, 3)})
if result.returncode:
break
passed = all(row['exit'] == 0 for row in results)
passed = passed and 'libtmux capture ready' in log.read_text().splitlines()
report = {'port': args.port, 'sourceRevision': example['sourceRevision'],
'pageSha256': hashlib.sha256(page.read_bytes()).hexdigest(),
'files': example['files'], 'runs': results, 'passed': passed,
'scope': 'Exact displayed program and setup; native execution on this host.'}
(output / 'result.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))
return 0 if passed else 1


if __name__ == '__main__':
raise SystemExit(main())
7 changes: 6 additions & 1 deletion site/src/content/docs/examples/capture-pane-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,14 @@ Complete programs with imports, setup, and cleanup:
[Go](/go/latest/examples/capture-pane-output/) ·
[Rust](/rs/latest/examples/capture-pane-output/) ·
[Java](/java/latest/examples/capture-pane-output/) ·
[Kotlin](/kotlin/latest/examples/capture-pane-output/) ·
[Scala](/scala/latest/examples/capture-pane-output/) ·
[.NET](/dotnet/latest/examples/capture-pane-output/) ·
[F#](/fsharp/latest/examples/capture-pane-output/) ·
[C++](/cxx/latest/examples/capture-pane-output/) ·
[Swift](/swift/latest/examples/capture-pane-output/)
[Swift](/swift/latest/examples/capture-pane-output/) ·
[Ruby](/ruby/latest/examples/capture-pane-output/) ·
[Lua](/lua/latest/examples/capture-pane-output/)

<a id="source-inclusion"></a>
<a id="where-this-comes-from"></a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ explains why sending and waiting are separate operations.
Use an empty directory. The commands pin the library
revision used to verify the program.

Save the program and build file using the displayed names. Use CMake
No separate header is needed for this single-file executable. Save the program
and build file using the displayed names. Use CMake
3.25 or newer, Ninja, and Clang 18 with libc++ 18 on Linux. The public testing
library supplies the private server's lifetime management; it is linked explicitly below.

Expand Down
4 changes: 4 additions & 0 deletions site/src/content/docs/ports/fsharp/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ sidebar:
group: Examples
---

Start with [Capture pane output](./capture-pane-output/) for a standalone program with imports, a project file, and private-server cleanup.

## Filter a snapshot

This complete program creates an isolated tmux server, captures its panes, and applies a portable filter. Both owned scopes close when the task completes. Follow the [package quickstart](../guides/quickstart/) to create an F# project and install LibTmux.FSharp, then use this as Program.fs.

```fsharp file="examples/LibTmux.FSharp.Quickstart/Program.fs"
Expand Down
138 changes: 138 additions & 0 deletions site/src/content/docs/ports/fsharp/examples/capture-pane-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
port: fsharp
route: examples/capture-pane-output
title: Capture pane output
description: Run a complete F# program that captures output on an isolated tmux server.
sidebar:
label: Capture pane output
group: Examples
order: 3
tableOfContents: true
---

This complete F# program starts a private tmux server, sends a command,
and captures the line it prints. It includes imports, setup, and cleanup.
You need tmux and a Unix environment; no existing session is required.

## Read what's on screen

The leading newline puts the output on a fresh row. Matching the whole line
avoids mistaking the echoed command for its output.

```fsharp title="Program.fs"
open System
open System.Collections.Generic
open System.Diagnostics
open System.IO
open System.Threading
open System.Threading.Tasks
open LibTmux
open LibTmux.FSharp

let capture () = task {
let directory = Path.Combine("/tmp/libtmux-dotnet-dev", Guid.NewGuid().ToString("N"))
Directory.CreateDirectory(directory) |> ignore
let errors = ResizeArray<exn>()
let mutable owned: OwnedServerScope option = None
let mutable stopped = false
try
use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10.0))
let token = timeout.Token
let environment = Dictionary<string, string>()
for name in [ "TMUX"; "TMUX_PANE"; "ENV"; "BASH_ENV" ] do
environment[name] <- null
let! scope = LibTmux.Server.CreateOwnedAsync(
ServerConnectionOptions(
SocketPath = Path.Combine(directory, "tmux.sock"),
ConfigurationFile = "/dev/null",
ChildEnvironment = environment), token)
owned <- Some scope
let! session = scope.Value.CreateSessionAsync(
NewSessionRequest(Name = "capture", Command = "/bin/sh"), token)
let! panes = session.GetPanesAsync(token)
let pane = panes[0]
do! pane |> Pane.sendKeys token (SendKeysRequest(
Text = "printf '\\nlibtmux capture ready\\n'", Literal = true, Enter = true))

let elapsed = Stopwatch.StartNew()
let mutable captured = false
while not captured && elapsed.Elapsed < TimeSpan.FromSeconds(5.0) do
let! lines = pane |> Pane.capture token (CapturePaneRequest())
captured <- Seq.contains "libtmux capture ready" lines
if not captured then do! Task.Delay(25, token)
if not captured then
raise (TimeoutException("Output did not arrive within five seconds"))
printfn "libtmux capture ready"
with error -> errors.Add(error)

// Keep the endpoint available for inspection if stopping the server fails.
match owned with
| Some scope ->
try
do! scope.DisposeAsync().AsTask()
stopped <- true
with error -> errors.Add(error)
| None -> stopped <- not (File.Exists(Path.Combine(directory, "tmux.sock")))
if stopped then
try Directory.Delete(directory, true)
with error -> errors.Add(error)
if errors.Count > 0 then raise (AggregateException(errors))
}

[<EntryPoint>]
let main _ =
try
capture().GetAwaiter().GetResult()
0
with error ->
eprintfn "%O" error
1
```

<a id="wait-for-text-instead-of-guessing-a-delay"></a>

## Wait for output or completion

`Pane.sendKeys` and `Pane.capture` return tasks. The program stops its owned server after either success or failure. It reports cleanup errors alongside the original error and keeps the socket directory if stopping fails.

Capture reads screen state and scrollback, so output that has scrolled away
may be absent. The program prints `libtmux capture ready` when its check passes
and exits unsuccessfully if an operation fails.

## Setup and run

Use an empty directory and save the files using the displayed names. You need
.NET SDK 10.0.302. Restore into a project-local cache from NuGet so FSharp.Core matches the library’s lockfile.

Save `Capture.fsproj` beside `Program.fs`.

```xml title="Capture.fsproj"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
<ProjectReference Include="libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj" />
</ItemGroup>
</Project>
```

The commands pin the library revision used to run this program.

```console
$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&
git -C libtmux-source checkout 661287848a6cfb407f37114b25e8249a29f99e3f &&
dotnet build Capture.fsproj --maxcpucount:1 \
-p:DisableImplicitLibraryPacksFolder=true \
-p:RestorePackagesPath="$PWD/.packages" &&
dotnet run --project Capture.fsproj --no-build
```

<a id="source-inclusion"></a>

## Where this comes from

The displayed files were compiled or loaded with their native tools and run
on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
4 changes: 4 additions & 0 deletions site/src/content/docs/ports/kotlin/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ sidebar:
group: Examples
---

Start with [Capture pane output](./capture-pane-output/) for a standalone program with imports, Gradle files, and private-server cleanup.

## Read a Flow

Read pushed pane output as a coroutine Flow and cancel a pending wait. The complete program belongs to the Java repository's examples module; that module also supplies `WatchPaneOutput`, and its test starts an isolated tmux server before calling this program's `main`. Run `./gradlew :examples:test` from that repository.

```kotlin file="examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt"
Expand Down
Loading
Loading