use crate::ids::{PaneId, SessionId, WindowId};
use crate::layout::Layout;
use crate::notification::{Notification, WindowFlags};
use crate::output::decode_output;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Notification(Notification),
Reply(Reply),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
pub number: u64,
pub control: bool,
pub output: Vec<String>,
pub error: bool,
}
#[derive(Debug, Default)]
pub struct Parser {
pending: Option<PendingReply>,
}
#[derive(Debug)]
struct PendingReply {
number: u64,
control: bool,
output: Vec<String>,
}
impl Parser {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, line: &[u8]) -> Option<Event> {
if self.pending.is_none()
&& let Some(notification) = parse_output_line(line)
{
return Some(Event::Notification(notification));
}
let text = String::from_utf8_lossy(line);
match self.pending.is_some() {
true => self.push_within_block(&text),
false => self.push_at_top_level(&text),
}
}
fn push_at_top_level(&mut self, line: &str) -> Option<Event> {
if line.is_empty() {
return None; }
if let Some(Guard {
kind: GuardKind::Begin,
number,
control,
}) = parse_guard(line)
{
self.pending = Some(PendingReply {
number,
control,
output: Vec::new(),
});
return None;
}
Some(Event::Notification(parse_notification(line)))
}
fn push_within_block(&mut self, line: &str) -> Option<Event> {
match parse_guard(line) {
Some(Guard {
kind: GuardKind::End,
..
}) => self.close_block(false),
Some(Guard {
kind: GuardKind::Error,
..
}) => self.close_block(true),
Some(Guard {
kind: GuardKind::Begin,
number,
control,
}) => {
let truncated = self.close_block(true);
self.pending = Some(PendingReply {
number,
control,
output: Vec::new(),
});
truncated
}
None => {
if let Some(pending) = self.pending.as_mut() {
pending.output.push(line.to_string());
}
None
}
}
}
fn close_block(&mut self, error: bool) -> Option<Event> {
let pending = self.pending.take()?;
Some(Event::Reply(Reply {
number: pending.number,
control: pending.control,
output: pending.output,
error,
}))
}
}
struct Guard {
kind: GuardKind,
number: u64,
control: bool,
}
enum GuardKind {
Begin,
End,
Error,
}
fn parse_guard(line: &str) -> Option<Guard> {
let rest = line.strip_prefix('%')?;
let mut parts = rest.split(' ');
let kind = match parts.next()? {
"begin" => GuardKind::Begin,
"end" => GuardKind::End,
"error" => GuardKind::Error,
_ => return None,
};
let _timestamp = parts.next()?;
let number: u64 = parts.next()?.parse().ok()?;
let control = parts.next()?.parse::<u32>().ok()? != 0;
if parts.next().is_some() {
return None;
}
Some(Guard {
kind,
number,
control,
})
}
fn parse_notification(line: &str) -> Notification {
let unknown = || Notification::Unknown(line.to_string());
let Some(rest) = line.strip_prefix('%') else {
return unknown();
};
let (kind, args) = rest.split_once(' ').unwrap_or((rest, ""));
let parsed = match kind {
"layout-change" => parse_layout_change(args),
"window-add" => first_window(args).map(Notification::WindowAdd),
"window-close" => first_window(args).map(Notification::WindowClose),
"window-renamed" => parse_id_name(args, window_id, Notification::WindowRenamed),
"window-pane-changed" => parse_window_pane_changed(args),
"unlinked-window-add" => first_window(args).map(Notification::UnlinkedWindowAdd),
"unlinked-window-close" => first_window(args).map(Notification::UnlinkedWindowClose),
"unlinked-window-renamed" => {
parse_id_name(args, window_id, Notification::UnlinkedWindowRenamed)
}
"session-changed" => parse_id_name(args, session_id, Notification::SessionChanged),
"session-renamed" => parse_id_name(args, session_id, Notification::SessionRenamed),
"session-window-changed" => parse_session_window_changed(args),
"client-session-changed" => parse_client_session_changed(args),
"sessions-changed" => Some(Notification::SessionsChanged),
"pane-mode-changed" => first_pane(args).map(Notification::PaneModeChanged),
"pause" => first_pane(args).map(Notification::Pause),
"continue" => first_pane(args).map(Notification::Continue),
"subscription-changed" => parse_subscription(args),
"exit" => Some(Notification::Exit(optional_reason(args))),
_ => None,
};
parsed.unwrap_or_else(unknown)
}
fn parse_output_line(line: &[u8]) -> Option<Notification> {
let unknown = || Notification::Unknown(String::from_utf8_lossy(line).into_owned());
if let Some(rest) = line.strip_prefix(b"%output ") {
return Some(parse_output(rest).unwrap_or_else(unknown));
}
if let Some(rest) = line.strip_prefix(b"%extended-output ") {
return Some(parse_extended_output(rest).unwrap_or_else(unknown));
}
None
}
fn parse_output(rest: &[u8]) -> Option<Notification> {
let (pane, data) = match rest.iter().position(|&b| b == b' ') {
Some(sp) => (&rest[..sp], &rest[sp + 1..]),
None => (rest, &b""[..]),
};
Some(Notification::Output {
pane: pane_id_bytes(pane)?,
bytes: decode_output(data),
})
}
fn parse_extended_output(rest: &[u8]) -> Option<Notification> {
let space = rest.iter().position(|&b| b == b' ')?;
let pane = pane_id_bytes(&rest[..space])?;
let tail = &rest[space + 1..];
let sep = find_subslice(tail, b" : ")?;
let ms_behind: u64 = std::str::from_utf8(&tail[..sep]).ok()?.parse().ok()?;
let data = &tail[sep + 3..];
Some(Notification::ExtendedOutput {
pane,
ms_behind,
bytes: decode_output(data),
})
}
fn pane_id_bytes(token: &[u8]) -> Option<PaneId> {
let digits = token.strip_prefix(b"%")?;
std::str::from_utf8(digits).ok()?.parse().ok().map(PaneId)
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn parse_layout_change(args: &str) -> Option<Notification> {
let mut parts = args.split(' ');
let window = window_id(parts.next()?)?;
let layout = Layout::parse(parts.next()?).ok()?;
let visible_layout = parts.next().map(Layout::parse).transpose().ok()?;
let flags = parts.next().map(parse_window_flags);
Some(Notification::LayoutChange {
window,
layout,
visible_layout,
flags,
})
}
fn parse_window_flags(field: &str) -> WindowFlags {
let mut flags = WindowFlags::default();
for ch in field.chars() {
match ch {
'*' => flags.current = true,
'-' => flags.last = true,
'#' => flags.activity = true,
'!' => flags.bell = true,
'~' => flags.silence = true,
'M' => flags.marked = true,
'Z' => flags.zoomed = true,
other => flags.unknown.push(other),
}
}
flags
}
fn parse_window_pane_changed(args: &str) -> Option<Notification> {
let (window, pane) = args.split_once(' ')?;
Some(Notification::WindowPaneChanged {
window: window_id(window)?,
pane: pane_id(pane)?,
})
}
fn parse_session_window_changed(args: &str) -> Option<Notification> {
let (session, window) = args.split_once(' ')?;
Some(Notification::SessionWindowChanged {
session: session_id(session)?,
window: window_id(window)?,
})
}
fn parse_client_session_changed(args: &str) -> Option<Notification> {
let (client, rest) = args.split_once(' ')?;
let (session, name) = rest.split_once(' ')?;
Some(Notification::ClientSessionChanged {
client: client.to_string(),
session: session_id(session)?,
name: name.to_string(),
})
}
fn parse_id_name<I>(
args: &str,
parse_id: fn(&str) -> Option<I>,
build: fn(I, String) -> Notification,
) -> Option<Notification> {
let (id, name) = args.split_once(' ')?;
Some(build(parse_id(id)?, name.to_string()))
}
fn parse_subscription(args: &str) -> Option<Notification> {
let name = args.split(' ').next()?.to_string();
let value = args
.split_once(" : ")
.map(|(_, v)| v)
.unwrap_or("")
.to_string();
Some(Notification::SubscriptionChanged { name, value })
}
fn optional_reason(args: &str) -> Option<String> {
match args.trim() {
"" => None,
reason => Some(reason.to_string()),
}
}
fn first_pane(args: &str) -> Option<PaneId> {
pane_id(args.split(' ').next()?)
}
fn first_window(args: &str) -> Option<WindowId> {
window_id(args.split(' ').next()?)
}
fn pane_id(token: &str) -> Option<PaneId> {
token.strip_prefix('%')?.parse().ok().map(PaneId)
}
fn window_id(token: &str) -> Option<WindowId> {
token.strip_prefix('@')?.parse().ok().map(WindowId)
}
fn session_id(token: &str) -> Option<SessionId> {
token.strip_prefix('$')?.parse().ok().map(SessionId)
}
#[cfg(test)]
mod tests {
use super::*;
fn drain(lines: &[&str]) -> Vec<Event> {
let mut parser = Parser::new();
lines
.iter()
.filter_map(|l| parser.push(l.as_bytes()))
.collect()
}
#[test]
fn parses_output_with_decoding() {
let events = drain(&[r"%output %1 hi\033there"]);
assert_eq!(
events,
vec![Event::Notification(Notification::Output {
pane: PaneId(1),
bytes: b"hi\x1bthere".to_vec(),
})]
);
}
#[test]
fn output_preserves_non_utf8_bytes() {
let mut parser = Parser::new();
let mut line = b"%output %1 ".to_vec();
line.extend_from_slice(&[0xff, b'e', b'n', b'd']);
assert_eq!(
parser.push(&line),
Some(Event::Notification(Notification::Output {
pane: PaneId(1),
bytes: vec![0xff, b'e', b'n', b'd'],
}))
);
}
#[test]
fn frames_a_reply_block() {
let events = drain(&[
"%begin 1700000000 7 1",
"line one",
"line two",
"%end 1700000000 7 1",
]);
assert_eq!(
events,
vec![Event::Reply(Reply {
number: 7,
control: true,
output: vec!["line one".to_string(), "line two".to_string()],
error: false,
})]
);
}
#[test]
fn reply_carries_control_flag() {
let ours = drain(&["%begin 1 5 1", "ok", "%end 1 5 1"]);
let theirs = drain(&["%begin 1 6 0", "internal", "%end 1 6 0"]);
let Event::Reply(ours) = &ours[0] else {
panic!("expected a reply");
};
let Event::Reply(theirs) = &theirs[0] else {
panic!("expected a reply");
};
assert!(ours.control);
assert!(!theirs.control);
}
#[test]
fn dropped_end_recovers_on_next_begin() {
let events = drain(&[
"%begin 1 1 1",
"partial-a",
"%begin 1 2 1",
"full-b",
"%end 1 2 1",
]);
assert_eq!(events.len(), 2);
let Event::Reply(truncated) = &events[0] else {
panic!("expected the truncated reply");
};
assert!(truncated.error);
assert_eq!(truncated.number, 1);
assert_eq!(truncated.output, vec!["partial-a".to_string()]);
let Event::Reply(complete) = &events[1] else {
panic!("expected the complete reply");
};
assert!(!complete.error);
assert_eq!(complete.number, 2);
}
#[test]
fn marks_error_replies() {
let events = drain(&["%begin 1 9 1", "no such window", "%error 1 9 1"]);
let Event::Reply(reply) = &events[0] else {
panic!("expected a reply");
};
assert!(reply.error);
assert_eq!(reply.number, 9);
}
#[test]
fn buffers_percent_lines_inside_a_block() {
let events = drain(&["%begin 1 2 1", "%output not-a-real-event", "%end 1 2 1"]);
let Event::Reply(reply) = &events[0] else {
panic!("expected a reply");
};
assert_eq!(reply.output, vec!["%output not-a-real-event".to_string()]);
}
#[test]
fn notifications_interleave_around_blocks() {
let events = drain(&[
"%window-add @3",
"%begin 1 4 1",
"ok",
"%end 1 4 1",
"%sessions-changed",
]);
assert_eq!(events.len(), 3);
assert_eq!(
events[0],
Event::Notification(Notification::WindowAdd(WindowId(3)))
);
assert!(matches!(events[1], Event::Reply(_)));
assert_eq!(
events[2],
Event::Notification(Notification::SessionsChanged)
);
}
#[test]
fn parses_layout_change() {
let events = drain(&["%layout-change @0 159x48,0,0{79x48,0,0,0,79x48,80,0,1}"]);
let Event::Notification(Notification::LayoutChange { window, .. }) = &events[0] else {
panic!("expected a layout change");
};
assert_eq!(*window, WindowId(0));
}
#[test]
fn parses_window_pane_changed() {
let events = drain(&["%window-pane-changed @2 %5"]);
assert_eq!(
events,
vec![Event::Notification(Notification::WindowPaneChanged {
window: WindowId(2),
pane: PaneId(5),
})]
);
}
#[test]
fn parses_unlinked_window_lines() {
let events = drain(&[
"%unlinked-window-add @7",
"%unlinked-window-close @7",
"%unlinked-window-renamed @7 other",
]);
assert_eq!(
events,
vec![
Event::Notification(Notification::UnlinkedWindowAdd(WindowId(7))),
Event::Notification(Notification::UnlinkedWindowClose(WindowId(7))),
Event::Notification(Notification::UnlinkedWindowRenamed(
WindowId(7),
"other".to_string()
)),
]
);
}
#[test]
fn parses_session_renamed_and_window_changed() {
let events = drain(&["%session-renamed $1 work", "%session-window-changed $1 @4"]);
assert_eq!(
events,
vec![
Event::Notification(Notification::SessionRenamed(
SessionId(1),
"work".to_string()
)),
Event::Notification(Notification::SessionWindowChanged {
session: SessionId(1),
window: WindowId(4),
}),
]
);
}
#[test]
fn parses_client_session_changed() {
let events = drain(&["%client-session-changed /dev/ttys003 $2 main"]);
assert_eq!(
events,
vec![Event::Notification(Notification::ClientSessionChanged {
client: "/dev/ttys003".to_string(),
session: SessionId(2),
name: "main".to_string(),
})]
);
}
#[test]
fn layout_change_carries_visible_layout_and_flags() {
let events =
drain(&["%layout-change @1 159x48,0,0{79x48,0,0,0,79x48,80,0,1} 159x48,0,0,0 *Z"]);
let Event::Notification(Notification::LayoutChange {
window,
visible_layout,
flags,
..
}) = &events[0]
else {
panic!("expected a layout change");
};
assert_eq!(*window, WindowId(1));
assert!(visible_layout.is_some());
assert_eq!(
flags,
&Some(WindowFlags {
current: true,
zoomed: true,
..Default::default()
})
);
}
#[test]
fn window_flags_retain_unknown_chars() {
let events = drain(&["%layout-change @0 159x48,0,0,0 159x48,0,0,0 !Q"]);
let Event::Notification(Notification::LayoutChange { flags, .. }) = &events[0] else {
panic!("expected a layout change");
};
assert_eq!(
flags,
&Some(WindowFlags {
bell: true,
unknown: "Q".to_string(),
..Default::default()
})
);
}
#[test]
fn layout_change_without_visible_layout_is_back_compatible() {
let events = drain(&["%layout-change @0 159x48,0,0{79x48,0,0,0,79x48,80,0,1}"]);
let Event::Notification(Notification::LayoutChange {
visible_layout,
flags,
..
}) = &events[0]
else {
panic!("expected a layout change");
};
assert!(visible_layout.is_none());
assert!(flags.is_none());
}
#[test]
fn parses_exit_with_and_without_reason() {
assert_eq!(
drain(&["%exit"]),
vec![Event::Notification(Notification::Exit(None))]
);
assert_eq!(
drain(&["%exit server exited"]),
vec![Event::Notification(Notification::Exit(Some(
"server exited".to_string()
)))]
);
}
#[test]
fn stray_blank_line_is_skipped_not_unknown() {
assert_eq!(drain(&[""]), vec![]);
}
#[test]
fn malformed_guard_does_not_open_a_block() {
let events = drain(&["%begin 1 7 1 garbage", "%window-add @9"]);
assert_eq!(
events,
vec![
Event::Notification(Notification::Unknown("%begin 1 7 1 garbage".to_string())),
Event::Notification(Notification::WindowAdd(WindowId(9))),
]
);
}
#[test]
fn unknown_lines_are_preserved() {
let events = drain(&["%made-up-thing whatever"]);
assert_eq!(
events,
vec![Event::Notification(Notification::Unknown(
"%made-up-thing whatever".to_string()
))]
);
}
}