#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Output {
pane: String,
data: Vec<u8>,
},
CommandReply {
number: u64,
lines: Vec<String>,
error: bool,
},
SessionChanged {
session: String,
name: String,
},
SessionsChanged,
SessionRenamed {
session: String,
name: String,
},
WindowAdd {
window: String,
},
WindowClose {
window: String,
},
WindowRenamed {
window: String,
name: String,
},
LayoutChange {
window: String,
layout: String,
},
PaneModeChanged {
pane: String,
},
Exit {
reason: Option<String>,
},
Unknown {
line: String,
},
}
#[derive(Debug)]
struct Block {
number: u64,
lines: Vec<String>,
}
#[derive(Debug, Default)]
pub struct Decoder {
block: Option<Block>,
}
impl Decoder {
pub fn new() -> Self {
Self::default()
}
pub fn in_block(&self) -> bool {
self.block.is_some()
}
pub fn push(&mut self, line: impl AsRef<[u8]>) -> Option<Event> {
let line = strip_wrapper(line.as_ref());
if self.block.is_none()
&& let Some(rest) = line.strip_prefix(b"%output ".as_slice())
{
return Some(parse_output(rest));
}
let line = String::from_utf8_lossy(line);
let line = line.as_ref();
if self.block.is_some() {
if let Some(rest) = line.strip_prefix("%end ") {
return self.close_block(rest, false);
}
if let Some(rest) = line.strip_prefix("%error ") {
return self.close_block(rest, true);
}
if let Some(block) = self.block.as_mut() {
block.lines.push(line.to_string());
}
return None;
}
if let Some(rest) = line.strip_prefix("%begin ") {
self.block = Some(Block {
number: field(rest, 1).and_then(|f| f.parse().ok()).unwrap_or(0),
lines: Vec::new(),
});
return None;
}
if !line.starts_with('%') {
return None;
}
Some(parse_notification(line))
}
fn close_block(&mut self, rest: &str, error: bool) -> Option<Event> {
let block = self.block.take()?;
let number = field(rest, 1)
.and_then(|f| f.parse().ok())
.unwrap_or(block.number);
Some(Event::CommandReply {
number,
lines: block.lines,
error,
})
}
}
fn parse_output(rest: &[u8]) -> Event {
match rest.iter().position(|&b| b == b' ') {
Some(i) => Event::Output {
pane: String::from_utf8_lossy(&rest[..i]).into_owned(),
data: unescape(&rest[i + 1..]),
},
None if !rest.is_empty() => Event::Output {
pane: String::from_utf8_lossy(rest).into_owned(),
data: Vec::new(),
},
None => Event::Unknown {
line: "%output".to_string(),
},
}
}
fn parse_notification(line: &str) -> Event {
let (tag, rest) = match line.split_once(' ') {
Some((tag, rest)) => (tag, rest),
None => (line, ""),
};
match tag {
"%session-changed" => two(rest)
.map(|(session, name)| Event::SessionChanged { session, name })
.unwrap_or_else(|| unknown(line)),
"%session-renamed" => two(rest)
.map(|(session, name)| Event::SessionRenamed { session, name })
.unwrap_or_else(|| unknown(line)),
"%sessions-changed" => Event::SessionsChanged,
"%window-add" => Event::WindowAdd {
window: rest.trim().to_string(),
},
"%window-close" | "%unlinked-window-close" => Event::WindowClose {
window: rest.trim().to_string(),
},
"%window-renamed" => two(rest)
.map(|(window, name)| Event::WindowRenamed { window, name })
.unwrap_or_else(|| unknown(line)),
"%layout-change" => two(rest)
.map(|(window, layout)| Event::LayoutChange { window, layout })
.unwrap_or_else(|| unknown(line)),
"%pane-mode-changed" => Event::PaneModeChanged {
pane: rest.trim().to_string(),
},
"%exit" => Event::Exit {
reason: (!rest.trim().is_empty()).then(|| rest.trim().to_string()),
},
_ => unknown(line),
}
}
fn unknown(line: &str) -> Event {
Event::Unknown {
line: line.to_string(),
}
}
fn two(rest: &str) -> Option<(String, String)> {
let (a, b) = rest.split_once(' ')?;
Some((a.to_string(), b.to_string()))
}
fn field(s: &str, index: usize) -> Option<&str> {
s.split_whitespace().nth(index)
}
fn strip_wrapper(line: &[u8]) -> &[u8] {
let line = line.strip_suffix(b"\n").unwrap_or(line);
let line = line.strip_suffix(b"\r").unwrap_or(line);
let line = line.strip_prefix(b"\x1bP1000p".as_slice()).unwrap_or(line);
line.strip_suffix(b"\x1b\\".as_slice()).unwrap_or(line)
}
fn unescape(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let digits = &bytes[i + 1..i + 4];
if digits.iter().all(|b| (b'0'..=b'7').contains(b)) {
let value = digits
.iter()
.fold(0u32, |acc, b| acc * 8 + u32::from(b - b'0'));
if value <= 0xff {
out.push(value as u8);
i += 4;
continue;
}
}
}
out.push(bytes[i]);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn decode(lines: &[&str]) -> Vec<Event> {
let mut decoder = Decoder::new();
lines.iter().filter_map(|l| decoder.push(l)).collect()
}
#[test]
fn unescapes_octal_payloads() {
let events = decode(&[r"%output %0 \033[1m\033[7m%\033[27m\015 \015"]);
let Event::Output { pane, data } = &events[0] else {
panic!("expected output, got {events:?}");
};
assert_eq!(pane, "%0");
assert_eq!(data, b"\x1b[1m\x1b[7m%\x1b[27m\r \r");
}
#[test]
fn a_literal_backslash_arrives_as_octal() {
let events = decode([r"%output %0 \033k/tmp\033\134"].as_ref());
let Event::Output { data, .. } = &events[0] else {
panic!("expected output");
};
assert_eq!(data, b"\x1bk/tmp\x1b\\");
}
#[test]
fn payloads_may_be_invalid_utf8() {
let events = decode(&[r"%output %0 \377\376"]);
let Event::Output { data, .. } = &events[0] else {
panic!("expected output");
};
assert_eq!(data, &[0xff, 0xfe]);
}
#[test]
fn raw_high_bytes_survive_the_decoder() {
let mut line = b"%output %0 A".to_vec();
line.extend_from_slice(&[0xff, 0xfe]);
line.extend_from_slice(b"B");
let mut decoder = Decoder::new();
let Some(Event::Output { data, .. }) = decoder.push(&line) else {
panic!("expected output");
};
assert_eq!(data, b"A\xff\xfeB");
}
#[test]
fn a_character_split_across_two_lines_is_not_corrupted() {
let heart = "\u{2764}".as_bytes();
let mut decoder = Decoder::new();
let mut first = b"%output %0 ".to_vec();
first.extend_from_slice(&heart[..2]);
let mut second = b"%output %0 ".to_vec();
second.extend_from_slice(&heart[2..]);
let mut all = Vec::new();
for line in [first, second] {
let Some(Event::Output { data, .. }) = decoder.push(&line) else {
panic!("expected output");
};
all.extend(data);
}
assert_eq!(String::from_utf8(all).unwrap(), "\u{2764}");
}
#[test]
fn a_lone_backslash_is_preserved() {
let events = decode(&[r"%output %0 a\zb"]);
let Event::Output { data, .. } = &events[0] else {
panic!("expected output");
};
assert_eq!(data, b"a\\zb", "malformed escapes must not lose bytes");
}
#[test]
fn command_output_starting_with_percent_is_not_a_notification() {
let events = decode(&[
"%begin 1785856377 280 1",
"%0",
"%1",
"%end 1785856377 280 1",
]);
assert_eq!(
events,
[Event::CommandReply {
number: 280,
lines: vec!["%0".into(), "%1".into()],
error: false,
}]
);
}
#[test]
fn even_output_notifications_inside_a_block_stay_literal() {
let events = decode(&["%begin 1 5 0", "%output %0 not-really", "%end 1 5 0"]);
assert_eq!(
events,
[Event::CommandReply {
number: 5,
lines: vec!["%output %0 not-really".into()],
error: false,
}]
);
}
#[test]
fn a_failed_command_is_flagged() {
let events = decode(&["%begin 1 7 0", "no such window", "%error 1 7 0"]);
assert_eq!(
events,
[Event::CommandReply {
number: 7,
lines: vec!["no such window".into()],
error: true,
}]
);
}
#[test]
fn an_empty_reply_still_arrives() {
let events = decode(&["%begin 1 275 0", "%end 1 275 0"]);
assert_eq!(
events,
[Event::CommandReply {
number: 275,
lines: vec![],
error: false,
}]
);
}
#[test]
fn block_state_is_observable() {
let mut decoder = Decoder::new();
assert!(!decoder.in_block());
decoder.push("%begin 1 2 0");
assert!(decoder.in_block());
decoder.push("%end 1 2 0");
assert!(!decoder.in_block());
}
#[test]
fn strips_the_dcs_wrapper_and_carriage_returns() {
let events = decode(&[
"\x1bP1000p%begin 1785856377 275 0\r",
"%end 1785856377 275 0\r",
]);
assert_eq!(
events,
[Event::CommandReply {
number: 275,
lines: vec![],
error: false,
}]
);
}
#[test]
fn parses_session_and_window_notifications() {
let events = decode(&[
"%session-changed $0 demo",
"%sessions-changed",
"%window-add @3",
"%window-close @3",
"%window-renamed @1 editor",
"%layout-change @1 bb62,80x24,0,0,1",
"%pane-mode-changed %4",
]);
assert_eq!(
events,
[
Event::SessionChanged {
session: "$0".into(),
name: "demo".into()
},
Event::SessionsChanged,
Event::WindowAdd {
window: "@3".into()
},
Event::WindowClose {
window: "@3".into()
},
Event::WindowRenamed {
window: "@1".into(),
name: "editor".into()
},
Event::LayoutChange {
window: "@1".into(),
layout: "bb62,80x24,0,0,1".into()
},
Event::PaneModeChanged { pane: "%4".into() },
]
);
}
#[test]
fn exit_carries_its_reason_when_there_is_one() {
assert_eq!(
decode(&["%exit"]),
[Event::Exit { reason: None }],
"a bare exit has no reason"
);
assert_eq!(
decode(&["%exit server exited"]),
[Event::Exit {
reason: Some("server exited".into())
}]
);
}
#[test]
fn unmodelled_notifications_are_surfaced_not_dropped() {
let events = decode(&["%subscription-changed foo $0 @0 %0 : value"]);
assert!(matches!(events[0], Event::Unknown { .. }));
}
#[test]
fn echoed_input_outside_a_block_is_ignored() {
assert!(decode(&[r##"list-panes -F "#{pane_id}""##]).is_empty());
}
#[test]
fn a_real_transcript_decodes_end_to_end() {
let events = decode(&[
r##"list-panes -F "#{pane_id}""##,
"\x1bP1000p%begin 1785856377 275 0\r",
"%end 1785856377 275 0\r",
"%session-changed $0 demo\r",
"%begin 1785856377 280 1\r",
"%0\r",
"%end 1785856377 280 1\r",
r"%output %0 ABC\015",
"%exit\r",
]);
assert_eq!(events.len(), 5, "{events:#?}");
assert!(matches!(events[0], Event::CommandReply { number: 275, .. }));
assert!(matches!(events[1], Event::SessionChanged { .. }));
let Event::CommandReply { lines, .. } = &events[2] else {
panic!("expected the list-panes reply");
};
assert_eq!(lines, &["%0"]);
assert!(matches!(events[3], Event::Output { .. }));
assert_eq!(events[4], Event::Exit { reason: None });
}
}