const BEL: u8 = 0x07;
const ESC: u8 = 0x1b;
const MAX_OSC_BODY: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum State {
#[default]
Ground,
Escape,
Osc,
OscEscape,
}
#[derive(Debug, Clone, Default)]
pub struct OscCapture {
state: State,
buffer: Vec<u8>,
overflowed: bool,
title: Option<String>,
progress: Option<String>,
}
impl OscCapture {
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, bytes: &[u8]) {
for &b in bytes {
match self.state {
State::Ground => {
if b == ESC {
self.state = State::Escape;
}
}
State::Escape => {
if b == b']' {
self.buffer.clear();
self.overflowed = false;
self.state = State::Osc;
} else {
self.state = if b == ESC {
State::Escape
} else {
State::Ground
};
}
}
State::Osc => match b {
BEL => {
self.finish();
self.state = State::Ground;
}
ESC => self.state = State::OscEscape,
_ => {
if self.buffer.len() < MAX_OSC_BODY {
self.buffer.push(b);
} else {
self.overflowed = true;
}
}
},
State::OscEscape => {
if b == b'\\' {
self.finish();
self.state = State::Ground;
} else if b == b']' {
self.buffer.clear();
self.overflowed = false;
self.state = State::Osc;
} else {
self.buffer.clear();
self.state = if b == ESC {
State::Escape
} else {
State::Ground
};
}
}
}
}
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn progress(&self) -> Option<&str> {
self.progress.as_deref()
}
fn finish(&mut self) {
if self.overflowed {
self.buffer.clear();
self.overflowed = false;
return;
}
if let Ok(body) = std::str::from_utf8(&self.buffer) {
if let Some((code, rest)) = body.split_once(';') {
match code {
"0" | "2" => self.title = Some(rest.to_string()),
"9" if rest.split(';').next() == Some("4") => {
self.progress = Some(rest.to_string())
}
_ => {}
}
}
}
self.buffer.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn osc_title_split_across_two_feeds_reassembles() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]2;hel");
assert_eq!(osc.title(), None, "not terminated yet, nothing captured");
osc.feed(b"lo world\x07");
assert_eq!(osc.title(), Some("hello world"));
}
#[test]
fn esc_introducer_split_from_bracket() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b");
osc.feed(b"]2;ok\x07");
assert_eq!(osc.title(), Some("ok"));
}
#[test]
fn st_terminator_accepted() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]0;title here\x1b\\");
assert_eq!(osc.title(), Some("title here"));
}
#[test]
fn braille_spinner_title_survives_mid_codepoint_split() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]2;\xe2\xa0");
osc.feed(b"\x8b Compiling\x07");
assert_eq!(osc.title(), Some("\u{280b} Compiling"));
}
#[test]
fn osc_9_4_is_progress_but_bare_osc_9_is_not() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]9;4;1;50\x07");
assert_eq!(osc.progress(), Some("4;1;50"));
let mut other = OscCapture::new();
other.feed(b"\x1b]9;build done\x07");
assert_eq!(other.progress(), None);
}
#[test]
fn latest_title_wins() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]2;first\x07\x1b]2;second\x07");
assert_eq!(osc.title(), Some("second"));
}
#[test]
fn unterminated_osc_running_into_next_osc_captures_the_second() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]2;first\x1b]2;second\x07");
assert_eq!(osc.title(), Some("second"));
}
#[test]
fn oversized_osc_body_is_dropped_not_published_truncated() {
let mut osc = OscCapture::new();
let mut seq = b"\x1b]2;".to_vec();
seq.extend(std::iter::repeat(b'x').take(MAX_OSC_BODY + 100));
seq.push(BEL);
osc.feed(&seq);
assert_eq!(
osc.title(),
None,
"truncated oversized title must not publish"
);
osc.feed(b"\x1b]2;ok\x07");
assert_eq!(osc.title(), Some("ok"));
}
#[test]
fn osc_1_icon_name_is_not_a_title() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]1;iconname\x07");
assert_eq!(osc.title(), None);
}
#[test]
fn interrupted_osc_does_not_capture_garbage() {
let mut osc = OscCapture::new();
osc.feed(b"\x1b]2;par\x1b[0mtial\x07");
assert_eq!(osc.title(), None);
osc.feed(b"\x1b]2;clean\x07");
assert_eq!(osc.title(), Some("clean"));
}
}