const ESC: u8 = 0x1B;
const BEL: u8 = 0x07;
const OSC_BODY_LIMIT: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
PromptStart,
PromptEnd,
CommandStart,
CommandEnd { exit_code: Option<i32> },
CurrentDir(std::path::PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatedEvent {
pub event: Event,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Copy)]
enum State {
Normal,
AfterEsc,
InOsc,
InOscAfterEsc,
}
#[derive(Debug, Clone)]
pub struct Detector {
state: State,
body: Vec<u8>,
sequence_start: Option<usize>,
}
impl Default for Detector {
fn default() -> Self {
Self::new()
}
}
impl Detector {
pub fn new() -> Self {
Self {
state: State::Normal,
body: Vec::new(),
sequence_start: None,
}
}
pub fn feed(&mut self, bytes: &[u8]) -> Vec<Event> {
self.feed_with_offsets(bytes)
.into_iter()
.map(|ev| ev.event)
.collect()
}
pub fn feed_with_offsets(&mut self, bytes: &[u8]) -> Vec<LocatedEvent> {
let mut out = Vec::new();
if !matches!(self.state, State::Normal) {
self.sequence_start = None;
}
for (idx, &b) in bytes.iter().enumerate() {
self.step(idx, b, &mut out);
}
out
}
fn finish_osc(&mut self, idx: usize, out: &mut Vec<LocatedEvent>) {
if let Some(ev) = parse_osc(&self.body) {
out.push(LocatedEvent {
event: ev,
start: self.sequence_start.unwrap_or(0),
end: idx + 1,
});
}
self.body.clear();
self.sequence_start = None;
self.state = State::Normal;
}
fn step(&mut self, idx: usize, b: u8, out: &mut Vec<LocatedEvent>) {
self.state = match self.state {
State::Normal => {
if b == ESC {
self.sequence_start = Some(idx);
State::AfterEsc
} else {
State::Normal
}
}
State::AfterEsc => match b {
b']' => {
self.body.clear();
State::InOsc
}
ESC => {
self.sequence_start = Some(idx);
State::AfterEsc
}
_ => {
self.sequence_start = None;
State::Normal
}
},
State::InOsc => {
if b == BEL {
self.finish_osc(idx, out);
return;
}
if b == ESC {
State::InOscAfterEsc
} else {
if self.body.len() < OSC_BODY_LIMIT {
self.body.push(b);
}
State::InOsc
}
}
State::InOscAfterEsc => match b {
b'\\' => {
self.finish_osc(idx, out);
return;
}
ESC => State::InOscAfterEsc,
_ => {
self.body.clear();
self.sequence_start = None;
State::Normal
}
},
};
}
}
fn parse_osc(body: &[u8]) -> Option<Event> {
parse_133(body).or_else(|| parse_osc7_cwd(body))
}
fn parse_133(body: &[u8]) -> Option<Event> {
let s = std::str::from_utf8(body).ok()?.strip_prefix("133;")?;
let mut parts = s.split(';');
match parts.next()? {
"A" => Some(Event::PromptStart),
"B" => Some(Event::PromptEnd),
"C" => Some(Event::CommandStart),
"D" => {
let exit_code = parts.next().and_then(|s| s.parse::<i32>().ok());
Some(Event::CommandEnd { exit_code })
}
_ => None,
}
}
fn parse_osc7_cwd(body: &[u8]) -> Option<Event> {
let uri = std::str::from_utf8(body).ok()?.strip_prefix("7;file://")?;
let path_start = uri.find('/')?;
let path = percent_decode_uri_path(&uri[path_start..]);
Some(Event::CurrentDir(std::path::PathBuf::from(path)))
}
fn percent_decode_uri_path(path: &str) -> String {
let mut out = Vec::with_capacity(path.len());
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if let (Some(&hi), Some(&lo)) = (bytes.get(i + 1), bytes.get(i + 2))
&& let (Some(hi), Some(lo)) = (hex_value(hi), hex_value(lo))
{
out.push(hi << 4 | lo);
i += 3;
continue;
}
} else {
out.push(bytes[i]);
i += 1;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn events(bytes: &[u8]) -> Vec<Event> {
let mut d = Detector::new();
d.feed(bytes)
}
#[test]
fn detects_command_start_bel() {
assert_eq!(events(b"\x1b]133;C\x07"), vec![Event::CommandStart]);
}
#[test]
fn detects_command_end_with_exit() {
assert_eq!(
events(b"\x1b]133;D;0\x07"),
vec![Event::CommandEnd { exit_code: Some(0) }]
);
}
#[test]
fn detects_command_end_with_nonzero_exit() {
assert_eq!(
events(b"\x1b]133;D;127\x07"),
vec![Event::CommandEnd {
exit_code: Some(127)
}]
);
}
#[test]
fn detects_command_end_without_exit() {
assert_eq!(
events(b"\x1b]133;D\x07"),
vec![Event::CommandEnd { exit_code: None }]
);
}
#[test]
fn detects_prompt_start_and_end() {
assert_eq!(
events(b"\x1b]133;A\x07hi\x1b]133;B\x07"),
vec![Event::PromptStart, Event::PromptEnd]
);
}
#[test]
fn st_terminator_string() {
assert_eq!(events(b"\x1b]133;C\x1b\\"), vec![Event::CommandStart]);
}
#[test]
fn ignores_unrelated_osc() {
assert!(events(b"\x1b]0;window title\x07").is_empty());
assert!(events(b"\x1b]2;another title\x07").is_empty());
assert!(events(b"\x1b]52;c;abcd\x07").is_empty());
}
#[test]
fn detects_current_dir_osc7() {
assert_eq!(
events(b"\x1b]7;file://host/tmp/a%20b\x07"),
vec![Event::CurrentDir(std::path::PathBuf::from("/tmp/a b"))]
);
}
#[test]
fn osc7_current_dir_tolerates_literal_percent() {
assert_eq!(
events(b"\x1b]7;file://host/tmp/100%done\x07"),
vec![Event::CurrentDir(std::path::PathBuf::from("/tmp/100%done"))]
);
}
#[test]
fn handles_byte_split_input() {
let mut d = Detector::new();
let mut all = vec![];
all.extend(d.feed(b"\x1b"));
all.extend(d.feed(b"]"));
all.extend(d.feed(b"1"));
all.extend(d.feed(b"3"));
all.extend(d.feed(b"3"));
all.extend(d.feed(b";"));
all.extend(d.feed(b"C"));
all.extend(d.feed(b"\x07"));
assert_eq!(all, vec![Event::CommandStart]);
}
#[test]
fn full_cycle_sequence() {
let bytes =
b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file1 file2\n\x1b]133;D;0\x07";
assert_eq!(
events(bytes),
vec![
Event::PromptStart,
Event::PromptEnd,
Event::CommandStart,
Event::CommandEnd { exit_code: Some(0) },
]
);
}
#[test]
fn embedded_in_random_output_with_colors() {
let bytes = b"random output\x1b[1;31mcolor\x1b[0m text \x1b]133;C\x07more";
assert_eq!(events(bytes), vec![Event::CommandStart]);
}
#[test]
fn multiple_in_one_buffer() {
let bytes = b"\x1b]133;C\x07stuff\x1b]133;D;1\x07\x1b]133;A\x07";
assert_eq!(
events(bytes),
vec![
Event::CommandStart,
Event::CommandEnd { exit_code: Some(1) },
Event::PromptStart,
]
);
}
#[test]
fn malformed_osc_does_not_panic() {
assert!(events(b"\x1b]133;").is_empty());
assert!(events(b"\x1b]133;Z\x07").is_empty());
assert!(events(b"\x1b]\x07").is_empty());
}
#[test]
fn body_length_cap() {
let mut huge = b"\x1b]133;".to_vec();
huge.extend(std::iter::repeat_n(b'X', 10_000));
huge.push(b'\x07');
let _ = events(&huge);
}
#[test]
fn esc_inside_normal_text_does_not_break_detection() {
let bytes = b"\x1bX\x1b]133;C\x07";
assert_eq!(events(bytes), vec![Event::CommandStart]);
}
}