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
3 changes: 3 additions & 0 deletions WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ routine site tests. A changed hash needs a new native run before review.
Use `--example attach` to check the existing-server programs in the attach guide.
Use `--example query --port ts` to run every complete program on the TypeScript
filtering page, including its displayed setup and error assertions.
Use `--example concept --port fsharp --page concepts/queries` for the native
concept pages. Each run command checks its own expected output; a later success
cannot hide an earlier missing result.

Put explanatory comments on separate lines above the code they describe.
Limit example comments to 100 columns, including indentation; prefer shorter
Expand Down
1 change: 1 addition & 0 deletions packages/api-model/src/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const BUILTINS: Record<string, Record<string, string>> = {
Flow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/',
StateFlow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/',
'collect()': 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect.html',
'singleOrNull()': 'https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/single-or-null.html',
},
scala: {
String: scala('scala/Predef$'), Boolean: scala('scala/Boolean'),
Expand Down
29 changes: 19 additions & 10 deletions scripts/check-example-prose.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@
def main():
repo = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--example', choices=['capture', 'attach', 'query'], default='capture')
parser.add_argument('--example', choices=['capture', 'attach', 'query', 'concept'], default='capture')
parser.add_argument('--port', required=True)
parser.add_argument('--page', help='Page path within the port, for example concepts/queries')
parser.add_argument('--output-dir', required=True, type=Path)
args = parser.parse_args()
manifest = json.loads((repo / f'site/test/fixtures/{args.example}-examples.json').read_text())
examples = {item['port']: item for item in manifest['examples']}
if args.port not in examples:
parser.error(f'Unknown port: {args.port}; choose from {", ".join(examples)}')
example = examples[args.port]
examples = [item for item in manifest['examples'] if item['port'] == args.port]
if args.page:
examples = [item for item in examples if item['page'] == f'ports/{args.port}/{args.page}']
if len(examples) != 1:
choices = ', '.join(item['page'] for item in examples)
parser.error(f'Choose one example with --port and --page; matching pages: {choices or "none"}')
example = examples[0]
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))
Expand All @@ -48,6 +52,10 @@ def main():
for block in blocks if block[1] == 'console']
if not commands or commands != example['shellRecipe']:
raise ValueError('Setup commands differ from the verification record')
expected = example.get('expectedOutputs', [[] for _ in commands[:-1]] +
[[example.get('expectedOutput', 'libtmux capture ready')]])
if len(expected) != len(commands):
raise ValueError('Expected output must be recorded for each setup or run command')

