Merge branch 'worktree-agent-a30feda38122e4492' into rustify

This commit is contained in:
iris committed 2026-09-04 23:54:48 -04:00
commit 85ec5416b6
22 files changed
+7187 -373

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ local.properties
.idea/
.DS_Store
server/target/
event-model/target/
client-core/target/
# Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh).
+130
View File
@@ -0,0 +1,130 @@
# client-core
`client-core/` is the app's pure logic held once instead of twice, per
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
framework dependency of any kind, so it can outlive whichever one the app
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
`event-model/` is its sibling: the wire shape both this crate and `server/`
share, extracted from `server/src/session/driver.rs` and
`session/transcript.rs` on 2026-09-04.
Neither crate is wired into anything yet. `server/` re-exports `event-model`
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
has no caller -- it exists for whichever experiment in RUST.md picks it up
next (a Masonry or iris transcript screen, most likely).
## What's here, and what Kotlin file it replaces
| `client-core/src/…` | Kotlin original | Status |
|------------------------------------------|-------------------------------------------|--------|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage).
## Correspondence notes worth knowing before touching either side
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
non-overlapping spans. Whatever UI framework ends up consuming this
crate maps `Style` onto its own text-styling type; nothing here should
change to accommodate a particular one.
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
strings. `highlight::span_text` turns a `Span` back into text for a
caller working the same way; a caller that wants byte offsets into a
`&str` has to convert.
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
translation bug while it was being written: an early draft let a
*damaged* chunk (one file unreadable, discard just this session) and a
genuine I/O failure (disk gone, disable the whole cache) both surface as
the same `Err` from one closure, which would have disabled every
session's cache over a single corrupt chunk. Fixed by checking a
thread-local "was this damage" flag before deciding which failure mode
it was -- see the comment on `guard` and the commit message for
`transcript_cache.rs`.
## What `api.rs` covers, and what it does not yet
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
`ApiClient` and `event_stream::follow_session_events` are tested with a
fake transport and no server). `UreqTransport` is the only real
implementation, backed by `ureq` -- see its Cargo.toml comment for why
(blocking, already a project dependency, no extra TLS crate needed since
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
Covered: session list/read, message send, unqueue, answer, interrupt,
stop, start, rename, cwd, model, permission-mode, notify, command,
compact, delete, and one transcript page.
**Not covered, and each is real work rather than a stub to fill in:**
setups (`/setups*`, machine and provider discovery), the file explorer
(`/setups/{id}/dir|file`), usage (`/usage`), models
(`/models*`, HuggingFace browsing and downloads), attachments
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
the full table to work from when one of these is next.
## What `transcript_fold.rs` covers, and what it does not yet
`fold_event` covers every `Event` variant server/ can produce today,
including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
merges a tool call split across two fetched pages and re-merges a run a
boundary cut through. This matters the moment paging backward through
history is exercised; it is deliberately left rather than rushed, since
it is exactly the kind of boundary logic this project's own "things that
have bitten" section warns reads fine and is wrong at the edges.
**Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
A server newer than this build that adds an event type will fail to parse
that line rather than degrading to a placeholder row. Closing this means
deciding how `event_model` itself represents "a shape I don't recognise"
-- a shared-model decision affecting `server/` too, not a `client-core`-only
fix, so it is recorded here rather than silently worked around.
## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a
renderer walks to lay out prose versus code versus a table.
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
AST is Compose rendering plumbing, not something to port as-is; a Rust
UI layer will want its own block parser or a crate for it, decided
alongside the framework choice in RUST.md.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
rather than about the transcript.
## Verifying
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`,
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
+940
View File
@@ -0,0 +1,940 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"serde",
"serde_json",
"tempfile",
"ureq",
]
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "client-core"
version = "0.1.0"
edition = "2024"
# The app's pure logic, held once instead of twice: the event model (shared
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
# surface (see `server/src/routes.rs`'s module doc for the table), the
# transcript fold and cache, the markdown block model, the syntax
# highlighter and the ANSI parser. See CLIENT_CORE.md at the repo root for
# what this holds today, what it does not yet, and how it corresponds to
# the Kotlin it replaces.
#
# No UI framework dependency of any kind -- this crate is meant to outlive
# whichever one the app ends up drawing with (see RUST.md).
[dependencies]
event-model = { path = "../event-model" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
# `server/` already depends on ureq for its own outbound HTTPS (the usage
# poll in usage.rs) and it is rustls-backed like the rest of this project's
# TLS, so this reuses that choice rather than pulling in reqwest's async
# stack -- a client that runs one blocking request at a time, the way
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
# no need of an async runtime, and RUST.md's brief for this port is
# "lightweight" throughout.
ureq = { version = "3", features = ["json"] }
[dev-dependencies]
tempfile = "3"
+534
View File
@@ -0,0 +1,534 @@
//! What a tool printed, with its terminal styling applied and everything
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
//! here, so a [`StyledText`] of plain text plus non-overlapping
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
//! [`Style`] onto whatever it draws with.
//!
//! Bash output arrives exactly as the program wrote it, escape sequences
//! included, and drawn verbatim those are line noise in the middle of the
//! thing being read. Stripping them all would be the other half-answer --
//! colour is often the whole of what a diff or a test run is saying.
//!
//! So the sequences that decide how text *looks* become spans, and every
//! other one is dropped rather than shown: the rest move a cursor around a
//! grid this is not, and "go to column 40" has no meaning in a scrolling
//! document.
//!
//! A carriage return is honoured the way a terminal honours it: what was
//! written since the last line break is thrown away and the line starts
//! again. That is what makes a progress bar show its final state rather
//! than every state it passed through.
use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
/// because the one place that needs partial transparency (dimming) says so
/// with a separate flag rather than baking it into the colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
/// The sixteen colours a terminal program names, and the two it assumes.
///
/// Its own palette rather than the syntax one: a program that prints in red
/// has chosen red, where a highlighter's colours are this app's reading of
/// somebody else's code.
#[derive(Debug, Clone)]
pub struct AnsiPalette {
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
pub colours: [Rgb; 16],
/// What uncoloured text is, needed only where a style has to state a colour.
pub foreground: Rgb,
/// What the text sits on, needed for reverse video.
pub background: Rgb,
}
/// One span's worth of styling. `None` fields mean "unspecified", the same
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Style {
pub color: Option<Rgb>,
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
pub alpha: Option<f32>,
pub background: Option<Rgb>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
/// Plain text plus the non-overlapping, ordered spans that style parts of it
/// -- this crate's stand-in for Compose's `AnnotatedString`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
pub text: String,
pub spans: Vec<(Range<usize>, Style)>,
}
impl StyledText {
fn plain(text: String) -> Self {
Self {
text,
spans: Vec::new(),
}
}
}
const ESC: char = '\u{1B}';
const BELL: char = '\u{7}';
/// [text] with its terminal styling applied and everything else taken out;
/// see the module doc.
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
// The common case by a long way -- nothing to do, and nothing allocated
// to find that out.
if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string());
}
let chars: Vec<char> = text.chars().collect();
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
let mut sgr = Sgr::PLAIN;
let mut at = 0usize;
let mut plain = String::new();
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
if !plain.is_empty() {
runs.push((std::mem::take(plain), sgr.span(palette)));
}
};
while at < chars.len() {
let c = chars[at];
if c == ESC {
flush(&mut plain, sgr, &mut runs);
at = skip_escape(&chars, at, |params, final_byte| {
if final_byte == 'm' {
sgr = sgr.apply(params, palette);
}
});
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
// A bare carriage return rewrites the line. One before a newline
// is the other half of a Windows line ending: it rewrites
// nothing, and it is dropped rather than kept, since that pair
// is one line break.
flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs);
at += 1;
} else if c == '\r' {
at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' {
// Everything printable, plus the two control characters that are
// layout rather than terminal commands. A stray bell or
// backspace goes for the same reason a cursor move does.
plain.push(c);
at += 1;
} else {
at += 1;
}
}
flush(&mut plain, sgr, &mut runs);
let mut out = String::new();
let mut spans = Vec::new();
for (run_text, style) in runs {
let start = out.len();
out.push_str(&run_text);
if let Some(style) = style {
spans.push((start..out.len(), style));
}
}
StyledText { text: out, spans }
}
/// Throws away everything written since the last line break, as a carriage
/// return does.
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') {
runs.push((text[..=break_at].to_string(), style));
return;
}
}
}
/// The bytes that end a CSI sequence.
fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c)
}
/// Steps over the escape sequence starting at `at`, reporting a CSI's
/// parameters and final byte. One reader for every kind, because the point
/// is to *leave* them all behind: a sequence this did not recognise would
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
/// the two-character ones.
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
let Some(&next) = chars.get(at + 1) else {
return at + 1;
};
match next {
'[' => {
let mut end = at + 2;
while end < chars.len() && !is_csi_final(chars[end]) {
end += 1;
}
if end >= chars.len() {
// Cut off mid-sequence, which is what a stream that has not
// finished arriving looks like: drop the fragment rather
// than printing it, and the whole sequence arrives with the
// next delta.
chars.len()
} else {
let params: String = chars[at + 2..end].iter().collect();
on_csi(&params, chars[end]);
end + 1
}
}
']' | 'P' | 'X' | '^' | '_' => {
// Runs to a string terminator: `ESC \`, or the bell that xterm
// allows after an OSC.
let mut end = at + 2;
while end < chars.len() {
if chars[end] == BELL {
return end + 1;
}
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
return end + 2;
}
end += 1;
}
chars.len()
}
_ => at + 2,
}
}
/// Everything an SGR sequence can turn on, as the terminal tracks it.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr {
fg: Option<Rgb>,
bg: Option<Rgb>,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
strike: bool,
reverse: bool,
}
/// How much of its colour dim text keeps: enough to read, little enough to recede.
const DIM_ALPHA: f32 = 0.65;
impl Sgr {
const PLAIN: Sgr = Sgr {
fg: None,
bg: None,
bold: false,
dim: false,
italic: false,
underline: false,
strike: false,
reverse: false,
};
/// `None` while nothing is set, so unstyled output costs no spans at all.
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN {
return None;
}
let front = if self.reverse {
Some(self.bg.unwrap_or(palette.background))
} else {
self.fg
};
let back = if self.reverse {
Some(self.fg.unwrap_or(palette.foreground))
} else {
self.bg
};
// Dim has to have a colour to dim, so where none was named it dims
// the ordinary one.
let stated = front.or(if self.dim {
Some(palette.foreground)
} else {
None
});
Some(Style {
color: stated,
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
background: back,
bold: self.bold,
italic: self.italic,
underline: self.underline,
strikethrough: self.strike,
})
}
/// This state with `params` applied -- one `ESC[...m`, which carries any
/// number of them.
///
/// A code this does not model is ignored rather than reset from: the
/// program meant something by it, and starting again would also drop
/// the codes beside it that are understood.
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
// zero too.
let codes: Vec<i64> = params
.split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
.collect();
let mut state = *self;
let mut at = 0usize;
while at < codes.len() {
let code = codes[at];
state = match code {
0 => Sgr::PLAIN,
1 => Sgr {
bold: true,
..state
},
2 => Sgr { dim: true, ..state },
3 => Sgr {
italic: true,
..state
},
4 => Sgr {
underline: true,
..state
},
7 => Sgr {
reverse: true,
..state
},
9 => Sgr {
strike: true,
..state
},
21 | 22 => Sgr {
bold: false,
dim: false,
..state
},
23 => Sgr {
italic: false,
..state
},
24 => Sgr {
underline: false,
..state
},
27 => Sgr {
reverse: false,
..state
},
29 => Sgr {
strike: false,
..state
},
30..=37 => Sgr {
fg: Some(palette.colours[(code - 30) as usize]),
..state
},
90..=97 => Sgr {
fg: Some(palette.colours[(code - 90 + 8) as usize]),
..state
},
40..=47 => Sgr {
bg: Some(palette.colours[(code - 40) as usize]),
..state
},
100..=107 => Sgr {
bg: Some(palette.colours[(code - 100 + 8) as usize]),
..state
},
39 => Sgr { fg: None, ..state },
49 => Sgr { bg: None, ..state },
38 | 48 => {
let (colour, last) = extended_colour(&codes, at, palette);
at = last;
if code == 38 {
Sgr {
fg: colour,
..state
}
} else {
Sgr {
bg: colour,
..state
}
}
}
_ => state,
};
at += 1;
}
state
}
}
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
/// last parameter.
///
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
/// one. The first sixteen of that table are the palette's own, so a program
/// asking for "colour 1" through either spelling gets the same red.
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) {
None => (None, at + 1),
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
},
Some(&2) => {
let r = codes.get(at + 2);
let g = codes.get(at + 3);
let b = codes.get(at + 4);
match (r, g, b) {
(Some(&r), Some(&g), Some(&b)) => (
Some(Rgb::new(
r.clamp(0, 255) as u8,
g.clamp(0, 255) as u8,
b.clamp(0, 255) as u8,
)),
at + 4,
),
_ => (None, at + 1),
}
}
_ => (None, at + 1),
}
}
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
/// grey ramp.
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 {
palette.foreground
} else if n < 16 {
palette.colours[n as usize]
} else if n < 232 {
let i = (n - 16) as usize;
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
} else if n < 256 {
let grey = (8 + (n - 232) * 10) as u8;
Rgb::new(grey, grey, grey)
} else {
palette.foreground
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
/// white foreground, black background.
fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() {
*c = Rgb::new(i as u8, 0, 0);
}
AnsiPalette {
colours,
foreground: Rgb::new(255, 255, 255),
background: Rgb::new(0, 0, 0),
}
}
fn styled(text: &str) -> StyledText {
ansi_styled(text, &palette())
}
/// The style covering the first character of `word`, or `None` where
/// nothing styles it.
fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text);
let at = out
.text
.find(word)
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
out.spans
.iter()
.find(|(range, _)| range.contains(&at))
.map(|(_, style)| *style)
}
#[test]
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
assert_eq!(styled(&text).text, "plain red plain");
assert_eq!(
style_over(&text, "red").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert!(style_over(&text, "plain").is_none());
}
#[test]
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
assert_eq!(
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
Some(Rgb::new(9, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
Some(Rgb::new(4, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
Some(Rgb::new(0, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
Some(Rgb::new(255, 255, 255))
);
assert_eq!(
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
.unwrap()
.color,
Some(Rgb::new(10, 20, 30))
);
}
#[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
// A cursor move, an erase, an OSC window title with its bell, and a
// bare two-character escape.
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde");
}
#[test]
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
}
#[test]
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
}
#[test]
fn unstyled_text_costs_no_spans_at_all() {
assert_eq!(styled("nothing to do here").spans.len(), 0);
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
}
}
+543
View File
@@ -0,0 +1,543 @@
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
//! module doc for the table); the SSE half is [`crate::event_stream`].
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
//! which do not.
//!
//! Network I/O sits behind the [`Transport`] trait so the rest of this
//! crate, and anything built on it, can be tested against a fake one with
//! no server involved. [`UreqTransport`] is the only real implementation.
use std::io::Read;
use serde::Deserialize;
use serde_json::Value;
/// A request that did not produce what it asked for, carrying the server's
/// own wording where it sent some.
///
/// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`.
#[derive(Debug, Clone)]
pub struct ApiError {
pub message: String,
pub status: Option<u16>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
/// A request body to send, in whichever of the two shapes the surface
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
pub enum Body {
Json(Value),
Bytes {
content_type: String,
bytes: Vec<u8>,
},
}
/// What a transport hands back for a REST call: the status and the body
/// read whole. A streamed body ([`Transport::stream`]) is a different
/// method because its whole point is not reading it whole.
pub struct RawResponse {
pub status: u16,
pub body: Vec<u8>,
}
/// The network boundary this crate's pure logic is kept out from behind.
/// `server/src/routes.rs`'s module doc is the surface this drives.
pub trait Transport: Send + Sync {
/// One request/response call -- everything but the long-lived SSE GETs.
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError>;
/// Opens `path` and answers a reader over the response body, for a
/// caller that reads it as a stream rather than all at once (the SSE
/// connections in [`crate::event_stream`]). Fails the same way
/// [`Transport::request`] does for a non-2xx response.
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
}
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
/// field means and why `setup` is never shown.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub id: String,
pub setup: String,
#[serde(default)]
pub keeps_own_transcript: bool,
pub setup_name: String,
pub provider: String,
pub title: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub permission_mode: Option<String>,
#[serde(default)]
pub imported: bool,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub context_tokens: Option<u64>,
#[serde(default)]
pub max_image_edge: Option<u32>,
pub status: String,
pub last_activity: f64,
}
fn default_true() -> bool {
true
}
/// A client-core equivalent of `requestFromServer` plus the typed calls
/// built on it. Holds no state of its own beyond the transport -- the
/// session id or setup id a call is about is a parameter, per this
/// project's "ask for the least you need".
pub struct ApiClient<T: Transport> {
transport: T,
}
impl<T: Transport> ApiClient<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
fn json_request<R: for<'de> Deserialize<'de>>(
&self,
method: &str,
path: &str,
body: Option<Value>,
) -> Result<R, ApiError> {
let raw = self.transport.request(method, path, body.map(Body::Json))?;
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
message: format!("Reached the server but couldn't read its response ({e})"),
status: Some(raw.status),
})
}
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
self.transport.request(method, path, body.map(Body::Json))?;
Ok(())
}
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
self.json_request("GET", "/sessions", None)
}
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
self.json_request("GET", &format!("/sessions/{session_id}"), None)
}
pub fn send_message(
&self,
session_id: &str,
text: &str,
attachment_ids: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/message"),
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
)
}
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/unqueue"),
Some(serde_json::json!({ "messageId": message_id })),
)
}
pub fn answer_question(
&self,
session_id: &str,
question_id: &str,
answers: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/answer"),
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
)
}
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
}
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
}
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
}
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/title"),
Some(serde_json::json!({ "title": title })),
)
}
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/cwd"),
Some(serde_json::json!({ "cwd": cwd })),
)
}
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/model"),
Some(serde_json::json!({ "model": model })),
)
}
pub fn set_session_permission_mode(
&self,
session_id: &str,
mode: &str,
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/permission-mode"),
Some(serde_json::json!({ "permissionMode": mode })),
)
}
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/notify"),
Some(serde_json::json!({ "notify": notify })),
)
}
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/command"),
Some(serde_json::json!({ "text": text })),
)
}
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
}
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
let path = if delete_foreign {
format!("/sessions/{session_id}?deleteForeign=true")
} else {
format!("/sessions/{session_id}")
};
self.empty_request("DELETE", &path, None)
}
/// A page of transcript history. `before` is the newest-first cursor
/// (server default is "the newest page" when absent, which a caller
/// gets by passing `None`); the events themselves are handed back as
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
/// out of this method's signature so a caller that only wants the raw
/// lines (for the transcript cache) is not forced to parse them.
pub fn fetch_transcript_page(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
) -> Result<Vec<Value>, ApiError> {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before {
path.push_str(&format!("&before={before}"));
}
if coalesce {
path.push_str("&coalesce=true");
}
self.json_request("GET", &path, None)
}
}
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
/// poll). Verifies the server's leaf against a single pinned CA, the way
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
/// store -- the server's certificate is self-signed on purpose (see
/// `wg-app-link`).
pub struct UreqTransport {
agent: ureq::Agent,
base_url: String,
token: String,
}
impl UreqTransport {
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
/// exactly as read from `certs/ca.pem`.
pub fn new(
base_url: impl Into<String>,
token: impl Into<String>,
ca_pem: &[u8],
) -> Result<Self, ApiError> {
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
message: format!("The pinned CA certificate could not be read: {e}"),
status: None,
})?;
let tls_config = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config)
// Read the body ourselves on every status, the way
// `requestFromServer` does: the server's own error wording is
// in the body of a 4xx/5xx, and the default behaviour throws
// it away before this code can read it.
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.build()
.into();
Ok(Self {
agent,
base_url: base_url.into(),
token: token.into(),
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
}
impl Transport for UreqTransport {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let mut builder = ureq::http::Request::builder()
.method(method)
.uri(&url)
.header("Authorization", &auth);
let response = match body {
None => builder
.body(())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req)),
Some(Body::Json(value)) => {
builder = builder.header("Content-Type", "application/json");
builder
.body(serde_json::to_vec(&value).unwrap_or_default())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
Some(Body::Bytes {
content_type,
bytes,
}) => {
builder = builder.header("Content-Type", content_type);
builder
.body(bytes)
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
};
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut body)
.map_err(|e| ApiError {
message: format!("Reached {url} but couldn't read its response ({e})"),
status: Some(status),
})?;
if !(200..300).contains(&status) {
return Err(response_error(status, &body, path));
}
Ok(RawResponse { status, body })
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let response = self
.agent
.get(&url)
.header("Authorization", &auth)
.header("Accept", "text/event-stream")
// No read timeout: between events there is nothing to read for
// as long as the thing being followed is idle, mirroring
// `EventStream.kt`'s `readTimeout = 0`.
.config()
.timeout_recv_response(None)
.build()
.call();
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
if status != 200 {
let mut body = Vec::new();
let _ = response.body_mut().as_reader().read_to_end(&mut body);
return Err(response_error(status, &body, path));
}
Ok(Box::new(response.into_body().into_reader()))
}
}
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
ApiError {
message: format!(
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
device able to reach that address (WireGuard up)? [{path}]"
),
status: None,
}
}
/// The 401 wording matches `Api.kt`'s, since that message is instructions
/// for the reader rather than a diagnostic -- see this project's UI rule
/// about shortening a failure in one place rather than at each display site.
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 {
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
rotate with --rotate-token and scan the new one)."
.to_string()
} else if detail.is_empty() {
format!("Server returned HTTP {status} for {path}")
} else {
detail
};
ApiError {
message,
status: Some(status),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::sync::Mutex;
/// A transport with no network at all, for the pure-logic tests this
/// module can run without a server.
#[derive(Default)]
struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>,
}
impl FakeTransport {
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
self.responses.lock().unwrap().push((
method.to_string(),
path.to_string(),
RawResponse {
status,
body: body.as_bytes().to_vec(),
},
));
}
}
impl Transport for FakeTransport {
fn request(
&self,
method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let mut responses = self.responses.lock().unwrap();
let index = responses
.iter()
.position(|(m, p, _)| m == method && p == path)
.ok_or_else(|| ApiError {
message: format!("no fake response for {method} {path}"),
status: None,
})?;
let (_, _, response) = responses.remove(index);
if !(200..300).contains(&response.status) {
return Err(response_error(response.status, &response.body, path));
}
Ok(response)
}
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(Vec::new())))
}
}
#[test]
fn fetch_sessions_parses_the_list() {
let transport = FakeTransport::default();
transport.respond(
"GET",
"/sessions",
200,
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
"title":"hi","status":"idle","lastActivity":1.0}]"#,
);
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop");
// Defaults for fields the server omits.
assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None);
}
#[test]
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
let client = ApiClient::new(transport);
let err = client.interrupt_session("s1").unwrap_err();
assert!(err.message.contains("Re-enroll"));
assert_eq!(err.status, Some(401));
}
#[test]
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/stop", 500, "");
let client = ApiClient::new(transport);
let err = client.stop_session("s1").unwrap_err();
assert!(err.message.contains("500"));
}
#[test]
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
let transport = FakeTransport::default();
transport.respond(
"POST",
"/sessions/s1/cwd",
409,
"that path does not exist on this machine",
);
let client = ApiClient::new(transport);
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
assert_eq!(err.message, "that path does not exist on this machine");
}
}
+142
View File
@@ -0,0 +1,142 @@
//! The SSE half of the API: one long-lived GET per open session screen,
//! replaying the transcript after a cursor and then following it live.
//! Ported from `app/.../EventStream.kt`; the framing itself is
//! [`crate::sse`].
use std::io::{BufRead, BufReader};
use event_model::SeqEvent;
use crate::api::{ApiError, Transport};
use crate::sse::SseReader;
/// The frame name the server uses to say a cursor was too far behind to
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
const RESET_EVENT: &str = "reset";
/// One frame of a session's event stream, folded from the wire shape the
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
/// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call.
pub enum StreamItem {
/// The connection was accepted; the measured moment the stream is live
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
/// event, is what clears a previous failure on screen).
Open,
/// The cursor was too far behind to continue from: everything already
/// displayed is stale, and the events that follow are a fresh window.
/// Arrives before those events, so a caller that clears on it stays in
/// order.
Reset,
/// One event, as both the raw line the transcript cache stores and the
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
/// line, so both travel together rather than being parsed twice from
/// two call sites.
Event { raw: String, event: SeqEvent },
}
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
/// each [`StreamItem`] until the connection drops or `on_item` asks to
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
/// the new cursor -- is the caller's job, same as in the Kotlin version.
pub fn follow_session_events(
transport: &dyn Transport,
session_id: &str,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let path = format!("/sessions/{session_id}/events?after={after}");
let body = transport.stream(&path)?;
if !on_item(StreamItem::Open) {
return Ok(());
}
let mut lines = BufReader::new(body).lines();
let mut reader = SseReader::new();
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
message: format!("Can't reach the server -- retrying. ({e})"),
status: None,
})? {
let Some(frame) = reader.feed_line(&line) else {
continue;
};
// A named frame carries no payload and a data frame has no name.
if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) {
return Ok(());
}
} else if !frame.data.is_empty() {
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
message: format!("The server sent an event this build couldn't parse: {e}"),
status: None,
})?;
if !on_item(StreamItem::Event {
raw: frame.data,
event,
}) {
return Ok(());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
body: &'static str,
}
impl Transport for FixtureTransport {
fn request(
&self,
_method: &str,
_path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
unimplemented!("this fixture only serves a stream")
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
}
}
#[test]
fn events_and_a_reset_frame_are_told_apart() {
let transport = FixtureTransport {
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut items = Vec::new();
follow_session_events(&transport, "s1", 0, |item| {
items.push(match item {
StreamItem::Open => "open".to_string(),
StreamItem::Reset => "reset".to_string(),
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
});
true
})
.unwrap();
assert_eq!(items, vec!["open", "reset", "event:1"]);
}
#[test]
fn the_caller_can_stop_early() {
let transport = FixtureTransport {
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut count = 0;
follow_session_events(&transport, "s1", 0, |item| {
if matches!(item, StreamItem::Event { .. }) {
count += 1;
}
count < 1
})
.unwrap();
assert_eq!(count, 1);
}
}
+581
View File
@@ -0,0 +1,581 @@
//! A language the highlighter can colour, and the data-driven [`Rules`] each
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
//! why nearly every language is a row of data read by one shared scanner,
//! with Markdown the one exception (`super::markdown`).
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
C,
Coffeescript,
Cpp,
Csharp,
Dart,
Fish,
Go,
Java,
Javascript,
Json,
Kotlin,
Markdown,
Perl,
Php,
Python,
Ron,
Ruby,
Rust,
Shell,
Swift,
Toml,
Typescript,
}
impl Language {
/// Every value, for the same exhaustiveness check the Kotlin test runs
/// (`Language.entries`).
pub const ALL: [Language; 22] = [
Language::C,
Language::Coffeescript,
Language::Cpp,
Language::Csharp,
Language::Dart,
Language::Fish,
Language::Go,
Language::Java,
Language::Javascript,
Language::Json,
Language::Kotlin,
Language::Markdown,
Language::Perl,
Language::Php,
Language::Python,
Language::Ron,
Language::Ruby,
Language::Rust,
Language::Shell,
Language::Swift,
Language::Toml,
Language::Typescript,
];
}
/// What [`super::scan`] needs to know about one language -- data, not code,
/// so that adding a language is a row here rather than a branch anywhere.
#[derive(Debug, Clone, Default)]
pub struct Rules {
/// Words drawn as keywords. Only plain words; the scanner cannot reach
/// anything else.
pub keywords: HashSet<&'static str>,
/// Tokens that open a comment running to the end of the line.
pub line_comments: Vec<&'static str>,
/// Whether `line_comments` count only at the start of a word. The shells
/// need it: `$#`, `${#x}` and `a#b` are not comments.
pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>,
/// The string forms. The longest opener that matches wins, so `"""` is
/// tried before `"`.
pub quotes: Vec<Quote>,
pub attributes: Attributes,
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label.
pub lifetimes: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockComment {
pub open: &'static str,
pub close: &'static str,
pub nests: bool,
}
/// One string form. `escapes` is whether a backslash escapes the closer
/// (and itself).
#[derive(Debug, Clone, Copy)]
pub struct Quote {
pub open: &'static str,
pub close: &'static str,
pub escapes: bool,
}
/// What opens a metadata span, of the shapes that exist across these languages.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes {
#[default]
None,
/// `@` and a word: Kotlin and Java annotations, Python decorators.
AtWord,
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
HashBracket,
/// `#` at the start of a line, to the end of it: the C preprocessor.
HashLine,
/// `[` at the start of a line through the matching `]`: a TOML table header.
LineBracket,
}
const C_STYLE: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: false,
};
const NESTING: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: true,
};
const DOUBLE: Quote = Quote {
open: "\"",
close: "\"",
escapes: true,
};
const SINGLE: Quote = Quote {
open: "'",
close: "'",
escapes: true,
};
const TRIPLE_DOUBLE: Quote = Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: true,
};
const TRIPLE_SINGLE: Quote = Quote {
open: "'''",
close: "'''",
escapes: true,
};
fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect()
}
/// The rules for one language. A `match` rather than a lazily-built map --
/// there is no once-per-process cost worth paying for in a language table
/// this small, and it sidesteps the Kotlin version's own workaround for
/// property initialization order.
pub fn rules_for(language: Language) -> Rules {
match language {
Language::C => Rules {
keywords: words(KEYWORDS_C),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Cpp => Rules {
keywords: words(KEYWORDS_CPP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Csharp => Rules {
keywords: words(KEYWORDS_CSHARP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
// `###` opens and closes a block comment and `#` opens a line one,
// which is why the scanner tries the block opener first.
Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"],
block_comment: Some(BlockComment {
open: "###",
close: "###",
nests: false,
}),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
..Default::default()
},
Language::Dart => Rules {
keywords: words(KEYWORDS_DART),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Fish => Rules {
keywords: words(KEYWORDS_FISH),
line_comments: vec!["#"],
line_comments_at_word_start: true,
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Go => Rules {
keywords: words(KEYWORDS_GO),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: false,
},
],
..Default::default()
},
Language::Java => Rules {
keywords: words(KEYWORDS_JAVA),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Javascript => Rules {
keywords: words(KEYWORDS_JAVASCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
..Default::default()
},
Language::Json => Rules {
keywords: words(KEYWORDS_JSON),
quotes: vec![DOUBLE],
..Default::default()
},
Language::Kotlin => Rules {
keywords: words(KEYWORDS_KOTLIN),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![
Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: false,
},
DOUBLE,
SINGLE,
],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Perl => Rules {
keywords: words(KEYWORDS_PERL),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Php => Rules {
keywords: words(KEYWORDS_PHP),
line_comments: vec!["//", "#"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Python => Rules {
keywords: words(KEYWORDS_PYTHON),
line_comments: vec!["#"],
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Ron => Rules {
keywords: words(KEYWORDS_RON),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashBracket,
raw_strings: true,
..Default::default()
},
Language::Ruby => Rules {
keywords: words(KEYWORDS_RUBY),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Rust => Rules {
keywords: words(KEYWORDS_RUST),
line_comments: vec!["//"],
block_comment: Some(NESTING),
// No `'` here: `lifetimes` decides when one opens a character literal.
quotes: vec![DOUBLE],
attributes: Attributes::HashBracket,
raw_strings: true,
lifetimes: true,
..Default::default()
},
Language::Shell => Rules {
keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"],
line_comments_at_word_start: true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes: vec![
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
..Default::default()
},
Language::Swift => Rules {
keywords: words(KEYWORDS_SWIFT),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Toml => Rules {
keywords: words(KEYWORDS_TOML),
line_comments: vec!["#"],
quotes: vec![
TRIPLE_DOUBLE,
Quote {
open: "'''",
close: "'''",
escapes: false,
},
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
attributes: Attributes::LineBracket,
..Default::default()
},
Language::Typescript => Rules {
keywords: words(KEYWORDS_TYPESCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
attributes: Attributes::AtWord,
..Default::default()
},
// Markdown has no token rules; see `super::markdown::scan_markdown`.
Language::Markdown => Rules::default(),
}
}
// The keyword sets. Every list below other than RON, TOML, fish and JSON
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
// Kotlin scanner replaced, so that no fence which was coloured there turns
// plain here either.
const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
void volatile while";
const KEYWORDS_CPP: &str =
"asm auto bool break case catch char class const const_cast continue default delete do
double dynamic_cast else enum explicit export extern false float for friend goto if inline
int long mutable namespace new operator private protected public register reinterpret_cast
return short signed sizeof static static_cast struct switch template this throw true try
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
const KEYWORDS_CSHARP: &str =
"abstract as base bool break byte case catch char checked class const continue decimal
default delegate do double else enum event explicit extern false finally fixed float for
foreach goto if implicit in int interface internal is lock long namespace new null object
operator out override params private protected public readonly ref return sbyte sealed short
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
unsafe ushort using virtual void volatile while";
const KEYWORDS_COFFEESCRIPT: &str =
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
default do else export extends false finally for function if import in instanceof is isnt
let loop new no not null of on or package return super switch this throw true try typeof
unless undefined var wait when with yield";
const KEYWORDS_DART: &str =
"abstract as assert async await base break case catch class const continue covariant
default deferred do dynamic else enum export extends external factory false final finally
for get if implements import in interface is late library mixin new null on operator part
required rethrow return sealed set show static super switch this throw true try var void
when with while yield";
/// fish is not in the library at all, so its fences are drawn plain today.
/// The list is the shell's own words, which is what a fish fence is mostly
/// made of.
const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source";
const KEYWORDS_GO: &str =
"break case chan const continue default defer else fallthrough false for func go goto if
import interface map package range return select struct switch true type var";
const KEYWORDS_JAVA: &str =
"abstract assert boolean break byte case catch char class const continue default do double
else enum extends final finally float for goto if implements import instanceof int interface
long native new null package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while";
const KEYWORDS_JAVASCRIPT: &str =
"async await boolean break case catch class const continue debugger default delete do else
enum export extends false finally for function if implements import in instanceof interface
let new null package private protected public return super switch this throw true try typeof
var void while with yield";
const KEYWORDS_JSON: &str = "true false null";
const KEYWORDS_KOTLIN: &str =
"actual abstract annotation as break by catch class companion const constructor continue
coroutine crossinline data delegate dynamic do else enum expect external false final finally
for fun get if import in infix inline interface internal is lazy lateinit native null object
open operator out override package private protected public reified return sealed set super
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
const KEYWORDS_PERL: &str =
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
use while xor";
const KEYWORDS_PHP: &str =
"__halt_compiler abstract and array as break callable case catch class clone const continue
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
endwhile eval exit extends final finally fn for foreach function global goto if implements
include include_once instanceof insteadof interface isset list match new or print private
protected public require require_once return static switch throw trait try unset use var
while xor yield";
const KEYWORDS_PYTHON: &str =
"False True and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with
yield";
/// RON is not in the library either; these are the words a RON file can hold.
const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str =
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
else elsif end ensure false for if in module next nil not or redo rescue retry return self
super then true undef unless until when while yield";
const KEYWORDS_RUST: &str =
"as async await break const continue crate dyn else enum extern false fn for if impl in
let loop match mod move mut pub ref return Self self static struct super trait true type
union unsafe use where while abstract become box do final macro override priv try typeof
unsized virtual yield";
const KEYWORDS_SHELL: &str =
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
test";
const KEYWORDS_SWIFT: &str =
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
let open operator private precedencegroup protocol public rethrows static struct subscript
typealias var break case catch continue default defer do else fallthrough for guard if in
repeat return throw switch where while Any as await false is nil self Self super throws true
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet";
/// TOML is not in the library; `inf` and `nan` are values rather than
/// names, like the booleans.
const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str =
"abstract as asserts await break case catch class const constructor continue debugger
default delete do else enum export extends false finally for from function get if implements
import in infer instanceof interface is keyof let module namespace new null number object
package private protected public readonly require global return set static string super
switch this throw true try type typeof undefined unique unknown var void while with yield";
/// The highlighter's language for a fence's info word, or `None` for one it
/// has no rules for. Also what `super::file_language` reads for a file's
/// extension -- one table, so a language added for fences is a language
/// added for files.
pub fn fence_language(name: Option<&str>) -> Option<Language> {
let name = name?.trim().to_lowercase();
FENCE_LANGUAGES
.iter()
.find(|(alias, _)| *alias == name)
.map(|(_, language)| *language)
}
/// The highlighter's language for a *file*, from its name.
///
/// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at
/// all -- `Makefile` -- is likewise `None`.
pub fn file_language(name: &str) -> Option<Language> {
let dot = name.rfind('.')?;
if dot < 1 {
return None;
}
fence_language(Some(&name[dot + 1..]))
}
const FENCE_LANGUAGES: &[(&str, Language)] = &[
("kotlin", Language::Kotlin),
("kt", Language::Kotlin),
("kts", Language::Kotlin),
("rust", Language::Rust),
("rs", Language::Rust),
("sh", Language::Shell),
("bash", Language::Shell),
("shell", Language::Shell),
("zsh", Language::Shell),
("console", Language::Shell),
("python", Language::Python),
("py", Language::Python),
("javascript", Language::Javascript),
("js", Language::Javascript),
("jsx", Language::Javascript),
("typescript", Language::Typescript),
("ts", Language::Typescript),
("tsx", Language::Typescript),
("java", Language::Java),
("c", Language::C),
("h", Language::C),
("cpp", Language::Cpp),
("c++", Language::Cpp),
("cc", Language::Cpp),
("hpp", Language::Cpp),
("csharp", Language::Csharp),
("cs", Language::Csharp),
("c#", Language::Csharp),
("go", Language::Go),
("golang", Language::Go),
("swift", Language::Swift),
("dart", Language::Dart),
("ruby", Language::Ruby),
("rb", Language::Ruby),
("php", Language::Php),
("perl", Language::Perl),
("pl", Language::Perl),
("coffeescript", Language::Coffeescript),
("coffee", Language::Coffeescript),
("ron", Language::Ron),
("toml", Language::Toml),
("fish", Language::Fish),
("json", Language::Json),
("markdown", Language::Markdown),
("md", Language::Markdown),
];
+681
View File
@@ -0,0 +1,681 @@
//! Markdown read into the spans that carry a colour -- a ```markdown fence
//! in a reply, and a `.md` file in the viewer. Ported from
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
//! scanner rather than a row of [`super::Rules`] (what a character means
//! depends on where it sits, not on what it is) and why an indented code
//! block is deliberately not recognised.
//!
//! Structure is read a line at a time and each line's prose left to right,
//! except the two decisions that are not: a fenced block is state carried
//! forward, and a table is found by its delimiter row, which comes after
//! the header it belongs to (the one place here that looks ahead).
use super::{Kind, Span};
/// The characters an unordered list may be bulleted with.
const BULLETS: &str = "-*+";
/// The characters a thematic break, or a setext heading's underline, can be
/// drawn with.
const RULE_MARKERS: &str = "-*_=";
/// The characters that can open emphasis, strong emphasis or a strikethrough.
const EMPHASIS: &str = "*_~";
/// Characters that end a bare URL wherever they appear, and ones only
/// trimmed off the end.
const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?";
pub fn scan_markdown(code: &str) -> Vec<Span> {
MarkdownScanner::new(code).run()
}
struct MarkdownScanner {
code: Vec<char>,
spans: Vec<Span>,
}
impl MarkdownScanner {
fn new(code: &str) -> Self {
Self {
code: code.chars().collect(),
spans: Vec::new(),
}
}
fn run(mut self) -> Vec<Span> {
let mut at = 0usize;
// The delimiter run that opened the fenced block we are inside, or
// None between them.
let mut fence: Option<Vec<char>> = None;
// Whether the row above was part of a table, which is what makes
// this one a body row.
let mut table = false;
loop {
let end = self.line_end(at);
if let Some(open) = fence.clone() {
// The content and the closing line alike: a fence is one
// block of code, and its own delimiters belong to it the
// way a string's quotes belong to the string.
self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) {
fence = None;
}
} else {
let opened = self.opens_fence(at, end);
if opened.is_some() {
table = false;
fence = opened;
} else {
table = self.row(at, end, table);
}
}
if end == self.code.len() {
break;
}
at = end + 1;
}
self.spans
}
/// The end of the line beginning at `at`: the newline, or the end of the text.
fn line_end(&self, at: usize) -> usize {
self.code[at..]
.iter()
.position(|&c| c == '\n')
.map(|p| at + p)
.unwrap_or(self.code.len())
}
/// One line that is not inside a fence, and whether the table it may be
/// part of is still open.
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) {
let indented = self.indented(start, end);
self.emit(indented, end, Kind::Mark);
return true;
}
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
if (table || header) && self.has_pipe(start, end) {
self.table_row(start, end);
return true;
}
self.structure(start, end);
false
}
/// A line of nothing but pipes, dashes, alignment colons and space, with
/// one of each needed.
fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false;
let mut pipes = false;
for at in self.indented(start, end)..end {
match self.code[at] {
'-' => dashes = true,
'|' => pipes = true,
':' | ' ' | '\t' => {}
_ => return false,
}
}
dashes && pipes
}
fn has_pipe(&self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end {
if self.code[at] == '\\' {
at += 2;
} else if self.code[at] == '|' {
return true;
} else {
at += 1;
}
}
false
}
/// A table row: the pipes are the structure, and what is between them is prose.
fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end);
let mut cell = at;
while at < end {
match self.code[at] {
'\\' => at += 2,
'|' => {
self.inline(cell, at);
self.emit(at, at + 1, Kind::Mark);
at += 1;
cell = at;
}
_ => at += 1,
}
}
self.inline(cell, end);
}
/// Spans, coalesced with the one before when they touch and agree.
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start {
return;
}
if let Some(last) = self.spans.last_mut()
&& last.kind == kind
&& last.end == start
{
last.end = end;
return;
}
self.spans.push(Span { start, end, kind });
}
/// The first character of the line at or after `start` that is not indentation.
fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
at += 1;
}
at
}
/// The run of backticks or tildes that could open or close a fence on
/// this line, or `None`.
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end);
if at == end {
return None;
}
let marker = self.code[at];
if marker != '`' && marker != '~' {
return None;
}
let mut run = at;
while run < end && self.code[run] == marker {
run += 1;
}
if run - at >= 3 { Some((at, run)) } else { None }
}
/// Draws an opening fence line and answers its delimiter, or `None` if
/// this is not one.
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String);
// The info word is what the fence is a fence *of*, which is
// metadata about the block rather than part of it.
let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec())
}
/// Whether this line closes a fence opened by `open`: the same
/// character, at least as many of them, and nothing else on the line.
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false;
};
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
return false;
}
self.indented(run_end, end) == end
}
/// One ordinary line: what its opening characters make it, and then its prose.
fn structure(&mut self, start: usize, end: usize) {
let mut at = start;
// Quote markers come before everything else and can be several
// deep, and what follows one is an ordinary line again -- a heading
// inside a quote is still a heading.
while at < end && self.code[at] == '>' {
at += 1;
self.emit(at - 1, at, Kind::Mark);
at = self.indented(at, end);
}
if at == end {
return;
}
if self.heading(at, end) || self.thematic_break(at, end) {
return;
}
let text_start = self.bullet(at, end);
self.inline(text_start, end);
}
/// `#` to `######` and a space. Without the space it is a word
/// beginning with a hash.
fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end && self.code[at] == '#' {
at += 1;
}
let depth = at - start;
if !(1..=6).contains(&depth) {
return false;
}
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
return false;
}
self.emit(start, end, Kind::Keyword);
true
}
/// A line made of one repeated rule character and nothing else.
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start];
if !RULE_MARKERS.contains(marker) {
return false;
}
let mut seen = 0usize;
for at in start..end {
let c = self.code[at];
if c == marker {
seen += 1;
} else if !c.is_whitespace() {
return false;
}
}
if seen < if marker == '=' { 1 } else { 3 } {
return false;
}
self.emit(start, end, Kind::Mark);
true
}
/// Draws a list marker if the line opens with one, and answers where
/// the item's text starts.
fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
self.emit(start, start + 1, Kind::Mark);
return self.indented(start + 1, end);
}
let mut digits = start;
while digits < end && self.code[digits].is_ascii_digit() {
digits += 1;
}
let delimiter = self.code.get(digits).copied();
if digits > start
&& (delimiter == Some('.') || delimiter == Some(')'))
&& self.space_or_end(digits + 1, end)
{
self.emit(start, digits + 1, Kind::Mark);
return self.indented(digits + 1, end);
}
start
}
fn space_or_end(&self, at: usize, end: usize) -> bool {
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
}
/// The inline forms, left to right. Every branch answers a position
/// strictly after `start` of its call, so this terminates.
fn inline(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end {
let c = self.code[at];
at = if c == '\\' {
// A backslash takes the character after it out of the
// running entirely, which is how `\*` stays an asterisk
// rather than opening emphasis.
at + 2
} else if c == '`' {
self.code_span(at, end)
} else if c == '[' {
self.link(at, at, end)
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
self.link(at, at + 1, end)
} else if c == '<' {
self.autolink(at, end)
} else if EMPHASIS.contains(c) {
self.emphasis(at, end)
} else {
self.url(at, end).unwrap_or(at + 1)
};
}
}
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start;
while open < end && self.code[open] == '`' {
open += 1;
}
let ticks = open - start;
let mut at = open;
while at < end {
if self.code[at] != '`' {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == '`' {
close += 1;
}
if close - at == ticks {
self.emit(start, close, Kind::String);
return close;
}
at = close;
}
// Nothing closes it on this line, so those were ordinary backticks.
open
}
/// `[text](destination)`, and the same with a leading `!` for an image.
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32;
let mut close = bracket;
while close < end {
match self.code[close] {
'\\' => close += 1,
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
close += 1;
}
if close >= end {
return start + 1;
}
let destination = close + 1;
if self.code.get(destination) != Some(&'(') {
return start + 1;
}
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
return start + 1;
};
let paren = destination + paren_rel;
if paren >= end {
return start + 1;
}
self.emit(start, bracket + 1, Kind::Mark);
self.inline(bracket + 1, close);
self.emit(close, destination, Kind::Mark);
self.emit(destination, paren + 1, Kind::Metadata);
paren + 1
}
/// `<https://example.com>` and `<name@example.com>`, drawn as the
/// destination they are.
fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1;
let mut addressed = false;
while at < end {
let c = self.code[at];
if c.is_whitespace() || c == '<' {
return start + 1;
}
if c == '>' {
if !addressed {
return start + 1;
}
self.emit(start, at + 1, Kind::Metadata);
return at + 1;
}
if c == ':' || c == '@' {
addressed = true;
}
at += 1;
}
start + 1
}
/// A bare `scheme://...` written in prose, or `None` if one does not
/// start here.
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) {
return None;
}
let mut scheme = start;
while scheme < end && self.code[scheme].is_alphabetic() {
scheme += 1;
}
if scheme == start || !starts_with(&self.code, scheme, "://") {
return None;
}
let body = scheme + 3;
let mut at = body;
let mut openers = 0i32;
let mut closers = 0i32;
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
if self.code[at] == '(' {
openers += 1;
} else if self.code[at] == ')' {
closers += 1;
}
at += 1;
}
while at > body {
let last = self.code[at - 1];
if URL_TRAILING.contains(last) {
at -= 1;
} else if last == ')' && closers > openers {
closers -= 1;
at -= 1;
} else {
break;
}
}
if at == body {
return None;
}
self.emit(start, at, Kind::Metadata);
Some(at)
}
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
/// all.
fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
let mut open = start;
while open < end && self.code[open] == marker {
open += 1;
}
let length = open - start;
if marker == '~' && length != 2 {
return open;
}
if length > 3 {
return open;
}
if open == end || self.code[open].is_whitespace() {
return open;
}
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
return open;
}
let mut at = open;
while at < end {
if self.code[at] == '\\' {
at += 2;
continue;
}
if self.code[at] != marker {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == marker {
close += 1;
}
let finish = at + length;
if close - at >= length
&& !self.code[at - 1].is_whitespace()
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
{
self.emit(start, finish, Kind::Literal);
return finish;
}
at = close;
}
open
}
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
#[cfg(test)]
mod tests {
use super::super::{Kind, Language, span_text, spans_of};
fn spans(code: &str, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, Language::Markdown)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
}
#[test]
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
let code = "## Layout\nissue #12 is fixed\n#hashtag";
assert_spans(code, Kind::Keyword, &["## Layout"]);
}
#[test]
fn seven_hashes_are_not_a_heading() {
assert_spans("####### deep", Kind::Keyword, &[]);
}
#[test]
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
let code = "text\n```kotlin\nval x = 1\n```\nmore";
assert_spans(code, Kind::Metadata, &["kotlin"]);
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
}
#[test]
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
let code = "````\n```\n# not a heading\n````\nafter";
assert_spans(code, Kind::Keyword, &[]);
assert_spans(
code,
Kind::String,
&["````", "```", "# not a heading", "````"],
);
}
#[test]
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
}
#[test]
fn list_markers_and_quote_markers_colour_without_their_text() {
let code = "- one\n2. two\n> quoted";
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
}
#[test]
fn a_rule_and_a_setext_underline_are_the_same_mark() {
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
}
#[test]
fn emphasis_needs_something_on_both_sides_of_it() {
assert_spans(
"**bold** and *thin*",
Kind::Literal,
&["**bold**", "*thin*"],
);
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
}
#[test]
fn an_underscore_inside_a_word_emphasises_nothing() {
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
}
#[test]
fn a_code_span_holds_a_backtick_when_opened_with_two() {
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
}
#[test]
fn an_unclosed_code_span_is_ordinary_text() {
assert_spans("a ` b", Kind::String, &[]);
}
#[test]
fn a_link_marks_its_brackets_and_colours_its_destination() {
let code = "see [the plan](PLAN.md) now";
assert_spans(code, Kind::Mark, &["[", "]"]);
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
}
#[test]
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
assert_spans(
code,
Kind::Mark,
&["|", "|", "|", "|---|---|", "|", "|", "|"],
);
}
#[test]
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
let code = "a | b\n--- | ---\nnot a row";
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
}
#[test]
fn an_autolink_colours_and_an_html_tag_does_not() {
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
assert_spans(
code,
Kind::Metadata,
&["<https://example.com>", "<a@b.com>", "http://x"],
);
}
#[test]
fn a_bare_url_gives_back_the_sentences_punctuation() {
assert_spans(
"see https://example.com/a., and ssh://host/x)",
Kind::Metadata,
&["https://example.com/a", "ssh://host/x"],
);
}
#[test]
fn a_bracket_a_url_opened_itself_stays_in_it() {
assert_spans(
"https://en.wikipedia.org/wiki/A_(b) here",
Kind::Metadata,
&["https://en.wikipedia.org/wiki/A_(b)"],
);
}
#[test]
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
assert_spans(
"[x](https://example.com)",
Kind::Metadata,
&["(https://example.com)"],
);
}
#[test]
fn a_bracket_with_no_destination_after_it_is_left_plain() {
assert_spans("an [aside] here", Kind::Mark, &[]);
}
}
+702
View File
@@ -0,0 +1,702 @@
//! `code` read once, left to right, into the spans that carry a colour.
//! Ported from `app/.../Highlighter.kt`.
//!
//! One pass with a small state -- in a comment, in a string, or in ordinary
//! code -- rather than a locator per token kind over the whole text, which
//! is what the library this replaced did and is why it found comments
//! before it knew the language: a `#` inside a shell string, a `//` inside
//! a URL and a block-comment opener inside a shell glob each commented out
//! the rest of a line that was nothing of the sort.
//!
//! Every span is produced by advancing an index forward, so the result is
//! ordered, non-overlapping and inside the code by construction. Nothing
//! here panics: an unterminated string or comment runs to the end of the
//! code, which is also what it looks like while a fence is still being
//! written.
//!
//! **Indices are char offsets, not byte offsets** -- the scanner works over
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
//! back into the text it covers.
pub mod languages;
pub mod markdown;
pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
};
/// What a span of code is, in the terms a palette has a colour for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Keyword,
String,
Literal,
Comment,
Metadata,
Punctuation,
Mark,
}
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
pub kind: Kind,
}
/// The text a [`Span`] covers, for a caller working in char indices (every
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
pub fn span_text(code: &[char], span: &Span) -> String {
code[span.start..span.end].iter().collect()
}
/// The spans `language` colours in `code` -- the one way to ask, whatever
/// the language turns out to be made of. `None` draws plain.
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
if language == Language::Markdown {
markdown::scan_markdown(code)
} else {
scan(code, &rules_for(language))
}
}
/// `code` read into the spans [`Rules`] describes. Also reachable directly
/// for a caller that already has a [`Rules`] (there is currently only one:
/// [`spans_of`]), kept public because the Kotlin original exposed it the
/// same way.
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run()
}
/// Characters coloured as punctuation, and as marks. Both sets are the ones
/// the library this replaced used.
const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&";
struct Scanner<'a> {
code: Vec<char>,
rules: &'a Rules,
spans: Vec<Span>,
at: usize,
}
impl<'a> Scanner<'a> {
fn new(code: &str, rules: &'a Rules) -> Self {
Self {
code: code.chars().collect(),
rules,
spans: Vec::new(),
at: 0,
}
}
fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() {
// Every branch that answers true has advanced `self.at`, so
// this terminates.
let consumed = self.block_comment()
|| self.line_comment()
|| self.raw_string()
|| self.character_or_lifetime()
|| self.string()
|| self.attribute()
|| self.number()
|| self.word()
|| self.single_character();
if !consumed {
self.at += 1;
}
}
self.spans
}
fn emit(&mut self, start: usize, kind: Kind) {
if self.at > start {
self.spans.push(Span {
start,
end: self.at,
kind,
});
}
}
fn starts(&self, token: &str) -> bool {
starts_with_at(&self.code, self.at, token)
}
/// Whether a line comment token here opens one; see
/// [`Rules::line_comments_at_word_start`].
fn at_word_start(&self) -> bool {
self.at == 0
|| self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1])
}
/// Whether only whitespace stands between the start of this line and here.
fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' {
if !self.code[back as usize].is_whitespace() {
return false;
}
back -= 1;
}
true
}
fn advance_to_end_of_line(&mut self) {
while self.at < self.code.len() && self.code[self.at] != '\n' {
self.at += 1;
}
}
/// From an open bracket through the one that matches it, or to the end
/// if none does.
fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32;
while self.at < self.code.len() {
match self.code[self.at] {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
self.at += 1;
if depth == 0 {
return;
}
}
}
fn block_comment(&mut self) -> bool {
let Some(comment) = self.rules.block_comment else {
return false;
};
if !self.starts(comment.open) {
return false;
}
let start = self.at;
self.at += comment.open.chars().count();
let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 {
// The closer is tried first so that a language whose two
// delimiters are the same string -- CoffeeScript's `###` --
// closes rather than nesting forever.
if self.starts(comment.close) {
depth -= 1;
self.at += comment.close.chars().count();
} else if comment.nests && self.starts(comment.open) {
depth += 1;
self.at += comment.open.chars().count();
} else {
self.at += 1;
}
}
self.emit(start, Kind::Comment);
true
}
fn line_comment(&mut self) -> bool {
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
return false;
}
if self.rules.line_comments_at_word_start && !self.at_word_start() {
return false;
}
let start = self.at;
self.advance_to_end_of_line();
self.emit(start, Kind::Comment);
true
}
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings {
return false;
}
let mut ahead = self.at;
if self.code.get(ahead) == Some(&'b') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'r') {
return false;
}
ahead += 1;
let mut hashes = 0usize;
while self.code.get(ahead) == Some(&'#') {
ahead += 1;
hashes += 1;
}
if self.code.get(ahead) != Some(&'"') {
return false;
}
let start = self.at;
let closer: String = std::iter::once('"')
.chain(std::iter::repeat_n('#', hashes))
.collect();
let closer_chars: Vec<char> = closer.chars().collect();
let closed = find_from(&self.code, ahead + 1, &closer_chars);
self.at = match closed {
Some(index) => index + closer_chars.len(),
None => self.code.len(),
};
self.emit(start, Kind::String);
true
}
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
/// literal opens nothing.
fn character_or_lifetime(&mut self) -> bool {
if !self.rules.lifetimes || self.code[self.at] != '\'' {
return false;
}
let Some(&next) = self.code.get(self.at + 1) else {
return false;
};
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
self.quoted(Quote {
open: "'",
close: "'",
escapes: true,
});
} else {
self.at += 1;
}
true
}
fn string(&mut self) -> bool {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
// than an empty string followed by a quote.
let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
quote = Some(*candidate);
}
}
let Some(quote) = quote else {
return false;
};
self.quoted(quote);
true
}
fn quoted(&mut self, quote: Quote) {
let start = self.at;
self.at += quote.open.chars().count();
while self.at < self.code.len() {
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
self.at += 2;
continue;
}
if self.starts(quote.close) {
self.at += quote.close.chars().count();
break;
}
self.at += 1;
}
self.at = self.at.min(self.code.len());
self.emit(start, Kind::String);
}
fn attribute(&mut self) -> bool {
let start = self.at;
match self.rules.attributes {
Attributes::None => return false,
Attributes::AtWord => {
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
{
return false;
}
self.at += 1;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
}
Attributes::HashBracket => {
if self.code[self.at] != '#' {
return false;
}
let mut ahead = self.at + 1;
if self.code.get(ahead) == Some(&'!') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'[') {
return false;
}
self.at = ahead;
self.advance_to_matching_bracket();
}
Attributes::HashLine => {
if self.code[self.at] != '#' || !self.at_line_start() {
return false;
}
self.advance_to_end_of_line();
}
Attributes::LineBracket => {
if self.code[self.at] != '[' || !self.at_line_start() {
return false;
}
self.advance_to_matching_bracket();
}
}
self.emit(start, Kind::Metadata);
true
}
/// A number is a run starting with a digit and carrying on through
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
/// and `3.14` without a grammar for any of them.
fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() {
return false;
}
let start = self.at;
while self.at < self.code.len() {
let c = self.code[self.at];
if c.is_alphanumeric() || c == '_' || c == '.' {
self.at += 1;
} else {
break;
}
}
self.emit(start, Kind::Literal);
true
}
fn word(&mut self) -> bool {
if !is_word_start(Some(self.code[self.at])) {
return false;
}
let start = self.at;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
let word: String = self.code[start..self.at].iter().collect();
if self.rules.keywords.contains(word.as_str()) {
self.emit(start, Kind::Keyword);
}
true
}
fn single_character(&mut self) -> bool {
let kind = if PUNCTUATION.contains(self.code[self.at]) {
Kind::Punctuation
} else if MARKS.contains(self.code[self.at]) {
Kind::Mark
} else {
return false;
};
self.at += 1;
self.emit(self.at - 1, kind);
true
}
}
fn is_word_start(c: Option<char>) -> bool {
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
}
fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Whether `code[at..]` starts with `token`, both read as chars.
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
/// The first index at or after `from` where `code` contains `needle`, or
/// `None`.
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() {
return None;
}
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
}
#[cfg(test)]
mod tests {
use super::*;
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, language)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
assert_eq!(
spans(code, language, kind),
expected.to_vec(),
"{kind:?} in: {code}"
);
}
#[test]
fn a_quoted_glob_is_one_string_not_a_comment() {
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_find_with_globs_has_no_comment_in_it() {
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
assert_spans(
code,
Language::Shell,
Kind::String,
&["'*/.git/*'", "'*.kt'"],
);
assert_spans(code, Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
let code = "curl https://example.com/x && echo done";
assert_spans(code, Language::Shell, Kind::Comment, &[]);
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
}
#[test]
fn a_url_inside_a_kotlin_string_stays_a_string() {
let code = "val url = \"https://example.com\"\nfun f() = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"https://example.com\""],
);
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
}
#[test]
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
assert_spans(code, Language::Rust, Kind::Comment, &[]);
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
}
#[test]
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
let code = "#![allow(dead_code)]\nfn f() {}";
assert_spans(
code,
Language::Rust,
Kind::Metadata,
&["#![allow(dead_code)]"],
);
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
}
#[test]
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
let code = "#include <stdio.h>\nint main() { return 0; }";
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
assert_spans(code, Language::C, Kind::Comment, &[]);
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
}
#[test]
fn a_kotlin_annotation_is_metadata() {
assert_spans(
"@Composable fun f() {}",
Language::Kotlin,
Kind::Metadata,
&["@Composable"],
);
}
#[test]
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
let code = "val c = \"#FF0000\"\nval d = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
}
#[test]
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
let code = "val a = \"don't\"\nval b = \"x\"";
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"don't\"", "\"x\""],
);
}
#[test]
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
}
#[test]
fn an_escaped_quote_is_inside_the_rust_character_literal() {
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
}
#[test]
fn a_rust_raw_string_keeps_its_inner_quotes() {
let code = "let s = r#\"a \"quoted\" b\"#;";
assert_spans(
code,
Language::Rust,
Kind::String,
&["r#\"a \"quoted\" b\"#"],
);
}
#[test]
fn a_kotlin_triple_quoted_string_is_one_string() {
assert_spans(
"val s = \"\"\"a \"b\" c\"\"\"",
Language::Kotlin,
Kind::String,
&["\"\"\"a \"b\" c\"\"\""],
);
}
#[test]
fn a_shell_single_quoted_string_takes_no_escapes() {
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
}
#[test]
fn rust_and_kotlin_nest_block_comments() {
let code = "/* a /* b */ c */ x";
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
assert_spans(
code,
Language::Kotlin,
Kind::Comment,
&["/* a /* b */ c */"],
);
}
#[test]
fn c_ends_a_block_comment_at_the_first_close() {
assert_spans(
"/* a /* b */ c */ x",
Language::C,
Kind::Comment,
&["/* a /* b */"],
);
}
#[test]
fn a_shell_comment_starts_only_at_a_word_boundary() {
let code = "${#x} $# a#b # real";
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
}
#[test]
fn a_hash_anywhere_is_a_python_comment() {
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
}
#[test]
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
}
#[test]
fn a_ron_attribute_and_its_values_colour() {
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
assert_spans(
code,
Language::Ron,
Kind::Metadata,
&["#![enable(implicit_some)]"],
);
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
}
#[test]
fn an_unknown_fence_language_is_none() {
assert_eq!(fence_language(Some("brainfuck")), None);
}
#[test]
fn every_language_the_fence_table_knows_has_a_scanner() {
for language in Language::ALL {
spans_of("x", language);
}
}
/// The scanner must never panic and must never answer a span the code
/// does not contain: the library this replaced answered a reversed
/// range here, which crashed a card, and a fence still being written is
/// an unterminated string or comment on every keystroke.
#[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [
"",
"'",
"\"",
"\"unterminated",
"/* unterminated",
"###",
"#",
"#![",
"[",
"r#\"",
"\\",
"'''",
"\"\"\"",
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
];
for language in Language::ALL {
for code in nasty {
let chars: Vec<char> = code.chars().collect();
let spans = spans_of(code, language);
for s in &spans {
assert!(
s.start <= s.end && s.end <= chars.len(),
"{language:?} answered {s:?} for {code:?}"
);
}
let mut sorted = spans.clone();
sorted.sort_by_key(|s| s.start);
assert_eq!(
spans, sorted,
"{language:?} answered spans out of order for {code:?}"
);
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
//! The app's pure logic, shared between the server and any Rust client --
//! see `CLIENT_CORE.md` at the repo root for what lives here and what does
//! not yet.
pub mod ansi;
pub mod api;
pub mod event_stream;
pub mod highlight;
pub mod sse;
pub mod transcript_cache;
pub mod transcript_fold;
pub use event_model::*;
+121
View File
@@ -0,0 +1,121 @@
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
//! `event:` lines accumulate until a blank line ends the frame, comments
//! start with `:`, and a frame is either named with no payload or a payload
//! with no name.
//!
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
//! caller here feeds lines from wherever they came from (a real connection,
//! a test fixture) and gets frames back with no I/O of its own -- which is
//! what lets this be tested with no server, per RUST.md's "pure logic
//! first" for this crate.
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub name: Option<String>,
pub data: String,
}
/// Accumulates lines into [`Frame`]s. One instance per connection --
/// `feed_line` is called for every line the transport reads (with line
/// endings already stripped), and answers a frame when a blank line closes
/// one.
#[derive(Debug, Default)]
pub struct SseReader {
data: String,
name: Option<String>,
}
impl SseReader {
pub fn new() -> Self {
Self::default()
}
/// Feeds one line (no trailing `\n`). Answers the frame this line
/// completed, if any.
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() {
let frame = Frame {
name: self.name.take(),
data: std::mem::take(&mut self.data),
};
return Some(frame);
}
return None;
}
if let Some(rest) = line.strip_prefix("data:") {
self.data.push_str(rest.trim());
} else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string());
}
// `id:`, comments -- nothing to do.
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frames(lines: &[&str]) -> Vec<Frame> {
let mut reader = SseReader::new();
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
}
#[test]
fn a_data_only_frame_has_no_name() {
assert_eq!(
frames(&["data:hello", ""]),
vec![Frame {
name: None,
data: "hello".to_string()
}]
);
}
#[test]
fn a_named_frame_with_no_payload_still_completes() {
assert_eq!(
frames(&["event:reset", ""]),
vec![Frame {
name: Some("reset".to_string()),
data: String::new()
}]
);
}
#[test]
fn a_blank_line_with_nothing_pending_yields_no_frame() {
assert_eq!(frames(&[""]), vec![]);
}
#[test]
fn a_comment_and_an_id_line_are_ignored() {
assert_eq!(
frames(&[":keepalive", "id:5", "data:hi", ""]),
vec![Frame {
name: None,
data: "hi".to_string()
}]
);
}
#[test]
fn two_frames_in_a_row_are_both_reported() {
assert_eq!(
frames(&["data:one", "", "data:two", ""]),
vec![
Frame {
name: None,
data: "one".to_string()
},
Frame {
name: None,
data: "two".to_string()
},
]
);
}
}
File diff suppressed because it is too large. Load diff
+827
View File
@@ -0,0 +1,827 @@
//! What the transcript renders: the event stream folded into displayable
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
//!
//! Events are the only data source, and there is deliberately no second
//! shape for history to drift from: a page fetched backwards, a live
//! frame, and a line read out of the transcript cache are all the same
//! events through the same fold.
//!
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
//! exists to bound how much a lazy list composes per frame, which is a
//! fact about the UI framework drawing it, not about the transcript. See
//! `CLIENT_CORE.md`.
//!
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
//! deserializes straight into [`event_model::Event`], which has no
//! `Unknown` catch-all -- an event type this build does not recognise
//! fails to parse rather than degrading to a placeholder row. Closing that
//! gap means giving `event_model::Event` its own forward-compatible
//! variant, which is a shared-model decision for both sides of the wire
//! and is deliberately left for whoever picks this up next (see
//! `CLIENT_CORE.md`).
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
/// A question this build has already asked the reader about, with what was
/// answered so far -- distinct from [`QuestionOption`], which is what could
/// be chosen.
#[derive(Debug, Clone, PartialEq)]
pub struct QuestionCard {
pub seq: u64,
pub id: String,
pub prompt: String,
pub header: Option<String>,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
pub answers: Vec<String>,
}
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
/// logic name it explicitly.
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
/// This item's identity in the list: a `Seq` for everything with no
/// identity of its own, `RunId` for a tool call (which keeps one across
/// however many calls join or leave its run), matching `TranscriptItem.key`
/// in the Kotlin original.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ItemKey {
Seq(u64),
RunId(String),
}
/// One row of the transcript, folded from [`Event`]s. See each variant's
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
/// doc only says what changed in translation.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptItem {
UserMsg {
seq: u64,
text: String,
attachments: Vec<String>,
},
AssistantMsg {
seq: u64,
text: String,
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
/// Kotlin doc for why the split it licenses matters.
settled: bool,
},
ToolRun {
seq: u64,
id: String,
run_id: String,
tool: String,
input: String,
output: String,
done: bool,
asks: Vec<QuestionCard>,
images: Vec<String>,
},
QuestionCard(QuestionCard),
ErrorMsg {
seq: u64,
message: String,
},
ImageItem {
seq: u64,
r#ref: String,
},
/// A message from another agent. `arrived` is this row's own identity
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
/// [`place_peer_note`] may set to the turn's opening seq instead.
PeerNote {
seq: u64,
from: String,
text: String,
arrived: u64,
},
CommandRow {
seq: u64,
text: String,
},
/// Placeholder for an event kind this build could not fold -- see the
/// module doc's "known gap".
Note {
seq: u64,
text: String,
},
ClearedNote {
seq: u64,
},
CompactedNote {
seq: u64,
pre_tokens: Option<u64>,
post_tokens: Option<u64>,
},
}
impl TranscriptItem {
pub fn seq(&self) -> u64 {
match self {
Self::UserMsg { seq, .. }
| Self::AssistantMsg { seq, .. }
| Self::ToolRun { seq, .. }
| Self::ErrorMsg { seq, .. }
| Self::ImageItem { seq, .. }
| Self::PeerNote { seq, .. }
| Self::CommandRow { seq, .. }
| Self::Note { seq, .. }
| Self::ClearedNote { seq }
| Self::CompactedNote { seq, .. } => *seq,
Self::QuestionCard(card) => card.seq,
}
}
pub fn key(&self) -> ItemKey {
match self {
Self::ToolRun { run_id, .. } => ItemKey::RunId(run_id.clone()),
Self::PeerNote { arrived, .. } => ItemKey::Seq(*arrived),
other => ItemKey::Seq(other.seq()),
}
}
fn as_tool_run(&self) -> Option<&str> {
match self {
Self::ToolRun { id, .. } => Some(id),
_ => None,
}
}
}
/// The run a call joins: the one it lands next to, or a new one named
/// after itself. See the Kotlin `runIdFor`'s doc for why the name, once
/// picked, never changes.
fn run_id_for(items: &[TranscriptItem], id: &str, tool: &str) -> String {
let Some(TranscriptItem::ToolRun {
run_id,
tool: previous_tool,
..
}) = items.last()
else {
return id.to_string();
};
if tool == ASK_USER_QUESTION || previous_tool == ASK_USER_QUESTION {
id.to_string()
} else {
run_id.clone()
}
}
fn update_tool(
items: &[TranscriptItem],
id: &str,
change: impl Fn(&mut TranscriptItem),
) -> Vec<TranscriptItem> {
items
.iter()
.cloned()
.map(|mut item| {
if item.as_tool_run() == Some(id) {
change(&mut item);
}
item
})
.collect()
}
/// Whether a status means the session is still doing something, mirroring
/// `sessionWorking` in `Events.kt`.
pub fn session_working(status: SessionStatus) -> bool {
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
}
/// A status saying the session stopped working is the moment its newest
/// reply is finished.
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
if session_working(status) {
return items.to_vec();
}
let Some(TranscriptItem::AssistantMsg { settled: false, .. }) = items.last() else {
return items.to_vec();
};
let mut items = items.to_vec();
if let Some(TranscriptItem::AssistantMsg { settled, .. }) = items.last_mut() {
*settled = true;
}
items
}
/// A peer message goes above the turn it started, not where it happened to
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
fn place_peer_note(
items: &[TranscriptItem],
seq: u64,
from: &str,
text: &str,
turn_start: Option<u64>,
) -> Vec<TranscriptItem> {
let Some(at) = turn_start else {
let mut items = items.to_vec();
items.push(TranscriptItem::PeerNote {
seq,
from: from.to_string(),
text: text.to_string(),
arrived: seq,
});
return items;
};
let note = TranscriptItem::PeerNote {
seq: at,
from: from.to_string(),
text: text.to_string(),
arrived: seq,
};
let Some(index) = items.iter().position(|i| i.seq() > at) else {
let mut items = items.to_vec();
items.push(note);
return items;
};
let behind = match index.checked_sub(1).and_then(|i| items.get(i)) {
Some(TranscriptItem::ToolRun { run_id, .. }) => Some(run_id.clone()),
_ => None,
};
let mut out = items[..index].to_vec();
out.push(note);
out.extend(split_run(&items[index..], behind.as_deref()));
out
}
/// The calls the note now sits in front of, renamed if they were sharing a
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun {
run_id: first_run_id,
id: first_id,
..
}) = tail.first()
else {
return tail.to_vec();
};
let Some(behind) = behind else {
return tail.to_vec();
};
if first_run_id != behind {
return tail.to_vec();
}
let run_len = tail
.iter()
.take_while(|i| matches!(i, TranscriptItem::ToolRun { run_id, .. } if run_id == behind))
.count();
let mut out: Vec<TranscriptItem> = tail[..run_len]
.iter()
.cloned()
.map(|mut item| {
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
*run_id = first_id.clone();
}
item
})
.collect();
out.extend(tail[run_len..].iter().cloned());
out
}
/// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback
/// at the parse layer).
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
let seq = entry.seq;
match &entry.event {
Event::UserMessage {
text, attachments, ..
} => {
let mut items = items.to_vec();
items.push(TranscriptItem::UserMsg {
seq,
text: text.clone(),
attachments: attachments.clone(),
});
items
}
// `MessageTaken` is folded into `UserMessage` by the manager before
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
// (a raw transcript line, say), it reads the same way.
Event::MessageTaken {
text, attachments, ..
} => {
let mut items = items.to_vec();
items.push(TranscriptItem::UserMsg {
seq,
text: text.clone(),
attachments: attachments.clone(),
});
items
}
Event::AssistantText { delta } => {
// Deltas accumulate into the message they're streaming, which
// keeps the seq of the *first* of them: a row whose identity
// changed with every delta would be a new row every frame.
// "A message growing again is not finished" -- whatever a
// status said in between -- is why this always clears
// `settled` rather than preserving it.
if let Some(TranscriptItem::AssistantMsg {
seq: first_seq,
text,
..
}) = items.last()
{
let first_seq = *first_seq;
let text = format!("{text}{delta}");
let mut items = items[..items.len() - 1].to_vec();
items.push(TranscriptItem::AssistantMsg {
seq: first_seq,
text,
settled: false,
});
items
} else {
let mut items = items.to_vec();
items.push(TranscriptItem::AssistantMsg {
seq,
text: delta.clone(),
settled: false,
});
items
}
}
Event::ToolStart { id, tool, input } => {
let run_id = run_id_for(items, id, tool);
let mut items = items.to_vec();
items.push(TranscriptItem::ToolRun {
seq,
id: id.clone(),
run_id,
tool: tool.clone(),
input: input.to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
});
items
}
Event::ToolUpdate { id, output } => update_tool(items, id, |item| {
if let TranscriptItem::ToolRun { output: out, .. } = item {
*out = output.clone();
}
}),
Event::ToolEnd { id, output } => {
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
update_tool(items, id, |item| {
if let TranscriptItem::ToolRun {
output: out, done, ..
} = item
{
*out = output.clone();
*done = true;
}
})
} else {
let run_id = run_id_for(items, id, "tool");
let mut items = items.to_vec();
items.push(TranscriptItem::ToolRun {
seq,
id: id.clone(),
run_id,
tool: "tool".to_string(),
input: String::new(),
output: output.clone(),
done: true,
asks: Vec::new(),
images: Vec::new(),
});
items
}
}
Event::Question {
id,
prompt,
header,
options,
multi_select,
about,
} => {
let card = QuestionCard {
seq,
id: id.clone(),
prompt: prompt.clone(),
header: header.clone(),
options: options.clone(),
multi_select: *multi_select,
answers: Vec::new(),
};
let about_tool = about
.as_deref()
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
if about_tool {
let about = about.clone().unwrap();
update_tool(items, &about, move |item| {
if let TranscriptItem::ToolRun { asks, .. } = item {
asks.push(card.clone());
}
})
} else {
let mut items = items.to_vec();
items.push(TranscriptItem::QuestionCard(card));
items
}
}
Event::Answered { id, answers } => items
.iter()
.cloned()
.map(|item| match item {
TranscriptItem::QuestionCard(mut card) if &card.id == id => {
card.answers = answers.clone();
TranscriptItem::QuestionCard(card)
}
TranscriptItem::ToolRun {
mut asks,
seq,
id: tid,
run_id,
tool,
input,
output,
done,
images,
} if asks.iter().any(|a| &a.id == id) => {
for ask in asks.iter_mut() {
if &ask.id == id {
ask.answers = answers.clone();
}
}
TranscriptItem::ToolRun {
seq,
id: tid,
run_id,
tool,
input,
output,
done,
asks,
images,
}
}
other => other,
})
.collect(),
Event::PeerMessage {
from,
text,
turn_start,
} => place_peer_note(items, seq, from, text, *turn_start),
Event::CommandSent { text, .. } => {
let mut items = items.to_vec();
items.push(TranscriptItem::CommandRow {
seq,
text: text.clone(),
});
items
}
// Screen-level state, not transcript rows.
Event::CommandQueued { .. }
| Event::MessageQueued { .. }
| Event::MessageDropped { .. }
| Event::Settings { .. }
| Event::UsageDelta { .. } => items.to_vec(),
Event::Status { state } => settle_reply(items, *state),
Event::Error { message } => {
let mut items = items.to_vec();
items.push(TranscriptItem::ErrorMsg {
seq,
message: message.clone(),
});
items
}
Event::Image { image, about } => {
let about_tool = about
.as_deref()
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
if about_tool {
let about = about.clone().unwrap();
let image = image.clone();
update_tool(items, &about, move |item| {
if let TranscriptItem::ToolRun { images, .. } = item {
images.push(image.clone());
}
})
} else {
let mut items = items.to_vec();
items.push(TranscriptItem::ImageItem {
seq,
r#ref: image.clone(),
});
items
}
}
Event::Cleared => {
let mut items = items.to_vec();
items.push(TranscriptItem::ClearedNote { seq });
items
}
Event::Compacted {
pre_tokens,
post_tokens,
..
} => {
let mut items = items.to_vec();
items.push(TranscriptItem::CompactedNote {
seq,
pre_tokens: *pre_tokens,
post_tokens: *post_tokens,
});
items
}
}
}
/// One row as the transcript draws it: a run of consecutive tool calls, or
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
/// `groupToolRuns` -- the Compose card rendering in that file is not part
/// of this crate.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptRow {
Single(TranscriptItem),
/// Two or more calls with nothing between them.
Tools(Vec<TranscriptItem>),
}
impl TranscriptRow {
pub fn key(&self) -> ItemKey {
match self {
Self::Single(item) => item.key(),
Self::Tools(calls) => calls[0].key(),
}
}
pub fn start_seq(&self) -> u64 {
match self {
Self::Single(item) => item.seq(),
Self::Tools(calls) => calls[0].seq(),
}
}
}
/// Runs of adjacent tool calls become one row; everything else passes
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
/// run each call names rather than by adjacency worked out here.
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
let mut rows = Vec::new();
let mut run: Vec<TranscriptItem> = Vec::new();
fn run_id_of(item: &TranscriptItem) -> Option<&str> {
match item {
TranscriptItem::ToolRun { run_id, .. } => Some(run_id),
_ => None,
}
}
let flush = |run: &mut Vec<TranscriptItem>, rows: &mut Vec<TranscriptRow>| match run.len() {
0 => {}
1 => rows.push(TranscriptRow::Single(run.drain(..).next().unwrap())),
_ => rows.push(TranscriptRow::Tools(std::mem::take(run))),
};
for item in items {
let joins = matches!(item, TranscriptItem::ToolRun { .. })
&& (run.is_empty() || run_id_of(&run[0]) == run_id_of(item));
if joins {
run.push(item.clone());
} else {
flush(&mut run, &mut rows);
if matches!(item, TranscriptItem::ToolRun { .. }) {
run.push(item.clone());
} else {
rows.push(TranscriptRow::Single(item.clone()));
}
}
}
flush(&mut run, &mut rows);
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn event(seq: u64, event: Event) -> SeqEvent {
SeqEvent {
seq,
ts: 1.0,
event,
}
}
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
events
.iter()
.fold(Vec::new(), |items, e| fold_event(&items, e))
}
#[test]
fn assistant_deltas_accumulate_into_one_message() {
let items = fold_all(&[
event(
1,
Event::AssistantText {
delta: "hel".to_string(),
},
),
event(
2,
Event::AssistantText {
delta: "lo".to_string(),
},
),
]);
assert_eq!(
items,
vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "hello".to_string(),
settled: false
}]
);
}
#[test]
fn a_status_that_stopped_working_settles_the_newest_reply() {
let items = fold_all(&[
event(
1,
Event::AssistantText {
delta: "hi".to_string(),
},
),
event(
2,
Event::Status {
state: SessionStatus::Idle,
},
),
]);
assert_eq!(
items,
vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "hi".to_string(),
settled: true
}]
);
}
#[test]
fn a_working_status_does_not_settle_anything() {
let items = fold_all(&[
event(
1,
Event::AssistantText {
delta: "hi".to_string(),
},
),
event(
2,
Event::Status {
state: SessionStatus::Running,
},
),
]);
assert_eq!(
items,
vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "hi".to_string(),
settled: false
}]
);
}
#[test]
fn adjacent_tool_calls_group_and_a_lone_one_does_not() {
let items = fold_all(&[
event(
1,
Event::ToolStart {
id: "a".to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({}),
},
),
event(
2,
Event::ToolStart {
id: "b".to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({}),
},
),
]);
let rows = group_tool_runs(&items);
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], TranscriptRow::Tools(calls) if calls.len() == 2));
let solo = fold_all(&[event(
1,
Event::ToolStart {
id: "a".to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({}),
},
)]);
let rows = group_tool_runs(&solo);
assert_eq!(rows.len(), 1);
assert!(matches!(
&rows[0],
TranscriptRow::Single(TranscriptItem::ToolRun { .. })
));
}
#[test]
fn a_tool_end_with_no_matching_start_still_draws_a_row() {
let items = fold_all(&[event(
5,
Event::ToolEnd {
id: "x".to_string(),
output: "done".to_string(),
},
)]);
assert_eq!(
items,
vec![TranscriptItem::ToolRun {
seq: 5,
id: "x".to_string(),
run_id: "x".to_string(),
tool: "tool".to_string(),
input: String::new(),
output: "done".to_string(),
done: true,
asks: Vec::new(),
images: Vec::new(),
}]
);
}
#[test]
fn a_question_about_a_tool_call_attaches_to_its_row_rather_than_drawing_its_own() {
let items = fold_all(&[
event(
1,
Event::ToolStart {
id: "a".to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({}),
},
),
event(
2,
Event::Question {
id: "q1".to_string(),
prompt: "run it?".to_string(),
header: None,
options: vec![QuestionOption::plain("yes"), QuestionOption::plain("no")],
multi_select: false,
about: Some("a".to_string()),
},
),
]);
assert_eq!(items.len(), 1);
match &items[0] {
TranscriptItem::ToolRun { asks, .. } => assert_eq!(asks.len(), 1),
other => panic!("expected a ToolRun, got {other:?}"),
}
}
#[test]
fn answering_resolves_a_bare_question_card() {
let items = fold_all(&[
event(
1,
Event::Question {
id: "q1".to_string(),
prompt: "pick one".to_string(),
header: None,
options: vec![QuestionOption::plain("a")],
multi_select: false,
about: None,
},
),
event(
2,
Event::Answered {
id: "q1".to_string(),
answers: vec!["a".to_string()],
},
),
]);
match &items[0] {
TranscriptItem::QuestionCard(card) => assert_eq!(card.answers, vec!["a".to_string()]),
other => panic!("expected a QuestionCard, got {other:?}"),
}
}
}
+107
View File
@@ -0,0 +1,107 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "event-model"
version = "0.1.0"
edition = "2024"
# The common event model, extracted from `server/session/driver.rs` and
# `session/transcript.rs` so a Rust client (`client-core`) can share one
# definition with the server instead of hand-mirroring it the way
# `app/.../Events.kt` used to. Nothing here talks to a process, a file, or a
# socket -- it is exactly the wire shape in PLAN.md's "common event model",
# plus the transcript envelope and the context-token rule three different
# readers (the pump, the transcript, and a phone folding the same events)
# have to agree on.
[dependencies]
serde = { version = "1", features = ["derive"] }
# `Event::ToolStart.input` is a tool's raw call arguments, whatever shape the
# dialect gave them -- typing it further would mean this crate knowing every
# driver's tool schema.
serde_json = { version = "1", features = ["float_roundtrip"] }
+377
View File
@@ -0,0 +1,377 @@
//! The common event model: what a driver's process turns into before it
//! touches the transcript or the phone (see `PLAN.md`'s "The common event
//! model"). Extracted from `server/src/session/driver.rs` and
//! `session/transcript.rs` on 2026-09-04 so `client-core` shares this
//! definition instead of hand-mirroring it, which is what
//! `app/.../Events.kt` used to do. `server/`'s `session::driver` module
//! re-exports everything here, so nothing downstream of it had to change.
//!
//! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`,
//! `Unqueued` and `EventSink`. Those are how *this* server runs a session,
//! not part of what a client reads off the wire.
use serde::{Deserialize, Serialize};
/// The name a session's image is stored and served under -- minted for an
/// upload or for one a tool produced, and fetched back from
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
/// renders them identically.
pub type ImageRef = String;
/// The name an upload is stored and served under: an image is
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
/// the reader attached and what the session is told. Told apart by
/// `crate::media::media_type_for`.
pub type AttachmentRef = String;
/// One choice offered in answer to a [`Event::Question`]. More than a label
/// because the reader is deciding rather than confirming: what an option
/// means, and what picking it would produce, are what decide it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuestionOption {
pub label: String,
/// A sentence about what this option means.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// A block to show as written -- a mockup, a diff, a config file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
impl QuestionOption {
pub fn plain(label: impl Into<String>) -> Self {
Self {
label: label.into(),
description: None,
preview: None,
}
}
}
/// Everything a session can tell the outside world. Every event is appended
/// to the transcript with a sequence number, then fanned out to SSE
/// subscribers, so reconnecting is just "events after seq N" -- no separate
/// history path to drift from the live one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
// `rename_all` renames the variants; `rename_all_fields` renames what is
// inside them. Both are needed and only the first is obvious: every field
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
// field went out as snake_case, the app looked for camelCase and found
// nothing, and the event still rendered -- as the "no counts reported" case,
// which is a state it is allowed to be in.
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Event {
/// What the user sent, written into the transcript by the manager (not by
/// drivers) so every device renders the conversation from one stream.
/// Recorded when the session reads it, which is what `MessageTaken` reports.
UserMessage {
/// The [`Event::MessageQueued`] this resolves, when it waited. The
/// phone has a bubble on screen for the waiting message and needs to
/// know *which* one this is, rather than matching on the text and
/// clearing the wrong one when the same thing was sent twice.
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
text: String,
/// What was attached, by the ref the files route serves. On the
/// message rather than beside it: these used to be their own `Image`
/// events just before, which left the phone deciding from adjacency
/// which message an image belonged to. `images` on disk until
/// 2026-09-03, when files joined them; the alias reads the older rows.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message accepted from the phone that the session cannot read yet.
///
/// Recorded, unlike the message itself, and that difference is the point:
/// the message belongs in the transcript where the session read it, but
/// something has to say it is waiting, and it has to be the server. The
/// phone used to remember its own outgoing messages, so leaving the
/// screen showed nothing pending when something was.
///
/// Carries no row of its own; resolved by the `UserMessage` bearing the
/// same id, as `CommandQueued` is resolved by `CommandSent`.
MessageQueued {
id: String,
text: String,
/// Carried for the same reason [`Event::UserMessage`] carries it,
/// and it matters more here: a waiting message is on screen for as
/// long as the turn runs, so its attachment has nowhere else to be.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message taken out of the queue before the session read it.
///
/// Recorded for the same reason `MessageQueued` is: the queue is the
/// server's, so what is waiting has to be answerable from the transcript
/// alone. Without it a phone that reconnects replays the `MessageQueued`
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
/// that normally does is exactly what is not coming.
///
/// Only ever sent for a message that had not been handed over; see
/// [`Unqueued::AlreadySent`].
MessageDropped {
id: String,
},
/// A driver has taken one of the user's messages and started reading it.
/// The manager turns this into the `UserMessage` above, so it never
/// reaches a phone itself.
///
/// It exists because sending and being read are not the same moment. A
/// message sent into a running turn waits, and recording it among things
/// already read puts it in the transcript above output that predates it.
MessageTaken {
/// The `MessageQueued` this answers, or `None` when it never waited.
/// Carried through onto the `UserMessage`.
id: Option<String>,
text: String,
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// Streaming assistant text; the phone renders the concatenation as
/// markdown.
AssistantText {
delta: String,
},
ToolStart {
id: String,
tool: String,
input: serde_json::Value,
},
ToolUpdate {
id: String,
output: String,
},
ToolEnd {
id: String,
output: String,
},
/// An image the session produced or was sent, saved under the session
/// dir and referenced by id; the phone fetches it by URL.
Image {
#[serde(rename = "ref")]
image: ImageRef,
/// The tool call whose result carried it, when one did. A screenshot
/// belongs under the call that took it, not floating beside it -- the
/// reader has to pair them by position otherwise, and position is
/// exactly what a page boundary breaks.
#[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>,
},
/// Anything the session needs a human for: AskUserQuestion, and
/// permission requests, are the same shape with different options.
Question {
id: String,
prompt: String,
/// A few words naming what the question is about, when the asker
/// offered one. `None` for a permission, which is about the call
/// above it.
header: Option<String>,
options: Vec<QuestionOption>,
/// Whether several options may be chosen at once. Here rather than
/// left for a phone to work out from the dialect underneath: how many
/// answers a question takes is a fact about the question, and the
/// alternative was Claude Code's tool-input schema written out a
/// second time in Kotlin, where no other dialect could reach it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
multi_select: bool,
/// The tool call this is permission for, when it is one, so a phone
/// can draw the ask on the tool's own row rather than as a second
/// card repeating its input. `None` for anything not about a tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>,
},
/// A message another agent sent this session.
///
/// Its own kind rather than a `UserMessage`, because it is not something
/// the reader said and a transcript that renders it in their voice is
/// claiming they did. It also explains what would otherwise be
/// inexplicable: a session working on something nobody here asked for.
PeerMessage {
/// The sending session's own name, which is what the reader
/// recognises it by -- the socket path it came from is not.
from: String,
text: String,
/// The seq of the `Status::Running` that opened the turn this message
/// started, so a reader can draw it above that turn.
///
/// The CLI says nothing about a peer message until the turn's
/// `result`, so the event is appended after everything it caused, and
/// an append-only transcript cannot go back and insert it. Carrying
/// the position instead keeps one order on the wire and one on screen.
///
/// Filled in by the pump, the only place that knows a seq, and only
/// where a turn was open: `None` for a message replayed by `import`,
/// which already has it in the right place.
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>,
},
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that
/// answered.
///
/// A list because a question can take several answers, and one that took
/// one is the list of length one rather than a different shape.
Answered {
id: String,
answers: Vec<String>,
},
Status {
state: SessionStatus,
},
/// What the session is set to, as the session itself reports it.
///
/// Asking for a change and having one are different things, and only this
/// is a measurement: a model name the dialect does not know, a mode it
/// refuses, or a driver whose model is fixed at startup all leave a
/// request that was sent and nothing that changed. Reporting from the
/// request put the answer on the phone before the question was answered.
///
/// Either field alone, because the two are confirmed separately.
Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
},
/// Per-turn token counts, where the dialect reports them.
UsageDelta {
/// What this turn cost: the tokens it was charged for.
tokens: u64,
/// What the model was holding when the turn ended -- see
/// [`context_tokens`].
///
/// Carried rather than summed by whoever is reading, because it is
/// not a sum: context goes *down* at a compaction and a clear, so
/// adding turns up would report a figure the session stopped being
/// true of long ago.
///
/// `None` where the dialect did not say, which every reader has to be
/// able to draw.
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<u64>,
},
/// A compaction that finished, and how much context it recovered.
///
/// The counts are the point, and a spinner is not. They are optional
/// because the record has shipped without them, and "the compaction
/// happened, we don't know by how much" is a state this has to be able to
/// say -- a plausible number would be indistinguishable from a counted one.
Compacted {
#[serde(default, skip_serializing_if = "Option::is_none")]
pre_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
post_tokens: Option<u64>,
/// What asked for it, in the dialect's own word -- `auto` when the
/// session compacted on its own. Carried rather than reduced to a bool
/// so an unrecognised trigger stays unrecognised: an automatic
/// compaction is the one worth naming, because it explains a wait
/// nobody asked for.
#[serde(default, skip_serializing_if = "Option::is_none")]
trigger: Option<String>,
},
/// A command the session was asked to run on itself, held because it
/// cannot run yet. These are not messages: `/compact` and `/rename` are
/// instructions about the session, and a session mid-turn reads a line
/// written to it as something the model should see. So they wait, and
/// this is what a phone draws while they do.
CommandQueued {
id: String,
text: String,
},
/// The same command, now handed to the session. Its [`CommandQueued`]
/// stops being pending when this arrives, matched by `id`; a command
/// that ran immediately has only this.
CommandSent {
id: String,
text: String,
},
/// The conversation was cleared: everything above this is still in the
/// record but is no longer in the session's context.
///
/// Nothing is deleted. A transcript is the thing a person scrolls back
/// through, so this is a divider, not a truncation.
///
/// **Load-bearing, not decorative.** For any driver that rebuilds its
/// conversation from the transcript, this marker decides what the model
/// is given -- dropping it, or treating it as something only the phone
/// draws, silently puts a cleared conversation back in front of the model
/// at full cost. Today `llama::conversation` is the only fold that reads
/// it, which is why this is written down rather than left to be inferred
/// from a second example that does not exist.
Cleared,
Error {
message: String,
},
}
/// How much the model was holding, from the three figures a turn reports:
/// the input side only, prompt plus both cache figures. A cached token is
/// cheaper but it is still one the model was given; output is what the turn
/// produced rather than what continuing has to carry.
///
/// One function so the definition cannot drift, because it is extracted two
/// quite different ways -- the live translators have the usage object parsed,
/// and `import::context_tokens` scans it out of a raw line without parsing.
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
input + cache_creation + cache_read
}
/// The context after `event`, given what it was before.
///
/// The whole rule in one place, because three readers need the same answer:
/// the pump keeping a live session's figure, the transcript seeding it at
/// startup, and the phone folding the same events into what it draws.
///
/// The two that *lower* it are the point. A clear takes the conversation away
/// and a compaction replaces it with a summary, so a figure measured before
/// either stopped being true at that moment -- and carrying it forward is how
/// a session that had just been cleared went on reporting the context it no
/// longer had.
///
/// `None` is "we don't know", which each of them can reach.
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
match event {
// `or`, so a turn the dialect reported no usage for leaves the last
// measurement standing: stale by a turn, which every context figure
// is, rather than wrong.
Event::UsageDelta { context, .. } => context.or(current),
Event::Compacted { post_tokens, .. } => *post_tokens,
Event::Cleared => None,
_ => current,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SessionStatus {
Idle,
Running,
AwaitingInput,
Compacting,
Exited,
/// There is a process recorded for this session and the machine will not
/// say whether it is still running.
///
/// Its own state rather than the nearest of the others, because both
/// neighbours are lies with consequences: `Exited` invites starting a
/// second process against a conversation that may already have one, and
/// `Idle` claims a session is waiting for you when nobody has checked.
Unknown,
}
/// One transcript line: an [`Event`] plus its position and time. The event
/// is flattened so the wire shape stays one flat object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeqEvent {
pub seq: u64,
/// Epoch seconds.
pub ts: f64,
#[serde(flatten)]
pub event: Event,
}
+15 -8
View File
@@ -1,11 +1,18 @@
#!/bin/sh
# Runs this repo's tests. Extra arguments are forwarded to `cargo test`,
# e.g. `./run-tests.sh transcript` to run just the transcript tests.
# Runs this repo's tests. Extra arguments are forwarded to each `cargo test`,
# e.g. `./run-tests.sh transcript` to run just the transcript tests in all
# three crates.
#
# Only `server/` has tests: it holds all the logic worth testing (event
# normalization, transcript cursors, config persistence, token auth), while
# the Android app is UI over its HTTP API. Verifying the app means running
# it -- see AGENTS.md.
# server/ holds the backend's own logic (event normalization, transcript
# cursors, config persistence, token auth); event-model is the wire shape
# server/ and client-core share; client-core is the app's pure logic held
# once instead of twice (see CLIENT_CORE.md) -- the syntax highlighter, the
# ANSI parser, the transcript cache and fold, the REST and SSE clients. It
# is not wired into the Android app yet (RUST.md), which still carries its
# own Kotlin copies and has no tests of its own to run here; verifying it
# means running it -- see AGENTS.md.
set -eu
cd "$(dirname "$0")/server"
exec cargo test "$@"
cd "$(dirname "$0")"
for crate in event-model client-core server; do
(cd "$crate" && cargo test "$@")
done
+9
View File
@@ -26,6 +26,7 @@ dependencies = [
"axum-server",
"base64 0.23.1",
"clap",
"event-model",
"libc",
"rand",
"ron",
@@ -543,6 +544,14 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "fastrand"
version = "2.5.0"
+4
View File
@@ -13,6 +13,10 @@ path = "src/main.rs"
# the RON house rules. Extracted from the two copies that had drifted --
# see that repo's README for the evidence and the bug the extraction found.
wg-app-link = { path = "../wg-app-link/server" }
# The event model, shared with `client-core` so a Rust client and this
# server read the same `Event`/`SeqEvent` rather than the app hand-mirroring
# it the way `Events.kt` used to.
event-model = { path = "../event-model" }
axum = { version = "0.8", features = ["json", "multipart"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] }
+11 -354
View File
@@ -5,345 +5,20 @@
//! and accepts the small inbound vocabulary below. The transcript, the SSE
//! stream, and the phone UI work purely in this model; nothing downstream
//! of a driver may branch on the session kind.
//!
//! The event model itself -- [`Event`], [`QuestionOption`], [`SessionStatus`],
//! [`AttachmentRef`], `ImageRef`, [`context_tokens`] and [`context_after`] --
//! moved to the `event-model` crate on 2026-09-04, so `client-core` can share
//! one definition with this server instead of a hand-kept Kotlin mirror.
//! Re-exported here so nothing downstream of this module had to change; what
//! stayed behind is the *driver* abstraction, which is how this server runs
//! a session rather than part of what a client reads off the wire.
pub use event_model::{
AttachmentRef, Event, QuestionOption, SessionStatus, context_after, context_tokens,
};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// The name a session's image is stored and served under -- minted for an
/// upload or for one a tool produced, and fetched back from
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
/// renders them identically.
pub type ImageRef = String;
/// The name an upload is stored and served under: an image is
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
/// the reader attached and what the session is told. Told apart by
/// `crate::media::media_type_for`.
pub type AttachmentRef = String;
/// One choice offered in answer to a [`Event::Question`]. More than a label
/// because the reader is deciding rather than confirming: what an option
/// means, and what picking it would produce, are what decide it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QuestionOption {
pub label: String,
/// A sentence about what this option means.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// A block to show as written -- a mockup, a diff, a config file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
impl QuestionOption {
pub fn plain(label: impl Into<String>) -> Self {
Self {
label: label.into(),
description: None,
preview: None,
}
}
}
/// Everything a session can tell the outside world. Every event is appended
/// to the transcript with a sequence number, then fanned out to SSE
/// subscribers, so reconnecting is just "events after seq N" -- no separate
/// history path to drift from the live one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
// `rename_all` renames the variants; `rename_all_fields` renames what is
// inside them. Both are needed and only the first is obvious: every field
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
// field went out as snake_case, the app looked for camelCase and found
// nothing, and the event still rendered -- as the "no counts reported" case,
// which is a state it is allowed to be in.
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Event {
/// What the user sent, written into the transcript by the manager (not by
/// drivers) so every device renders the conversation from one stream.
/// Recorded when the session reads it, which is what `MessageTaken` reports.
UserMessage {
/// The [`Event::MessageQueued`] this resolves, when it waited. The
/// phone has a bubble on screen for the waiting message and needs to
/// know *which* one this is, rather than matching on the text and
/// clearing the wrong one when the same thing was sent twice.
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
text: String,
/// What was attached, by the ref the files route serves. On the
/// message rather than beside it: these used to be their own `Image`
/// events just before, which left the phone deciding from adjacency
/// which message an image belonged to. `images` on disk until
/// 2026-09-03, when files joined them; the alias reads the older rows.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message accepted from the phone that the session cannot read yet.
///
/// Recorded, unlike the message itself, and that difference is the point:
/// the message belongs in the transcript where the session read it, but
/// something has to say it is waiting, and it has to be the server. The
/// phone used to remember its own outgoing messages, so leaving the
/// screen showed nothing pending when something was.
///
/// Carries no row of its own; resolved by the `UserMessage` bearing the
/// same id, as `CommandQueued` is resolved by `CommandSent`.
MessageQueued {
id: String,
text: String,
/// Carried for the same reason [`Event::UserMessage`] carries it,
/// and it matters more here: a waiting message is on screen for as
/// long as the turn runs, so its attachment has nowhere else to be.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message taken out of the queue before the session read it.
///
/// Recorded for the same reason `MessageQueued` is: the queue is the
/// server's, so what is waiting has to be answerable from the transcript
/// alone. Without it a phone that reconnects replays the `MessageQueued`
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
/// that normally does is exactly what is not coming.
///
/// Only ever sent for a message that had not been handed over; see
/// [`Unqueued::AlreadySent`].
MessageDropped {
id: String,
},
/// A driver has taken one of the user's messages and started reading it.
/// The manager turns this into the `UserMessage` above, so it never
/// reaches a phone itself.
///
/// It exists because sending and being read are not the same moment. A
/// message sent into a running turn waits, and recording it among things
/// already read puts it in the transcript above output that predates it.
MessageTaken {
/// The `MessageQueued` this answers, or `None` when it never waited.
/// Carried through onto the `UserMessage`.
id: Option<String>,
text: String,
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// Streaming assistant text; the phone renders the concatenation as
/// markdown.
AssistantText {
delta: String,
},
ToolStart {
id: String,
tool: String,
input: serde_json::Value,
},
ToolUpdate {
id: String,
output: String,
},
ToolEnd {
id: String,
output: String,
},
/// An image the session produced or was sent, saved under the session
/// dir and referenced by id; the phone fetches it by URL.
Image {
#[serde(rename = "ref")]
image: ImageRef,
/// The tool call whose result carried it, when one did. A screenshot
/// belongs under the call that took it, not floating beside it -- the
/// reader has to pair them by position otherwise, and position is
/// exactly what a page boundary breaks.
#[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>,
},
/// Anything the session needs a human for: AskUserQuestion, and
/// permission requests, are the same shape with different options.
Question {
id: String,
prompt: String,
/// A few words naming what the question is about, when the asker
/// offered one. `None` for a permission, which is about the call
/// above it.
header: Option<String>,
options: Vec<QuestionOption>,
/// Whether several options may be chosen at once. Here rather than
/// left for a phone to work out from the dialect underneath: how many
/// answers a question takes is a fact about the question, and the
/// alternative was Claude Code's tool-input schema written out a
/// second time in Kotlin, where no other dialect could reach it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
multi_select: bool,
/// The tool call this is permission for, when it is one, so a phone
/// can draw the ask on the tool's own row rather than as a second
/// card repeating its input. `None` for anything not about a tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>,
},
/// A message another agent sent this session.
///
/// Its own kind rather than a `UserMessage`, because it is not something
/// the reader said and a transcript that renders it in their voice is
/// claiming they did. It also explains what would otherwise be
/// inexplicable: a session working on something nobody here asked for.
PeerMessage {
/// The sending session's own name, which is what the reader
/// recognises it by -- the socket path it came from is not.
from: String,
text: String,
/// The seq of the `Status::Running` that opened the turn this message
/// started, so a reader can draw it above that turn.
///
/// The CLI says nothing about a peer message until the turn's
/// `result`, so the event is appended after everything it caused, and
/// an append-only transcript cannot go back and insert it. Carrying
/// the position instead keeps one order on the wire and one on screen.
///
/// Filled in by the pump, the only place that knows a seq, and only
/// where a turn was open: `None` for a message replayed by `import`,
/// which already has it in the right place.
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>,
},
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that
/// answered.
///
/// A list because a question can take several answers, and one that took
/// one is the list of length one rather than a different shape.
Answered {
id: String,
answers: Vec<String>,
},
Status {
state: SessionStatus,
},
/// What the session is set to, as the session itself reports it.
///
/// Asking for a change and having one are different things, and only this
/// is a measurement: a model name the dialect does not know, a mode it
/// refuses, or a driver whose model is fixed at startup all leave a
/// request that was sent and nothing that changed. Reporting from the
/// request put the answer on the phone before the question was answered.
///
/// Either field alone, because the two are confirmed separately.
Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
},
/// Per-turn token counts, where the dialect reports them.
UsageDelta {
/// What this turn cost: the tokens it was charged for.
tokens: u64,
/// What the model was holding when the turn ended -- see
/// [`context_tokens`].
///
/// Carried rather than summed by whoever is reading, because it is
/// not a sum: context goes *down* at a compaction and a clear, so
/// adding turns up would report a figure the session stopped being
/// true of long ago.
///
/// `None` where the dialect did not say, which every reader has to be
/// able to draw.
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<u64>,
},
/// A compaction that finished, and how much context it recovered.
///
/// The counts are the point, and a spinner is not. They are optional
/// because the record has shipped without them, and "the compaction
/// happened, we don't know by how much" is a state this has to be able to
/// say -- a plausible number would be indistinguishable from a counted one.
Compacted {
#[serde(default, skip_serializing_if = "Option::is_none")]
pre_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
post_tokens: Option<u64>,
/// What asked for it, in the dialect's own word -- `auto` when the
/// session compacted on its own. Carried rather than reduced to a bool
/// so an unrecognised trigger stays unrecognised: an automatic
/// compaction is the one worth naming, because it explains a wait
/// nobody asked for.
#[serde(default, skip_serializing_if = "Option::is_none")]
trigger: Option<String>,
},
/// A command the session was asked to run on itself, held because it
/// cannot run yet. These are not messages: `/compact` and `/rename` are
/// instructions about the session, and a session mid-turn reads a line
/// written to it as something the model should see. So they wait, and
/// this is what a phone draws while they do.
CommandQueued {
id: String,
text: String,
},
/// The same command, now handed to the session. Its [`CommandQueued`]
/// stops being pending when this arrives, matched by `id`; a command
/// that ran immediately has only this.
CommandSent {
id: String,
text: String,
},
/// The conversation was cleared: everything above this is still in the
/// record but is no longer in the session's context.
///
/// Nothing is deleted. A transcript is the thing a person scrolls back
/// through, so this is a divider, not a truncation.
///
/// **Load-bearing, not decorative.** For any driver that rebuilds its
/// conversation from the transcript, this marker decides what the model
/// is given -- dropping it, or treating it as something only the phone
/// draws, silently puts a cleared conversation back in front of the model
/// at full cost. Today `llama::conversation` is the only fold that reads
/// it, which is why this is written down rather than left to be inferred
/// from a second example that does not exist.
Cleared,
Error {
message: String,
},
}
/// How much the model was holding, from the three figures a turn reports:
/// the input side only, prompt plus both cache figures. A cached token is
/// cheaper but it is still one the model was given; output is what the turn
/// produced rather than what continuing has to carry.
///
/// One function so the definition cannot drift, because it is extracted two
/// quite different ways -- the live translators have the usage object parsed,
/// and `import::context_tokens` scans it out of a raw line without parsing.
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
input + cache_creation + cache_read
}
/// The context after `event`, given what it was before.
///
/// The whole rule in one place, because three readers need the same answer:
/// the pump keeping a live session's figure, the transcript seeding it at
/// startup, and the phone folding the same events into what it draws.
///
/// The two that *lower* it are the point. A clear takes the conversation away
/// and a compaction replaces it with a summary, so a figure measured before
/// either stopped being true at that moment -- and carrying it forward is how
/// a session that had just been cleared went on reporting the context it no
/// longer had.
///
/// `None` is "we don't know", which each of them can reach.
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
match event {
// `or`, so a turn the dialect reported no usage for leaves the last
// measurement standing: stale by a turn, which every context figure
// is, rather than wrong.
Event::UsageDelta { context, .. } => context.or(current),
Event::Compacted { post_tokens, .. } => *post_tokens,
Event::Cleared => None,
_ => current,
}
}
/// Something a session can be asked to do to itself.
///
/// A closed set rather than a string, because the two that are not
@@ -382,24 +57,6 @@ impl SessionCommand {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SessionStatus {
Idle,
Running,
AwaitingInput,
Compacting,
Exited,
/// There is a process recorded for this session and the machine will not
/// say whether it is still running.
///
/// Its own state rather than the nearest of the others, because both
/// neighbours are lies with consequences: `Exited` invites starting a
/// second process against a conversation that may already have one, and
/// `Idle` claims a session is waiting for you when nobody has checked.
Unknown,
}
/// What became of a request to take a queued message back.
///
/// Three states rather than a bool because the two failures are not the same
+5 -11
View File
@@ -13,20 +13,14 @@ use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use super::driver::{Event, SessionStatus, context_after};
/// One transcript line: an [`Event`] plus its position and time. The event
/// is flattened so the wire shape stays one flat object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeqEvent {
pub seq: u64,
/// Epoch seconds.
pub ts: f64,
#[serde(flatten)]
pub event: Event,
}
// `SeqEvent` moved to `event-model` on 2026-09-04 along with the rest of the
// event model, so `client-core` can read the same wire shape; re-exported
// here since every caller in this crate reaches it through this module.
pub use event_model::SeqEvent;
pub struct Transcript {
file: File,