iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9a6562dc6
commit
6d5a231f5c
100 files changed
+924
-3295
No files matched your search
@@ -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(¶ms, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
||||
//! module doc for the table); the SSE half is [`crate::client::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 event_model::SeqEvent;
|
||||
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::client::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 }
|
||||
}
|
||||
|
||||
/// The transport underneath, for a caller that needs the raw SSE
|
||||
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||
/// is the one that does.
|
||||
pub fn transport(&self) -> &T {
|
||||
&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::client::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> {
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of transcript history, each line handed back paired with the
|
||||
/// exact text it came from, and bounded below by `after` -- the shape
|
||||
/// `crate::client::transcript_source::TranscriptSource` needs to store what it
|
||||
/// fetched in the transcript cache without a second round trip to fetch
|
||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||
///
|
||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||
/// sent (key order and float literal included) rather than this
|
||||
/// crate's own idea of how to write them back out -- the cache and a
|
||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||
/// exactly what caught the `serde_json` float-rounding bug this
|
||||
/// project's `AGENTS.md` records.
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||
raw.into_iter()
|
||||
.map(|value| {
|
||||
let line = value.get().to_string();
|
||||
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
"the server sent a transcript line this build couldn't parse: {e}"
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
Ok((line, event))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||
/// parameter is written rather than keeping two copies to drift.
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> String {
|
||||
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");
|
||||
}
|
||||
if let Some(after) = after {
|
||||
path.push_str(&format!("&after={after}"));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||
//! the same text a phone would scan as a QR, with no second format
|
||||
//! invented for it (RUST.md's E4).
|
||||
//!
|
||||
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
||||
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
||||
//! desktop app, the app-private files directory on Android. **Which**
|
||||
//! directory is the only part left to the platform: the format, the file
|
||||
//! mode and the "nothing saved yet is not an error" answer are the same on
|
||||
//! both, and were written twice before this.
|
||||
//!
|
||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||
//! never is one -- only the app itself writes or reads it.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||
/// with `token` as a bearer header.
|
||||
///
|
||||
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||
/// because an app built on the machine its server runs on pins the CA at
|
||||
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||
/// iris Android client is cross-compiled in a VM and run against the
|
||||
/// host's server -- has no other way to get it. A public certificate
|
||||
/// rather than a secret, so it costs the link nothing but length.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnrolledServer {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// `#[serde(default)]` so an enrollment saved before this field
|
||||
/// existed still loads, as the enrolled server it always was.
|
||||
#[serde(default)]
|
||||
pub ca_pem: Option<String>,
|
||||
}
|
||||
|
||||
impl EnrolledServer {
|
||||
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
||||
/// does not matter; unrecognised keys are ignored). `token` is
|
||||
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
||||
/// a raw token can contain `+`, which turns into a space if left to a
|
||||
/// naive splitter.
|
||||
///
|
||||
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||
/// here, because that is what every consumer of it wants
|
||||
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||
/// at). A `ca` that does not decode fails the whole link rather than
|
||||
/// enrolling a server with no trust anchor: the link said which
|
||||
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||
/// nothing downstream could notice.
|
||||
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||
format!(
|
||||
"'{link}' has no query string (expected \
|
||||
aiapp://enroll?host=...&port=...&token=...)"
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut host = None;
|
||||
let mut port = None;
|
||||
let mut token = None;
|
||||
let mut ca = None;
|
||||
for pair in query.split('&') {
|
||||
let Some((key, value)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = percent_decode(value);
|
||||
match key {
|
||||
"host" => host = Some(value),
|
||||
"port" => port = Some(value),
|
||||
"token" => token = Some(value),
|
||||
"ca" => ca = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
ca_pem,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a `crate::client::api::UreqTransport` reaches this server.
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
|
||||
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(ca.as_bytes())
|
||||
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||
for line in body.as_bytes().chunks(64) {
|
||||
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||
pem.push('\n');
|
||||
}
|
||||
pem.push_str("-----END CERTIFICATE-----\n");
|
||||
Ok(pem)
|
||||
}
|
||||
|
||||
/// Where one client keeps the enrollment it should not have to be told
|
||||
/// about a second time. `dir` is the caller's, because that is the only
|
||||
/// part that differs by platform -- see this module's doc.
|
||||
pub struct EnrollmentStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl EnrollmentStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn file(&self) -> PathBuf {
|
||||
self.dir.join("enrollment.json")
|
||||
}
|
||||
|
||||
/// Writes `server` under `dir`, creating it if needed, and sets the
|
||||
/// file owner-only -- it carries a bearer token, the same reason
|
||||
/// `server/`'s own token store is 0600.
|
||||
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let path = self.file();
|
||||
let json = serde_json::to_vec_pretty(server)
|
||||
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||
std::fs::write(&path, json)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
|
||||
/// -- "not enrolled" is an ordinary first-run state, not a failure
|
||||
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
|
||||
/// just as well to a file that simply hasn't been written yet).
|
||||
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||
let path = self.file();
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(server))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& i + 2 < bytes.len()
|
||||
&& let Ok(byte) =
|
||||
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||
{
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_host_port_and_token() {
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
server,
|
||||
EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "abcDEF123".to_string(),
|
||||
ca_pem: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_order_does_not_matter() {
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||
.unwrap();
|
||||
assert_eq!(server.host, "example.com");
|
||||
assert_eq!(server.port, 443);
|
||||
assert_eq!(server.token, "tok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percent_encoded_token_is_decoded() {
|
||||
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||
// otherwise arrive as a space.
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||
assert_eq!(server.token, "a+b/c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_field_is_named_in_the_error() {
|
||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||
assert!(
|
||||
err.contains("token"),
|
||||
"error should name the missing field: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The CA travels as base64url of the DER and comes back out as the
|
||||
/// PEM every consumer of it wants -- the same round trip
|
||||
/// `wg_app_link::enroll::ca_param` mints.
|
||||
#[test]
|
||||
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||
let server =
|
||||
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||
.unwrap();
|
||||
let pem = server.ca_pem.expect("the link carried a CA");
|
||||
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||
assert!(
|
||||
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||
"{pem}"
|
||||
);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
pem.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect::<String>()
|
||||
)
|
||||
.unwrap(),
|
||||
der
|
||||
);
|
||||
}
|
||||
|
||||
/// A link with no `ca` is an ordinary link, not a broken one: an app
|
||||
/// that pins at build time mints and reads exactly these.
|
||||
#[test]
|
||||
fn no_ca_parameter_is_none_not_an_error() {
|
||||
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||
assert_eq!(server.ca_pem, None);
|
||||
}
|
||||
|
||||
/// The half that cannot be noticed later: a `ca` that does not decode
|
||||
/// must fail the link rather than enrolling with nothing pinned.
|
||||
#[test]
|
||||
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||
let err =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||
.unwrap_err();
|
||||
assert!(err.contains("ca"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_enrollment_reads_back_the_same() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
let server = EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "tok".to_string(),
|
||||
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||
};
|
||||
store.save(&server).unwrap();
|
||||
assert_eq!(store.load().unwrap(), Some(server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_saved_yet_is_none_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||
}
|
||||
|
||||
/// An enrollment written before `ca_pem` existed still loads.
|
||||
#[test]
|
||||
fn an_enrollment_without_a_ca_still_loads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
std::fs::create_dir_all(dir.path()).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("enrollment.json"),
|
||||
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn the_saved_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
store
|
||||
.save(&EnrolledServer {
|
||||
host: "h".to_string(),
|
||||
port: 1,
|
||||
token: "t".to_string(),
|
||||
ca_pem: None,
|
||||
})
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_named_in_the_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||
assert!(err.to_string().contains("enrollment.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_port_is_named_in_the_error() {
|
||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||
assert!(
|
||||
err.contains("port"),
|
||||
"error should name the offending field: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||
//!
|
||||
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||
//! bar, and nothing in this crate needs it yet.
|
||||
|
||||
/// A span of milliseconds, written the way somebody reads it.
|
||||
///
|
||||
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||
/// minutes. The rule has two halves, because a short span and a long one
|
||||
/// are read for different things. Under a minute the question is "roughly
|
||||
/// how long", so only the largest unit is shown and a fraction carries the
|
||||
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||
/// units are left out rather than written as zero.
|
||||
///
|
||||
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||
/// is measured in milliseconds.
|
||||
pub fn format_millis(ms: i64) -> String {
|
||||
if ms < 0 {
|
||||
return format!("-{}", format_millis(-ms));
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
if ms < 60_000 {
|
||||
let tenths = (ms + 50) / 100;
|
||||
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||
return if rest == 0 {
|
||||
format!("{whole}s")
|
||||
} else {
|
||||
format!("{whole}.{rest}s")
|
||||
};
|
||||
}
|
||||
let seconds = ms / 1000;
|
||||
[
|
||||
("d", seconds / 86_400),
|
||||
("h", seconds / 3600 % 24),
|
||||
("m", seconds / 60 % 60),
|
||||
("s", seconds % 60),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(unit, n)| format!("{n}{unit}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// `text` as a span when it is a whole number of milliseconds, and
|
||||
/// unchanged when it is not.
|
||||
pub fn format_millis_text(text: &str) -> String {
|
||||
match text.trim().parse::<i64>() {
|
||||
Ok(ms) => format_millis(ms),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two ways a span of time is written here, and the rule each of
|
||||
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||
/// both are read off a screen to make a decision, so what matters is
|
||||
/// that the shortest form that answers the question is what appears.
|
||||
#[test]
|
||||
fn under_a_minute_is_the_largest_unit_alone() {
|
||||
assert_eq!(format_millis(30), "30ms");
|
||||
assert_eq!(format_millis(999), "999ms");
|
||||
assert_eq!(format_millis(1000), "1s");
|
||||
assert_eq!(format_millis(2500), "2.5s");
|
||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||
// half than two and four.
|
||||
assert_eq!(format_millis(2460), "2.5s");
|
||||
assert_eq!(format_millis(59_900), "59.9s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||
// The figure this rule was written for: a tool timeout, which
|
||||
// arrives as milliseconds and is unreadable as 480000.
|
||||
assert_eq!(format_millis(480_000), "8m");
|
||||
assert_eq!(format_millis(60_000), "1m");
|
||||
assert_eq!(format_millis(90_000), "1m 30s");
|
||||
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||
// Empty units are left out rather than written as zero: the labels
|
||||
// say which is which, and "5d 0h 4m" is only longer.
|
||||
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||
// A timeout a tool expressed some other way is its own words,
|
||||
// passed through rather than guessed at.
|
||||
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||
assert_eq!(format_millis_text(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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::client::sse`].
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::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::client::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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
@@ -0,0 +1,680 @@
|
||||
//! 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 c in &self.code[self.indented(start, end)..end] {
|
||||
match c {
|
||||
'-' => 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 &c in &self.code[start..end] {
|
||||
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, &[]);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"###",
|
||||
"#",
|
||||
"#.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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
//! The app's own recent log, held in memory so it can be read back
|
||||
//! without `logcat`.
|
||||
//!
|
||||
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
|
||||
//! no `adb`, and Android forbids one app reading another's logcat, so
|
||||
//! nothing outside the process can recover what it wrote. The only way a
|
||||
//! line reaches her is for the app to carry its own copy. This is that
|
||||
//! copy: a bounded ring every `log::info!` in the process lands in, on top
|
||||
//! of whichever platform logger was already installed (`android_logger`,
|
||||
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
|
||||
//!
|
||||
//! Three consumers, all reading the same ring rather than each keeping
|
||||
//! their own: whatever hands the log out of the process -- on Android, the
|
||||
//! `DevLogProvider` Dev Updater queries, which reads [`LogRing::since`]
|
||||
//! and [`LogRing::newest_seq`] -- the bench app's diagnostics pane, which
|
||||
//! only counts it ([`LogRing::summary`]), and the panic hook
|
||||
//! ([`LogRing::try_tail_text`]). That is why reading does not consume: a
|
||||
//! line already handed over must still be readable, and a report taken
|
||||
//! twice must say the same thing.
|
||||
//!
|
||||
//! Nothing inlines the log into a copied report any more (2026-09-08):
|
||||
//! Dev Updater reads it directly, so a second copy on the clipboard was
|
||||
//! the same lines twice.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// How many lines a default ring holds, and how many bytes of message.
|
||||
///
|
||||
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||
/// modes are different: a flood of short lines exhausts the count, and one
|
||||
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||
/// only by bytes can be emptied by a single line.
|
||||
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// One recorded line. `seq` is assigned by the ring and only ever
|
||||
/// increases, so a reader that remembers where it got to can ask for what
|
||||
/// came after -- and a gap in the sequence is exactly the lines the bound
|
||||
/// dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub seq: u64,
|
||||
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||
/// app's rather than the receiver's: a line is timestamped when it
|
||||
/// happened, and an upload can be minutes later or never.
|
||||
pub at_ms: u64,
|
||||
pub level: log::Level,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
/// Roughly what the line costs the ring. The two `String`s dominate;
|
||||
/// the fixed fields are counted as a flat overhead so a ring of empty
|
||||
/// messages still has a bound.
|
||||
fn weight(&self) -> usize {
|
||||
self.target.len() + self.message.len() + 32
|
||||
}
|
||||
|
||||
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||
/// person skims. Time of day only -- the date is in the report's own
|
||||
/// header, and a ring never spans one.
|
||||
pub fn format(&self) -> String {
|
||||
format!(
|
||||
"{} {:<5} {}: {}",
|
||||
clock_time(self.at_ms),
|
||||
self.level,
|
||||
self.target,
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
|
||||
/// library: the only field this needs is the time of day, and dividing out
|
||||
/// the day is the whole calculation. Deliberately not local time -- the
|
||||
/// phone's offset is not knowable here, and a report that says UTC is
|
||||
/// comparable with the server's log, which is what it gets read against.
|
||||
fn clock_time(at_ms: u64) -> String {
|
||||
let ms = at_ms % 1000;
|
||||
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
secs_of_day / 3600,
|
||||
(secs_of_day % 3600) / 60,
|
||||
secs_of_day % 60,
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||
/// the app down for.
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
lines: VecDeque<LogLine>,
|
||||
bytes: usize,
|
||||
max_lines: usize,
|
||||
max_bytes: usize,
|
||||
next_seq: u64,
|
||||
/// How many lines the bounds have discarded since the ring was made.
|
||||
/// Reported rather than inferred, so "the log starts here" and "the
|
||||
/// log was cut off here" are distinguishable -- the unknown state the
|
||||
/// UI rules ask for.
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
|
||||
/// there is one per process and every holder sees the same lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||
|
||||
impl LogRing {
|
||||
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||
assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a ring with no room holds nothing"
|
||||
);
|
||||
Self(Arc::new(Mutex::new(Inner {
|
||||
lines: VecDeque::new(),
|
||||
bytes: 0,
|
||||
max_lines,
|
||||
max_bytes,
|
||||
next_seq: 0,
|
||||
dropped: 0,
|
||||
})))
|
||||
}
|
||||
|
||||
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
|
||||
/// [`DEFAULT_MAX_BYTES`].
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||
/// take the app down a second time -- the ring is a diagnostic, and
|
||||
/// losing it must not be worse than the fault it was recording.
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||
let mut guard = match self.0.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
/// Records a line, evicting the oldest until both bounds hold again.
|
||||
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||
self.with(|inner| {
|
||||
let line = LogLine {
|
||||
seq: inner.next_seq,
|
||||
at_ms: now_ms(),
|
||||
level,
|
||||
target: target.to_string(),
|
||||
message,
|
||||
};
|
||||
inner.next_seq += 1;
|
||||
inner.bytes += line.weight();
|
||||
inner.lines.push_back(line);
|
||||
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||
// the whole byte bound is kept, because dropping it would
|
||||
// leave the ring silently empty while lines were arriving.
|
||||
while inner.lines.len() > inner.max_lines
|
||||
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||
{
|
||||
if let Some(evicted) = inner.lines.pop_front() {
|
||||
inner.bytes -= evicted.weight();
|
||||
inner.dropped += 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Every line held, oldest first.
|
||||
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||
}
|
||||
|
||||
/// The lines with a sequence number at or after `seq`, oldest first,
|
||||
/// and the sequence to ask from next time. Does not consume: see this
|
||||
/// module's doc for why.
|
||||
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||
self.with(|inner| {
|
||||
let lines: Vec<LogLine> = inner
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.seq >= seq)
|
||||
.cloned()
|
||||
.collect();
|
||||
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||
(lines, next)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.with(|inner| inner.lines.len())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.with(|inner| inner.dropped)
|
||||
}
|
||||
|
||||
/// The sequence number of the newest line held, or `None` for a ring
|
||||
/// nothing has been written to.
|
||||
///
|
||||
/// What a reader needs to notice that this process **restarted**: the
|
||||
/// ring is in memory, so a new process starts again at zero, and a
|
||||
/// reader holding a cursor from the previous one would otherwise ask
|
||||
/// for lines after a number nothing will reach for hours and see
|
||||
/// nothing at all -- silently, which is worse than seeing the log
|
||||
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||
/// logged" is not a sequence number.
|
||||
pub fn newest_seq(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||
}
|
||||
|
||||
/// When the newest line was written, in unix milliseconds, or `None`
|
||||
/// for a ring nothing has been written to.
|
||||
pub fn last_at_ms(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||
}
|
||||
|
||||
/// Every line held, formatted one per line -- what `Copy report`
|
||||
/// appends.
|
||||
pub fn to_text(&self) -> String {
|
||||
self.snapshot()
|
||||
.iter()
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||
/// locked at this instant.
|
||||
///
|
||||
/// For the one caller that must not block: **the panic hook**. A panic
|
||||
/// raised while this ring's own lock was held -- an allocation failing
|
||||
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||
/// -- would deadlock the hook against the thread that is panicking,
|
||||
/// and the process would hang instead of aborting, with nothing
|
||||
/// written anywhere. Losing the context lines is the right trade
|
||||
/// against that, and `None` says which happened rather than looking
|
||||
/// like an empty log.
|
||||
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||
let guard = match self.0.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
// A poisoned lock is uncontended, so its contents are still
|
||||
// readable -- the same judgement as `with`.
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||
};
|
||||
let lines = &guard.lines;
|
||||
let from = lines.len().saturating_sub(max_lines);
|
||||
Some(
|
||||
lines
|
||||
.iter()
|
||||
.skip(from)
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// One line for a diagnostics pane: how much is held, how much was
|
||||
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||
/// wording rather than a count of zero with a made-up time, because
|
||||
/// "nothing has been logged" and "logging is not running" would
|
||||
/// otherwise look the same.
|
||||
pub fn summary(&self) -> String {
|
||||
let (len, dropped, last) = self.with(|inner| {
|
||||
(
|
||||
inner.lines.len(),
|
||||
inner.dropped,
|
||||
inner.lines.back().map(|line| line.at_ms),
|
||||
)
|
||||
});
|
||||
match last {
|
||||
None => "app log: no lines yet".to_string(),
|
||||
Some(at) => {
|
||||
let dropped = if dropped > 0 {
|
||||
format!(", {dropped} dropped")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"app log: {len} lines held{dropped}, last {}",
|
||||
clock_time(at)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a target belongs to this app or to `iris` rather than to a
|
||||
/// dependency -- `starts_with` guarded by an
|
||||
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||
/// same letters (there is no such crate today, but the check should not
|
||||
/// rely on that) is never mistaken for one of ours.
|
||||
fn is_own_target(target: &str) -> bool {
|
||||
target == "iris"
|
||||
|| target.starts_with("iris::")
|
||||
|| target == "ai_app"
|
||||
|| target.starts_with("ai_app::")
|
||||
}
|
||||
|
||||
/// Whether a line at `level` from `target` belongs in the ring, given
|
||||
/// whether tracing is on right now.
|
||||
///
|
||||
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||
/// asked for, applied once here rather than at each `debug!` call site:
|
||||
/// Info and above always ring, from anything, because a real warning or
|
||||
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||
/// from this app's own targets, and only while tracing is switched on --
|
||||
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||
/// (the process logger's own level, set once at install and unrelated to
|
||||
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||
/// control.
|
||||
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||
}
|
||||
|
||||
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
|
||||
/// logger the platform already installs, so nothing that reads the
|
||||
/// platform's log (`logcat`, a terminal) changes.
|
||||
///
|
||||
/// The inner logger is passed in rather than chosen here: `client-core`
|
||||
/// has no business depending on `android_logger` or `env_logger`, and
|
||||
/// which one is right is exactly what differs between the two platforms
|
||||
/// (the sharing rule in AGENTS.md).
|
||||
pub struct RingLogger {
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
|
||||
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
|
||||
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
|
||||
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
|
||||
/// direction"), so the platform crate that depends on both is the one
|
||||
/// that wires this closure through, the same way it already supplies
|
||||
/// `inner`.
|
||||
trace_enabled: fn() -> bool,
|
||||
}
|
||||
|
||||
impl RingLogger {
|
||||
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||
Self {
|
||||
ring,
|
||||
inner,
|
||||
trace_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for RingLogger {
|
||||
/// True for anything `log`'s own max level lets through: the ring
|
||||
/// wants everything the *inner* logger might also want, even where the
|
||||
/// platform logger would filter it out. Which lines the ring itself
|
||||
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||
self.ring
|
||||
.push(record.level(), record.target(), record.args().to_string());
|
||||
}
|
||||
if self.inner.enabled(record.metadata()) {
|
||||
self.inner.log(record);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a [`RingLogger`] as the process logger and answers the ring it
|
||||
/// records into.
|
||||
///
|
||||
/// Fails only if a logger is already installed, which is a programmer
|
||||
/// error (two initialisation paths) rather than a recoverable condition --
|
||||
/// the caller is named in the error so it is findable.
|
||||
pub fn install(
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||
log::set_max_level(max_level);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one ring this process records into.
|
||||
///
|
||||
/// **A deliberate process-global, where this project's rules otherwise say
|
||||
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||
/// has exactly one backend per process, set once, and every `log::info!`
|
||||
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||
/// would be a *second* answer to "which lines exist" -- the report would
|
||||
/// show one ring while the logger filled another, and which one a caller
|
||||
/// got would depend on how far down the call tree it was. The tests above
|
||||
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||
/// testable.
|
||||
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||
|
||||
/// The process's ring, created on first use with the default bounds.
|
||||
/// Safe to call before [`install_process_logger`] -- it will simply be
|
||||
/// empty.
|
||||
pub fn process_ring() -> &'static LogRing {
|
||||
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||
}
|
||||
|
||||
/// Installs [`process_ring`] as the recording half of the process logger,
|
||||
/// forwarding to `inner` (the platform's own logger, already configured).
|
||||
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
|
||||
/// else is shared. `trace_enabled` is the platform's own trace toggle
|
||||
/// (`iris::diagnostics::trace_enabled` on Android) -- see
|
||||
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
|
||||
/// passed in rather than called directly.
|
||||
pub fn install_process_logger(
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Level;
|
||||
|
||||
fn fill(ring: &LogRing, count: usize) {
|
||||
for n in 0..count {
|
||||
ring.push(Level::Info, "test", format!("line {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_come_back_oldest_first() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||
let ring = LogRing::new(3, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||
assert_eq!(ring.len(), 3);
|
||||
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||
// Room for 1000 lines but only a few hundred bytes.
|
||||
let ring = LogRing::new(1000, 300);
|
||||
for n in 0..10 {
|
||||
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||
}
|
||||
assert!(
|
||||
ring.len() < 10,
|
||||
"the byte bound evicted: {} held",
|
||||
ring.len()
|
||||
);
|
||||
assert!(ring.dropped() > 0);
|
||||
assert!(
|
||||
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||
"and it evicted from the old end"
|
||||
);
|
||||
}
|
||||
|
||||
/// The case the `len() > 1` guard exists for: one line larger than the
|
||||
/// whole bound must still be readable, or a ring that is over budget
|
||||
/// reads as a ring nothing was written to.
|
||||
#[test]
|
||||
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||
let ring = LogRing::new(100, 64);
|
||||
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||
assert_eq!(ring.len(), 1);
|
||||
assert_eq!(ring.dropped(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let (first, cursor) = ring.since(0);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert_eq!(cursor, 3);
|
||||
|
||||
let (none, cursor) = ring.since(cursor);
|
||||
assert!(none.is_empty(), "nothing new yet");
|
||||
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||
|
||||
ring.push(Level::Warn, "test", "later".into());
|
||||
let (more, cursor) = ring.since(cursor);
|
||||
assert_eq!(more.len(), 1);
|
||||
assert_eq!(more[0].message, "later");
|
||||
assert_eq!(cursor, 4);
|
||||
}
|
||||
|
||||
/// The restart signal: a reader that saw sequence 4 and is now told
|
||||
/// the newest is 0 knows the process is not the one it was reading.
|
||||
#[test]
|
||||
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||
fill(&ring, 5);
|
||||
assert_eq!(ring.newest_seq(), Some(4));
|
||||
|
||||
let restarted = LogRing::new(100, 1 << 20);
|
||||
fill(&restarted, 1);
|
||||
assert_eq!(
|
||||
restarted.newest_seq(),
|
||||
Some(0),
|
||||
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_does_not_consume() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
let (sent, _) = ring.since(0);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||
assert_eq!(ring.to_text().lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||
let ring = LogRing::new(1000, 1 << 20);
|
||||
fill(&ring, 200);
|
||||
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||
let lines: Vec<&str> = tail.lines().collect();
|
||||
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||
}
|
||||
|
||||
/// The whole point of the `try_`: the panic hook calls this from a
|
||||
/// thread that may already hold the ring's lock, and a blocking read
|
||||
/// there would hang the process instead of aborting it.
|
||||
#[test]
|
||||
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let held = ring.0.lock().expect("fresh ring");
|
||||
assert_eq!(ring.try_tail_text(80), None);
|
||||
drop(held);
|
||||
assert!(ring.try_tail_text(80).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||
let ring = LogRing::with_defaults();
|
||||
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||
assert_eq!(ring.last_at_ms(), None);
|
||||
assert!(ring.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||
fill(&ring, 2);
|
||||
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_formats_as_time_level_target_message() {
|
||||
let line = LogLine {
|
||||
seq: 0,
|
||||
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
|
||||
// hand rather than against another clock.
|
||||
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||
level: Level::Info,
|
||||
target: "iris::android".into(),
|
||||
message: "surface created".into(),
|
||||
}
|
||||
.format();
|
||||
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||
}
|
||||
|
||||
/// The forwarding half: a line reaches the ring *and* the logger the
|
||||
/// platform already had, and one the inner logger filters out is still
|
||||
/// in the ring.
|
||||
#[test]
|
||||
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||
use log::Log;
|
||||
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||
impl Log for Collect {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= self.1
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.lock().unwrap().push(record.args().to_string());
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let ring = LogRing::with_defaults();
|
||||
// Own target, tracing on: this is the case where the ring and the
|
||||
// inner logger disagree, which is the thing under test -- a
|
||||
// foreign target is covered separately below.
|
||||
let logger = RingLogger::new(
|
||||
ring.clone(),
|
||||
Box::new(Collect(seen.clone(), Level::Info)),
|
||||
|| true,
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("kept"))
|
||||
.level(Level::Info)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("filtered"))
|
||||
.level(Level::Debug)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
["kept"],
|
||||
"the inner logger's own filter still applies"
|
||||
);
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["kept", "filtered"],
|
||||
"own-target debug still rings while tracing is on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
|
||||
/// unconditionally, and used to flood the ring even though nothing in
|
||||
/// this app asked for their Debug output. A foreign target's Debug
|
||||
/// line must not ring even while tracing is on -- tracing controls
|
||||
/// this app's own diagnostics, not a dependency's chatter.
|
||||
#[test]
|
||||
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||
use log::Log;
|
||||
struct Discard;
|
||||
impl Log for Discard {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, _: &log::Record) {}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let ring = LogRing::with_defaults();
|
||||
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga debug spam"))
|
||||
.level(Level::Debug)
|
||||
.target("naga::front")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga warning"))
|
||||
.level(Level::Warn)
|
||||
.target("wgpu_core::device")
|
||||
.build(),
|
||||
);
|
||||
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["naga warning"],
|
||||
"Info-and-above always rings; foreign Debug never does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||
assert!(
|
||||
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||
"Info+ from anything, tracing off"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Warn, "jni", true),
|
||||
"Info+ from anything, tracing on"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "jni", true),
|
||||
"foreign Debug, tracing on: still excluded"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||
"own Debug, tracing off: excluded"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Debug, "iris::sense", true),
|
||||
"own Debug, tracing on: included"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Trace, "ai_app::api", true),
|
||||
"own Trace, tracing on: included"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||
assert!(is_own_target("iris"));
|
||||
assert!(is_own_target("iris::sense"));
|
||||
assert!(is_own_target("ai_app"));
|
||||
assert!(is_own_target("ai_app::log_ring"));
|
||||
assert!(!is_own_target("iris_something_else"));
|
||||
assert!(!is_own_target("naga::front"));
|
||||
assert!(!is_own_target("jni"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Split a markdown message into its top-level **blocks** -- one
|
||||
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||
//! byte slice of the original source.
|
||||
//!
|
||||
//! This exists for streaming. A transcript row used to be one text widget
|
||||
//! holding the whole message, so a single streamed delta re-shaped every
|
||||
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||
//! block now, and a delta that lands in the last block leaves every
|
||||
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||
//! what that rejected and why the split lives here rather than in the UI
|
||||
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||
//! keeping it here means iris stays a text renderer that knows nothing
|
||||
//! about markdown.
|
||||
//!
|
||||
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||
//! renderer's own job, per block -- this deliberately does not build a
|
||||
//! full AST, because nothing needs one yet.
|
||||
//!
|
||||
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||
//!
|
||||
//! It nearly always does, which is what makes the fast path worth having,
|
||||
//! but markdown has no such rule: appending a "```" line can turn text
|
||||
//! that was three paragraphs into one fenced block, and appending "---"
|
||||
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||
//! taking the O(last block) path **must compare the prefix it is about to
|
||||
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||
//! it is cheap next to laying the text out again.
|
||||
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
|
||||
/// What a block is, for a renderer that wants to style or space blocks
|
||||
/// differently. `Other` is deliberately present rather than a panic or a
|
||||
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||
/// list and more get added, and a renderer treating an unknown one as
|
||||
/// prose is right, but it should be able to *tell* that is what it is
|
||||
/// doing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading,
|
||||
/// A fenced or indented code block.
|
||||
Code,
|
||||
List,
|
||||
Table,
|
||||
Quote,
|
||||
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||
/// distinguished treatment here.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One top-level block: its kind and the exact source that produced it.
|
||||
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||
/// two splits of the same prefix compare equal even when one of them had a
|
||||
/// delta arriving after it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn kind_of(tag: &Tag) -> BlockKind {
|
||||
match tag {
|
||||
Tag::Paragraph => BlockKind::Paragraph,
|
||||
Tag::Heading { .. } => BlockKind::Heading,
|
||||
Tag::CodeBlock(_) => BlockKind::Code,
|
||||
Tag::List(_) => BlockKind::List,
|
||||
Tag::Table(_) => BlockKind::Table,
|
||||
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||
_ => BlockKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||
// boundary here and the styling there cannot disagree about what the
|
||||
// source means.
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||
/// whitespace-only input gives no blocks; text the parser does not put
|
||||
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||
/// as `Other`, rather than being dropped.
|
||||
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut kind = BlockKind::Other;
|
||||
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(tag) => {
|
||||
if depth == 0 {
|
||||
kind = kind_of(&tag);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
push(&mut out, kind, &src[range]);
|
||||
}
|
||||
}
|
||||
// A top-level event that is not part of any block -- a
|
||||
// thematic break, a block of raw HTML. Inside one, it is the
|
||||
// enclosing block's business and this does nothing.
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
push(&mut out, BlockKind::Other, &src[range]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||
let source = source.trim_end();
|
||||
if source.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push(Block {
|
||||
kind,
|
||||
source: source.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||
/// this is a comparison rather than an assumption.
|
||||
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_splits_into_its_top_level_blocks() {
|
||||
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![
|
||||
BlockKind::Heading,
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Code,
|
||||
BlockKind::List
|
||||
]
|
||||
);
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks[1].source, "First para.");
|
||||
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_input_has_no_blocks() {
|
||||
assert!(split_blocks("").is_empty());
|
||||
assert!(split_blocks(" \n\n ").is_empty());
|
||||
}
|
||||
|
||||
/// The property the streaming fast path rests on, in its ordinary
|
||||
/// shape: a delta landing in the last paragraph must leave every
|
||||
/// earlier block byte-identical.
|
||||
#[test]
|
||||
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(before.len(), 3);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_ne!(before[2], after[2]);
|
||||
}
|
||||
|
||||
/// A delta that starts a *new* block keeps every old block, including
|
||||
/// the one that was last -- so the fast path appends rather than
|
||||
/// replacing.
|
||||
#[test]
|
||||
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||
let before = split_blocks("First para.\n\nSecond para.");
|
||||
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(after.len(), 3);
|
||||
}
|
||||
|
||||
/// A code fence arrives one delta at a time and is unterminated for
|
||||
/// most of its life. It must still be *one* block the whole way, or
|
||||
/// every delta would re-split the message into a different number of
|
||||
/// pieces.
|
||||
#[test]
|
||||
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||
for src in [
|
||||
"Here:\n\n```rust\n",
|
||||
"Here:\n\n```rust\nfn main() {\n",
|
||||
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||
"{src:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The half the fast path had no reason to touch, and the reason
|
||||
/// `common_prefix` is a comparison rather than an assumption:
|
||||
/// appending can rewrite what came before. `---` under a paragraph
|
||||
/// turns that paragraph into a setext heading, so the block that was
|
||||
/// already laid out is not the block it is now.
|
||||
#[test]
|
||||
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||
let before = split_blocks("Not a heading\n\nsecond");
|
||||
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||
assert_eq!(
|
||||
common_prefix(&before, &after),
|
||||
1,
|
||||
"the rewritten block must not be reported as keepable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thematic_break_is_its_own_block() {
|
||||
assert_eq!(
|
||||
kinds("one\n\n---\n\ntwo"),
|
||||
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// The shapes a real transcript actually contains, each checked for
|
||||
/// the one property the streaming fast path needs: the *number* of
|
||||
/// blocks and every earlier block's source stay put while the message
|
||||
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||
/// list and a table are all places where a naive line-based split
|
||||
/// would break the message into more pieces than there are blocks.
|
||||
#[test]
|
||||
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||
assert_eq!(
|
||||
kinds(fence_with_blanks),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||
"a blank line inside a fence is not a block boundary"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("```\n---\n```"),
|
||||
vec![BlockKind::Code],
|
||||
"a thematic break inside a fence is code, not a break"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("- a\n - a1\n - a2\n- b"),
|
||||
vec![BlockKind::List],
|
||||
"a nested list is one top-level block"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("## Heading\n```sh\nls\n```"),
|
||||
vec![BlockKind::Heading, BlockKind::Code],
|
||||
"a fence directly under a heading, with no blank line"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||
vec![BlockKind::Table]
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("> quoted\n> more\n\nplain"),
|
||||
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_delta`'s precondition, stated as the property rather than
|
||||
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||
/// the blocks before the last one must be exactly the blocks the
|
||||
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||
/// above), `common_prefix` has to *say* so -- which is what the
|
||||
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||
/// would keep a widget whose text is no longer what it holds.
|
||||
#[test]
|
||||
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||
// Every character boundary, so a delta landing mid-word and one
|
||||
// landing exactly on a fence's closing backtick are both covered.
|
||||
let mut prev = Vec::new();
|
||||
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||
let now = split_blocks(&full[..end]);
|
||||
let common = common_prefix(&prev, &now);
|
||||
assert!(
|
||||
prev.is_empty() || common + 1 >= prev.len(),
|
||||
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||
prev.len()
|
||||
);
|
||||
prev = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// The half a growing message cannot show: a fence that never closes.
|
||||
/// The stream ends there and the block must still be the code block
|
||||
/// it has been all along, not re-split into paragraphs.
|
||||
#[test]
|
||||
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(
|
||||
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||
);
|
||||
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||
}
|
||||
|
||||
/// A delta that closes a fence changes the *last* block only, so the
|
||||
/// fast path takes it -- the case the module doc says is the reason
|
||||
/// `common_prefix` is a comparison.
|
||||
#[test]
|
||||
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||
assert_eq!(before.len(), after.len());
|
||||
assert_eq!(common_prefix(&before, &after), 1);
|
||||
assert_ne!(before[1], after[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The app's pure logic, shared between the server and any Rust client --
|
||||
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
||||
//! not yet.
|
||||
|
||||
pub mod ansi;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod log_ring;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod text_cap;
|
||||
pub mod tool_summary;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||
//! places, never both" describes. Ported from the parsing half of
|
||||
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||
//! `Notification`/`NotificationKind`).
|
||||
//!
|
||||
//! What is deliberately **not** here, because it is a decision rather than
|
||||
//! logic: whether a given notification is shown at all (the session on
|
||||
//! screen gets nothing), handed to the app as a banner, or posted to the
|
||||
//! platform's own notification drawer. That three-way choice reads
|
||||
//! process-wide state (what screen is open, whether the app is in front)
|
||||
//! that has no meaning to a pure crate with no UI and no Android in it --
|
||||
//! see `android-shell` for where it lives for this port.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::sse::SseReader;
|
||||
|
||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
||||
/// `Notification` field for field.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNotification {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub kind: NotificationKind,
|
||||
/// Epoch seconds, so a phone that was asleep can say how long ago.
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
||||
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
||||
/// `"finished"` directly rather than through a string match.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NotificationKind {
|
||||
AwaitingInput,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl NotificationKind {
|
||||
/// What a notification asks of the reader, in the words they see --
|
||||
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
|
||||
/// function because the same fact is shown in two places (the
|
||||
/// platform's drawer and the app's own banner) and two mappings of one
|
||||
/// word drift.
|
||||
pub fn attention_line(self) -> &'static str {
|
||||
match self {
|
||||
NotificationKind::AwaitingInput => "Waiting for you",
|
||||
NotificationKind::Finished => "Finished",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows `/notifications`, calling `on_notification` for each frame until
|
||||
/// the connection drops or the callback asks to stop (by returning
|
||||
/// `false`). Reconnecting is the caller's job -- mirroring
|
||||
/// `NotificationService.follow`'s retry loop, which is a platform policy
|
||||
/// (how long to wait, whether to give up) rather than parsing logic.
|
||||
pub fn follow_notifications(
|
||||
transport: &dyn Transport,
|
||||
mut on_notification: impl FnMut(SessionNotification) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let body = transport.stream("/notifications")?;
|
||||
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;
|
||||
};
|
||||
if frame.data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let notification: SessionNotification =
|
||||
serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||
message: format!("The server sent a notification this build couldn't parse: {e}"),
|
||||
status: None,
|
||||
})?;
|
||||
if !on_notification(notification) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::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 a_notification_frame_parses_both_kinds() {
|
||||
let transport = FixtureTransport {
|
||||
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
|
||||
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
follow_notifications(&transport, |n| {
|
||||
seen.push((n.session_id, n.kind));
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![
|
||||
("s1".to_string(), NotificationKind::AwaitingInput),
|
||||
("s2".to_string(), NotificationKind::Finished),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caller_can_stop_early() {
|
||||
let transport = FixtureTransport {
|
||||
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
|
||||
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||
};
|
||||
let mut count = 0;
|
||||
follow_notifications(&transport, |_| {
|
||||
count += 1;
|
||||
count < 1
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_line_matches_the_kotlin_original() {
|
||||
assert_eq!(
|
||||
NotificationKind::AwaitingInput.attention_line(),
|
||||
"Waiting for you"
|
||||
);
|
||||
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! How much of a long thing a transcript draws before offering the rest
|
||||
//! behind a tap.
|
||||
//!
|
||||
//! One rule, four surfaces: a tool call's input, its output, and a user or
|
||||
//! assistant message. It lives here rather than at any one of them because
|
||||
//! four copies would eventually disagree about what "too long" is, and
|
||||
//! because the Compose app has to answer the same question the same way --
|
||||
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
|
||||
//! same numbers so a benchmark comparing the apps is comparing renderers
|
||||
//! rather than policies.
|
||||
//!
|
||||
//! **Lines and bytes both, whichever runs out first**, because they run
|
||||
//! out on different things: a diff is thousands of short lines, a minified
|
||||
//! file or a base64 blob is one enormous one, and a cap that only counted
|
||||
//! one of them draws the whole of the other.
|
||||
//!
|
||||
//! **Cut at the head, keeping the beginning.** A tool's output is read
|
||||
//! from the top and the line saying what went wrong is nearly always the
|
||||
//! first; a message is read from the top for the obvious reason. (A path
|
||||
//! is identified by its other end -- none of these is a path.)
|
||||
|
||||
/// The default bound on a verbatim block -- a tool call's input or its
|
||||
/// output. Short, because this text is a machine's and the reader is
|
||||
/// looking for one line of it.
|
||||
pub const VERBATIM_LINES: usize = 80;
|
||||
pub const VERBATIM_BYTES: usize = 4096;
|
||||
|
||||
/// The bound on a message, a person's or the model's. Larger than a
|
||||
/// verbatim block's in bytes and smaller in lines: prose is read whole and
|
||||
/// wraps, so a screenful of it is far fewer lines than a screenful of a
|
||||
/// log, and cutting a reply at 80 lines would cut most long answers that
|
||||
/// nobody would call long.
|
||||
pub const MESSAGE_LINES: usize = 200;
|
||||
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// A cap of nothing would draw an empty panel and a "Show all" for
|
||||
/// everything there is, which reads as a rendering fault rather than as a
|
||||
/// cap. Checked at compile time, since all four are constants.
|
||||
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||
|
||||
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||
/// count it was cut *from*; `None` when the whole of it fits.
|
||||
///
|
||||
/// The count is the whole text's, not the shown part's -- it is what the
|
||||
/// "Show all N lines" offer says, and a reader deciding whether to ask for
|
||||
/// the rest wants to know how much the rest is.
|
||||
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||
debug_assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
|
||||
);
|
||||
let by_lines = text
|
||||
.char_indices()
|
||||
.filter(|(_, c)| *c == '\n')
|
||||
.nth(max_lines - 1)
|
||||
.map(|(i, _)| i);
|
||||
let by_bytes = (text.len() > max_bytes).then(|| {
|
||||
let mut end = max_bytes;
|
||||
// Back up to a character boundary: a cut inside a multi-byte
|
||||
// character panics on the slice below, and a transcript is full of
|
||||
// them.
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
end
|
||||
});
|
||||
let cut = match (by_lines, by_bytes) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(a, b) => a.or(b)?,
|
||||
};
|
||||
Some((&text[..cut], text.lines().count()))
|
||||
}
|
||||
|
||||
/// What a "Show all" offer says, so the wording is one string rather than
|
||||
/// one per surface.
|
||||
pub fn show_all_label(lines: usize) -> String {
|
||||
format!("Show all {lines} lines")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_under_both_bounds_is_not_cut() {
|
||||
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_cuts_at_a_line_boundary() {
|
||||
let text = "a\nb\nc\nd\n";
|
||||
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
|
||||
assert_eq!(shown, "a\nb");
|
||||
assert_eq!(
|
||||
lines, 4,
|
||||
"the count is the whole text's, not the shown part's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the line bound cannot catch: one enormous line, which is
|
||||
/// what a minified file or an embedded image arrives as.
|
||||
#[test]
|
||||
fn the_byte_bound_cuts_one_long_line() {
|
||||
let text = "x".repeat(5000);
|
||||
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
|
||||
assert_eq!(shown.len(), 4096);
|
||||
assert_eq!(lines, 1);
|
||||
}
|
||||
|
||||
/// Whichever bites first, rather than whichever was checked first.
|
||||
#[test]
|
||||
fn the_tighter_of_the_two_bounds_wins() {
|
||||
let text = "aaaa\n".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 100).expect("over both");
|
||||
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
|
||||
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
|
||||
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||
}
|
||||
|
||||
/// A cut that lands inside a multi-byte character has to back up to
|
||||
/// the boundary; slicing there would panic, and a transcript carries
|
||||
/// em dashes and box drawing in every other line.
|
||||
#[test]
|
||||
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||
let text = "é".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
|
||||
assert_eq!(
|
||||
shown,
|
||||
"é".repeat(5),
|
||||
"11 bytes lands mid-character; 10 is the cut"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! A tool call's input, read rather than dumped -- the port of
|
||||
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||
//! card's one-line summary and the expanded card's key/value list are
|
||||
//! derived from.
|
||||
//!
|
||||
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||
//! line they care about. So the fields that carry the meaning are pulled
|
||||
//! out, and anything left over is still shown, because dropping a field
|
||||
//! would be claiming the tool has no other input when it might.
|
||||
//!
|
||||
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||
//! of this crate exists: the derivation is the same on a phone and on a
|
||||
//! desktop, and it is testable without a renderer.
|
||||
|
||||
use crate::client::durations::format_millis_text;
|
||||
use crate::client::highlight::Language;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A tool call's input, split into the parts a card draws separately.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ToolInput {
|
||||
/// The thing that will actually be run or read, if this tool has one.
|
||||
pub subject: Option<String>,
|
||||
/// The language [`ToolInput::subject`] is written in, for
|
||||
/// highlighting.
|
||||
pub language: Option<Language>,
|
||||
/// The tool's own one-line summary, when it wrote one.
|
||||
pub description: Option<String>,
|
||||
/// How long the call may take, in the largest units it fits. Shown
|
||||
/// apart because it is a limit on the call rather than part of what
|
||||
/// the call does.
|
||||
pub timeout: Option<String>,
|
||||
/// Everything else, as `name: value` lines. Never dropped.
|
||||
pub rest: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolInput {
|
||||
/// The one line to show when there is only room for one: what this
|
||||
/// call is for.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.description
|
||||
.as_deref()
|
||||
.or(self.subject.as_deref())
|
||||
// A subject that is only whitespace would draw as an empty
|
||||
// summary line, which reads as a tool with nothing to say
|
||||
// rather than as one whose subject was blank.
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which field of which tool is the subject.
|
||||
///
|
||||
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||
/// shape stops any of them from being the special case that gets its own
|
||||
/// code path. Unknown tools fall through to "no subject, everything is
|
||||
/// rest".
|
||||
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("Bash", "command", Some(Language::Shell)),
|
||||
("Read", "file_path", None),
|
||||
("Write", "file_path", None),
|
||||
("Edit", "file_path", None),
|
||||
("Glob", "pattern", None),
|
||||
("Grep", "pattern", None),
|
||||
("WebFetch", "url", None),
|
||||
];
|
||||
|
||||
/// Fields that are the tool's own prose about itself rather than input to
|
||||
/// it.
|
||||
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||
|
||||
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||
/// string is its own characters, anything else is its JSON form.
|
||||
///
|
||||
/// One function rather than two, because the same coercion decides both
|
||||
/// what a subject reads as and what a leftover field's value reads as, and
|
||||
/// two copies would eventually disagree about a number.
|
||||
fn as_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
let text = as_text(value?);
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||
///
|
||||
/// Input that is not a JSON object -- older transcripts and some tools
|
||||
/// send a bare string -- is still the input, so it is still shown, as the
|
||||
/// whole of `rest`.
|
||||
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||
return ToolInput {
|
||||
rest: match input.trim().is_empty() {
|
||||
true => Vec::new(),
|
||||
false => vec![input.to_string()],
|
||||
},
|
||||
..ToolInput::default()
|
||||
};
|
||||
};
|
||||
parse_object(tool, &json)
|
||||
}
|
||||
|
||||
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
let (subject_key, language) = SUBJECTS
|
||||
.iter()
|
||||
.find(|(name, ..)| *name == tool)
|
||||
.map(|(_, key, language)| (Some(*key), *language))
|
||||
.unwrap_or((None, None));
|
||||
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||
let description = DESCRIPTIONS
|
||||
.iter()
|
||||
.find_map(|key| non_blank(json.get(*key)));
|
||||
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||
|
||||
// Sorted, so the leftovers are in the same order every time this call
|
||||
// is drawn rather than in whatever order the JSON happened to arrive
|
||||
// in. A field is left out only when it is already drawn somewhere
|
||||
// else on the card.
|
||||
let mut keys: Vec<&String> = json
|
||||
.keys()
|
||||
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||
.collect();
|
||||
keys.sort();
|
||||
let rest = keys
|
||||
.into_iter()
|
||||
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||
.collect();
|
||||
|
||||
ToolInput {
|
||||
subject,
|
||||
language,
|
||||
description,
|
||||
timeout,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_tool_in_the_table_has_its_own_subject() {
|
||||
// One assertion per row of `SUBJECTS`, because the table is the
|
||||
// whole of the rule and a row lost in an edit would otherwise
|
||||
// only show up as a card with no summary line.
|
||||
let cases = [
|
||||
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||
];
|
||||
for (tool, input, expected) in cases {
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||
}
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||
Some(Language::Shell),
|
||||
"a Bash command is shell, and is the one row that names a language"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||
// The description wins over the subject: it is the tool's own
|
||||
// prose about what this call is for, which is what a reader
|
||||
// scanning a collapsed run is looking for.
|
||||
let parsed = parse_tool_input(
|
||||
"Bash",
|
||||
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||
);
|
||||
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||
// The half the "never dropped" promise is about: a tool this
|
||||
// build has never heard of has no subject, so *everything* is
|
||||
// rest -- and a known tool's extra fields are too.
|
||||
let parsed = parse_tool_input(
|
||||
"Edit",
|
||||
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec![
|
||||
"new_string: y".to_string(),
|
||||
"old_string: x".to_string(),
|
||||
"replace_all: true".to_string(),
|
||||
],
|
||||
"sorted, and a non-string value written as JSON"
|
||||
);
|
||||
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||
assert_eq!(unknown.subject, None);
|
||||
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_that_is_not_an_object_is_still_the_input() {
|
||||
// Older transcripts and some tools send a bare string; a card
|
||||
// that dropped it would claim the call had no input at all.
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", "just a string").rest,
|
||||
vec!["just a string".to_string()]
|
||||
);
|
||||
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||
assert_eq!(parsed.subject, None);
|
||||
assert_eq!(parsed.title(), None);
|
||||
// Not dropped just because it was blank -- it is still a field
|
||||
// the call carried.
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,588 @@
|
||||
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||
//!
|
||||
//! One seam rather than a cache the screen has to remember to consult.
|
||||
//! Everything fetched before is asked of this, and everything the server
|
||||
//! sends is written into the cache on the way past, so a caller never
|
||||
//! learns which side answered. The one rule worth keeping in mind: the
|
||||
//! cache is never load-bearing. Every read here has a network path beside
|
||||
//! it producing the same result.
|
||||
//!
|
||||
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||
//! ability to close a live stream from another thread. Both are wall-clock
|
||||
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||
//! `event_stream::follow_session_events`, just with the cache write built
|
||||
//! in so a future caller does not have to repeat it a third time.
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||
use crate::client::event_stream::{self, StreamItem};
|
||||
use crate::client::transcript_cache::SessionCache;
|
||||
|
||||
/// How many events a session screen opens with, cached or fetched.
|
||||
///
|
||||
/// The server's own default page size, named here because the cached
|
||||
/// opening has to be the same size as the fetched one -- a reader must not
|
||||
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||
/// in the Kotlin original).
|
||||
pub const OPENING_WINDOW: u32 = 80;
|
||||
|
||||
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||
/// caller can tell "the server is unreachable" from "the server (or this
|
||||
/// phone's own disk) sent something this build cannot read" -- the two
|
||||
/// mean different things to a reader (retry, versus a build that is
|
||||
/// behind).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Either half of what can go wrong asking for a page: the network, or a
|
||||
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||
/// read.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageError {
|
||||
Api(ApiError),
|
||||
Parse(ParseError),
|
||||
}
|
||||
|
||||
impl From<ApiError> for PageError {
|
||||
fn from(e: ApiError) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseError> for PageError {
|
||||
fn from(e: ParseError) -> Self {
|
||||
Self::Parse(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||
/// one possibly-empty list.
|
||||
///
|
||||
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||
/// question could not be asked yet, which it must not. Collapsing the two
|
||||
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||
/// same silently.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OlderPage {
|
||||
/// The events before the cursor, oldest first. Empty means the start
|
||||
/// of the conversation has been reached.
|
||||
Events(Vec<SeqEvent>),
|
||||
/// Nothing is loaded, so there was no cursor to page back from
|
||||
/// (`before == 0`). Not an answer about the conversation at all.
|
||||
NothingLoaded,
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||
}
|
||||
|
||||
/// This phone's copy of one session's transcript, plus the server it
|
||||
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||
pub struct TranscriptSource<T: Transport> {
|
||||
api: ApiClient<T>,
|
||||
session_id: String,
|
||||
pub cache: SessionCache,
|
||||
}
|
||||
|
||||
impl<T: Transport> TranscriptSource<T> {
|
||||
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||
Self {
|
||||
api,
|
||||
session_id: session_id.into(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached opening window, or `None` when there is nothing usable
|
||||
/// to draw.
|
||||
///
|
||||
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||
/// whole point of the feature: the rows are on screen while the check
|
||||
/// that they are still the server's rows is in flight, and a failed
|
||||
/// check replaces them exactly as a reset does.
|
||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||
self.cache.tail()?;
|
||||
let lines = self.cache.newest(limit);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||
Ok(events) => Some(events),
|
||||
// A line this build cannot read at all, which the cache's own checks cannot
|
||||
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||
// open.
|
||||
Err(ParseError(_)) => {
|
||||
self.cache.purge();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the server's event at the cached cursor is still the cached
|
||||
/// one.
|
||||
///
|
||||
/// A caller must not resume a live stream from a cached seq unless it
|
||||
/// is the same conversation: a transcript is append-only in ordinary
|
||||
/// use, but the file backing it can be replaced or truncated (a
|
||||
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||
/// re-imported), and the server's catch-up on such a file would hand
|
||||
/// this phone a continuation of a *different* conversation, spliced
|
||||
/// onto the cached one with no seam. Caught with one request of a few
|
||||
/// hundred bytes.
|
||||
///
|
||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||
/// server not being askable, which is neither: the cached rows stay
|
||||
/// on screen and the caller tries again on its own reconnect schedule.
|
||||
///
|
||||
/// What this cannot see is a line changed in the middle of the file
|
||||
/// with the tail intact -- that is what a full reload is for.
|
||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||
let Some(tail) = self.cache.tail() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||
// event *at* the cursor when the server still has one there.
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(tail.seq + 1),
|
||||
1,
|
||||
false,
|
||||
None,
|
||||
)?;
|
||||
let matches = page.len() == 1
|
||||
&& parse_line(&tail.line)
|
||||
.map(|cached| cached == page[0].1)
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
self.cache.purge();
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Today's opening fetch, kept as the start of the live run. Only
|
||||
/// called when the cache has nothing to open with, or when
|
||||
/// [`Self::probe`] said what it had was not the server's.
|
||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||
let page =
|
||||
self.api
|
||||
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||
for (line, event) in &page {
|
||||
self.cache.append(line, event.seq);
|
||||
}
|
||||
self.cache.flush();
|
||||
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||
}
|
||||
|
||||
/// The page before `before`: from the cache when it holds it,
|
||||
/// otherwise from the server bounded by what the cache already has.
|
||||
///
|
||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||
/// coalesced page reaches back as far as its row count takes it -- a
|
||||
/// single reply is hundreds of lines -- so a page fetched after the
|
||||
/// reader has been away could run straight past the cached run and
|
||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||
/// this phone's copy starts, the server stops there instead.
|
||||
///
|
||||
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||
/// have bitten": there is no event before the first one, so the
|
||||
/// request is not a harmless no-op, and its empty answer is
|
||||
/// indistinguishable from having reached the start of history.
|
||||
/// Guarded here rather than left to every caller, because it is a fact
|
||||
/// about the question, not about who is asking it.
|
||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||
if before == 0 {
|
||||
return Ok(OlderPage::NothingLoaded);
|
||||
}
|
||||
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||
let events: Vec<SeqEvent> = lines
|
||||
.iter()
|
||||
.map(|l| parse_line(l).map_err(PageError::from))
|
||||
.collect::<Result<_, _>>()?;
|
||||
return Ok(OlderPage::Events(events));
|
||||
}
|
||||
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(before),
|
||||
limit,
|
||||
coalesce,
|
||||
after,
|
||||
)?;
|
||||
if let Some((_, first_event)) = page.first() {
|
||||
// `before` rather than the newest line's seq: a coalesced page covers
|
||||
// everything up to the cursor it was asked with, and nothing in its lines
|
||||
// says so.
|
||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||
self.cache
|
||||
.store_page(&lines, first_event.seq, before, coalesce);
|
||||
}
|
||||
Ok(OlderPage::Events(
|
||||
page.into_iter().map(|(_, event)| event).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// [`event_stream::follow_session_events`], with every frame written to
|
||||
/// the cache before `on_item` sees it.
|
||||
///
|
||||
/// Before, so that an event held back for a reader who is scrolled
|
||||
/// away is already on disk -- what the cache holds is what the server
|
||||
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||
/// status change, which is a turn's boundary and the granularity a
|
||||
/// crash may as well lose, and once more when the stream ends.
|
||||
pub fn follow(
|
||||
&self,
|
||||
after: u64,
|
||||
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let cache = &self.cache;
|
||||
let result = event_stream::follow_session_events(
|
||||
self.api.transport(),
|
||||
&self.session_id,
|
||||
after,
|
||||
|item| {
|
||||
if let StreamItem::Event { raw, event } = &item {
|
||||
cache.append(raw, event.seq);
|
||||
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||
cache.flush();
|
||||
}
|
||||
}
|
||||
on_item(item)
|
||||
},
|
||||
);
|
||||
cache.flush();
|
||||
result
|
||||
}
|
||||
|
||||
/// Leaves the cache with everything it was given -- called once a
|
||||
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||
/// final flush (that method's stream cancellation itself is the
|
||||
/// runtime concern the module doc says is not ported here).
|
||||
pub fn close(&self) {
|
||||
self.cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::api::{Body, RawResponse};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport that answers fixed bodies in call order, and records
|
||||
/// every path it was asked for -- so a test can assert *how many*
|
||||
/// requests a method made, which is the point for the `before == 0`
|
||||
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||
/// before it happens, not merely tolerate the empty answer).
|
||||
#[derive(Default)]
|
||||
struct ScriptedTransport {
|
||||
responses: Mutex<VecDeque<(u16, String)>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScriptedTransport {
|
||||
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back((status, body.into()));
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ScriptedTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (status, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||
Ok(RawResponse {
|
||||
status,
|
||||
body: body.into_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (_, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||
});
|
||||
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
fn source(
|
||||
transport: ScriptedTransport,
|
||||
cache_root: &std::path::Path,
|
||||
) -> TranscriptSource<ScriptedTransport> {
|
||||
let api = ApiClient::new(transport);
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||
TranscriptSource::new(api, "s1", cache)
|
||||
}
|
||||
|
||||
fn status_line(seq: u64) -> String {
|
||||
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
let opening = source.fetch_opening().unwrap();
|
||||
assert_eq!(opening.len(), 1);
|
||||
assert_eq!(opening[0].seq, 1);
|
||||
// The fetch wrote through: reopening the same cache now has something to show.
|
||||
assert!(source.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
// The server now answers with a different event at the same seq -- the file
|
||||
// behind this session was replaced.
|
||||
let transport2 = ScriptedTransport::default();
|
||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||
transport2.respond(200, format!("[{different}]"));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(!source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(500, "server on fire");
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().is_err());
|
||||
assert!(
|
||||
source2.cache.tail().is_some(),
|
||||
"an unreachable server must not be treated as a mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression this module exists to close: `before == 0` must
|
||||
/// never reach the network or the cache, because an empty answer there
|
||||
/// is indistinguishable from "there is genuinely no more history" --
|
||||
/// AGENTS.md's `loadOlderPage` incident.
|
||||
#[test]
|
||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = source(transport, dir.path());
|
||||
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||
assert_eq!(source.api.transport().call_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let calls_before = source.api.transport().call_count();
|
||||
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||
panic!("a cursor of 2 is a real question about the conversation");
|
||||
};
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].seq, 1);
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
calls_before,
|
||||
"a cache hit must not touch the network"
|
||||
);
|
||||
}
|
||||
|
||||
/// With nothing older cached there is no floor to give the server, so
|
||||
/// the request carries no `after` at all.
|
||||
#[test]
|
||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(5)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
source2.page(5, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source2.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the test above cannot show: when the cache *does* hold an
|
||||
/// older run, the fetch is floored at its end, or the page would run
|
||||
/// straight past it and overlap -- which `store_page` then refuses,
|
||||
/// silently costing the phone the page it just paid for.
|
||||
#[test]
|
||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||
// cache reads the directory as damaged and discards it.
|
||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||
assert!(cache.store_page(&lines, 3, 6, true));
|
||||
cache.append(&status_line(6), 6);
|
||||
cache.append(&status_line(7), 7);
|
||||
cache.flush();
|
||||
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(9)));
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
source.page(10, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||
"the fetch must stop one seq below where this phone's copy ends"
|
||||
);
|
||||
}
|
||||
|
||||
/// A page the server could not answer is an error, never an empty
|
||||
/// page: the caller would read the second as "this conversation has no
|
||||
/// more history" and stop paging for good.
|
||||
#[test]
|
||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(500, "server on fire");
|
||||
let source = source(transport, dir.path());
|
||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||
}
|
||||
|
||||
/// A cached line this build cannot read is told apart from the network
|
||||
/// failing, for the same reason: neither is "no more history".
|
||||
#[test]
|
||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.store_page(
|
||||
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||
3,
|
||||
4,
|
||||
true,
|
||||
);
|
||||
cache.append(&status_line(4), 4);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
0,
|
||||
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.append("not json at all", 1);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
assert!(
|
||||
source.cache.tail().is_none(),
|
||||
"a damaged line purges the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||
let source = source(transport, dir.path());
|
||||
let mut seen = Vec::new();
|
||||
source
|
||||
.follow(0, |item| {
|
||||
if let StreamItem::Event { event, .. } = item {
|
||||
seen.push(event.seq);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![1]);
|
||||
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||
}
|
||||
|
||||
fn sse_frame(data: &str) -> String {
|
||||
format!("data:{data}")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user