output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=False)
Expand All @@ -66,13 +74,14 @@ def main():
with log.open('w') as stream:
result = subprocess.run(['sh', '-eu', '-c', command], cwd=output,
env=env, stdout=stream, stderr=subprocess.STDOUT)
missing = [line for line in expected[index] if line not in log.read_text().splitlines()]
results.append({'command': command, 'exit': result.returncode,
'seconds': round(time.monotonic() - start, 3)})
if result.returncode:
'seconds': round(time.monotonic() - start, 3), 'missingOutput': missing})
if result.returncode or missing:
break
passed = all(row['exit'] == 0 for row in results)
passed = passed and example.get('expectedOutput', 'libtmux capture ready') in log.read_text().splitlines()
report = {'port': args.port, 'sourceRevision': example['sourceRevision'],
passed = len(results) == len(commands) and all(
row['exit'] == 0 and not row['missingOutput'] for row in results)
report = {'port': args.port, 'page': example['page'], '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.'}
Expand Down
1 change: 1 addition & 0 deletions site/src/content/docs/concepts/index.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
supportedPorts: [py, ts, rs, go, java, dotnet, cxx, swift, ruby, lua]
title: Concepts
description: tmux objects, command transports, queries, and workspaces.
sidebar:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
supportedPorts: [py, ts, rs, go, java, dotnet, cxx, swift, ruby, lua]
title: Server, session, window, pane
description: The object hierarchy every libtmux port mirrors from tmux itself, and the client that sits outside it.
sidebar:
Expand Down
22 changes: 22 additions & 0 deletions site/src/content/docs/ports/fsharp/concepts/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
port: fsharp
route: concepts
title: F# concepts
description: Understand F# handles, filters, transports and layout ownership.
sidebar:
label: F# concepts
group: Concepts
order: 1
tableOfContents: true
---

Use these concepts to reason about F# handles, selection and command execution. Each page includes complete programs with imports, project files, run commands and cleanup.

| Concept | What you will do |
| --- | --- |
| [Server, session, window, pane](./server-session-window-pane/) | Traverse a capture and refresh after a rename. |
| [Filtering and queries](./queries/) | Combine predicates, handle result counts and match related windows. |
| [Commands and control mode](./transports/) | Run bounded commands and manage a persistent control client. |
| [Layouts and repeated setup](./workspaces/) | Create a split window and reuse a named window safely. |

For individual types and operations, open the [API reference](../reference/). For a first connection, start with [attaching to tmux](../guides/attaching-to-tmux/).
251 changes: 251 additions & 0 deletions site/src/content/docs/ports/fsharp/concepts/queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
---
port: fsharp
route: concepts/queries
title: Filtering and queries
description: Compose F# filters, distinguish zero and several matches, and query captured relations.
sidebar:
label: Filtering and queries
group: Concepts
order: 4
tableOfContents: true
---

Filter captured objects in F#, handle result counts explicitly, and match sessions by their related windows. Local filters read captured values; they do not subscribe to changes or issue a fresh tmux query.

[`Filter`](../../reference/libtmux-fsharp-filter/) supplies equality, ordinal prefix matching and membership. Use `allOf`, `anyOf` and `negate` to compose conditions. [`Query.matching`](../../reference/libtmux-fsharp-query-matching/) materializes the matching objects in source order. Use `Seq.filter` for an application predicate that does not need a portable query.

## Setup and run

Use an empty directory on Linux with Git, tmux 3.2a or newer, and .NET SDK 10.0.302. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point.

```xml title="Query.fsproj"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Example Condition="'$(Example)' == ''">Local</Example>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(Example).fs" />
<ProjectReference Include="libtmux-source/src/LibTmux.FSharp/LibTmux.FSharp.fsproj" />
</ItemGroup>
</Project>
```

The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error.

```sh title="run.sh"
#!/bin/sh
set -eu

binary=$(command -v tmux)
mkdir -p /tmp/libtmux-dotnet-dev
directory=$(mktemp -d /tmp/libtmux-dotnet-dev/query.XXXXXX)
socket="$directory/tmux.sock"

cleanup() {
status=$?
trap - 0 HUP INT TERM
if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then
printf 'Cannot stop tmux; kept %s\n' "$directory" >&2
exit 1
fi
rm -rf "$directory" || exit 1
exit "$status"
}
trap cleanup 0
trap 'exit 1' HUP INT TERM

unset TMUX TMUX_PANE
export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary"
"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat
"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat
"$@"
"$binary" -S "$socket" has-session -t '=work-one'
```

Fetch the library revision used by these examples:

```console
$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&
git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6
```

Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs.

## Match names and combine conditions

Select the two names beginning with `work-`, then assert equality, AND, OR and exclusion against the same captured collection. Matching is case sensitive. The native collection predicate agrees with the typed prefix query.

```fsharp title="Local.fs"
open System
open System.Threading
open System.Threading.Tasks
open LibTmux
open LibTmux.FSharp

let run () = task {
let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH")
if String.IsNullOrEmpty(socket) then
invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket"
use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0))
let token = timeout.Token
let! server = LibTmux.Server.ConnectAsync(
ServerConnectionOptions(SocketPath = socket), token)
let! captured = server |> Server.capture token SnapshotDepth.Sessions
let prefix = Filter.startsWith "work-" SessionFields.name
let matching = captured.Sessions |> Query.matching prefix
let names = matching |> Seq.map (fun session -> session.Name) |> Seq.sort |> Seq.toList
if names <> [ "work-one"; "work-two" ] then failwith "Unexpected sessions"
printfn "%s" (String.concat ", " names)
let native = captured.Sessions |> Seq.filter (fun session -> session.Name.StartsWith("work-"))
if Seq.length native <> matching.Count then failwith "Local filters disagree"
let onlyOne = Filter.allOf [
Filter.startsWith "work-" SessionFields.name
Filter.eq "work-one" SessionFields.name
]
let selected = captured.Sessions |> Query.matching onlyOne
if selected.Count <> 1 || selected[0].Name <> "work-one" then failwith "Wrong AND result"
let either = Filter.oneOf [ "work-one"; "work-two" ] SessionFields.name
if (captured.Sessions |> Query.matching either).Count <> 2 then failwith "Wrong OR result"
let excluded = Filter.eq "work-one" SessionFields.name |> Filter.negate
let remaining = captured.Sessions |> Query.matching excluded
if remaining.Count <> 1 || remaining[0].Name <> "work-two" then failwith "Wrong NOT result"
}

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

```console
$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Local \
-p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" &&
sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Local
```

Expected program output:

```text
work-one, work-two
```

## Distinguish missing and ambiguous results

[`Selection.exactlyOne`](../../reference/libtmux-fsharp-selection-exactlyone/) returns a `Result`. Match `NoMatches` and `MultipleMatches` separately. A missing optional target and an ambiguous destructive target should not take the same path.

```fsharp title="Cardinality.fs"
open System
open System.Threading
open System.Threading.Tasks
open LibTmux
open LibTmux.FSharp

let run () = task {
let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH")
if String.IsNullOrEmpty(socket) then
invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket"
use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0))
let token = timeout.Token
let! server = LibTmux.Server.ConnectAsync(
ServerConnectionOptions(SocketPath = socket), token)
let! captured = server |> Server.capture token SnapshotDepth.Sessions
for name in [ "work-one"; "missing" ] do
let matches = captured.Sessions |> Query.matching (Filter.eq name SessionFields.name)
match matches |> Selection.exactlyOne with
| Ok session -> printfn "%s: selected" session.Name
| Error CardinalityError.NoMatches -> printfn "%s: absent" name
| Error CardinalityError.MultipleMatches -> failwithf "Ambiguous session: %s" name
let many = captured.Sessions |> Selection.exactlyOne
match many with
| Error CardinalityError.MultipleMatches -> printfn "work-: ambiguous"
| _ -> failwith "Expected two sessions"
}

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

```console
$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Cardinality \
-p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" &&
sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Cardinality
```

Expected program output:

```text
work-one: selected
missing: absent
work-: ambiguous
```

## Filter through related windows

Match sessions with any window named `editor`, then match sessions with none. For an empty captured relation, `any` is false and `none` is true; `all` is true. An uncaptured relation is a different condition and must be captured before filtering. This program derives the required snapshot depth from the query document.

```fsharp title="Relations.fs"
open System
open System.Threading
open System.Threading.Tasks
open LibTmux
open LibTmux.FSharp

let run () = task {
let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH")
if String.IsNullOrEmpty(socket) then
invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket"
use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0))
let token = timeout.Token
let! server = LibTmux.Server.ConnectAsync(
ServerConnectionOptions(SocketPath = socket), token)
let editor = Filter.eq "editor" WindowFields.name
let withEditor = editor |> Filter.any SessionFields.windows
let withoutEditor = editor |> Filter.none SessionFields.windows
let depth = (Filter.toDocument withEditor).RequiredSnapshotDepth
let! captured = server |> Server.capture token depth
let selected = captured.Sessions |> Query.matching withEditor
let excluded = captured.Sessions |> Query.matching withoutEditor
if selected.Count <> 1 || selected[0].Name <> "work-one" then failwith "Wrong editor session"
if excluded.Count <> 1 || excluded[0].Name <> "work-two" then failwith "Wrong other session"
printfn "editor: %s" selected[0].Name
printfn "no editor: %s" excluded[0].Name
}

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

```console
$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Relations \
-p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" &&
sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Relations
```

Expected program output:

```text
editor: work-one
no editor: work-two
```

## Refresh before repeating a decision

Reusing the same list repeats the same decision over old state. Capture again when you need to observe a rename, new window or closed pane. The [snapshot example](../server-session-window-pane/#observe-a-rename-with-a-fresh-read) shows the old and fresh values side by side.
Loading
Loading