use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Poll};
use std::time::Duration;
use futures_core::Stream;
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::sync::{mpsc, oneshot, watch};
use crate::limits::ControlLimits;
use crate::version::since::CONTROL_PANE_OFF;
use crate::{Command, Error, PaneId, Server, SessionId, TmuxText, WindowId};
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Event {
Output {
pane: PaneId,
bytes: Vec<u8>,
},
ExtendedOutput {
pane: PaneId,
age: Duration,
bytes: Vec<u8>,
},
Paused {
pane: PaneId,
},
Continued {
pane: PaneId,
},
SessionChanged {
session: SessionId,
},
SessionRenamed {
session: SessionId,
name: TmuxText,
},
SessionWindowChanged {
session: SessionId,
window: WindowId,
},
SessionsChanged,
WindowAdded {
window: WindowId,
},
WindowClosed {
window: WindowId,
},
WindowRenamed {
window: WindowId,
name: TmuxText,
},
WindowPaneChanged {
window: WindowId,
pane: PaneId,
},
UnlinkedWindowAdded {
window: WindowId,
},
UnlinkedWindowClosed {
window: WindowId,
},
UnlinkedWindowRenamed {
window: WindowId,
name: TmuxText,
},
LayoutChanged {
window: WindowId,
layout: TmuxText,
visible_layout: TmuxText,
flags: TmuxText,
},
PaneModeChanged {
pane: PaneId,
},
ClientDetached {
client: TmuxText,
},
ClientSessionChanged {
client: TmuxText,
session: SessionId,
name: TmuxText,
},
PasteBufferChanged {
name: TmuxText,
},
PasteBufferDeleted {
name: TmuxText,
},
SubscriptionChanged {
name: TmuxText,
session: SessionId,
window: Option<WindowId>,
index: Option<u32>,
pane: Option<PaneId>,
value: TmuxText,
},
ConfigError {
message: TmuxText,
},
Message {
message: TmuxText,
},
Exit {
reason: Option<TmuxText>,
},
Other {
name: String,
rest: TmuxText,
},
}
impl Event {
#[must_use]
pub const fn invalidates_listings(&self) -> bool {
!matches!(
self,
Self::Output { .. }
| Self::ExtendedOutput { .. }
| Self::Paused { .. }
| Self::Continued { .. }
| Self::SubscriptionChanged { .. }
| Self::ConfigError { .. }
| Self::Message { .. }
)
}
#[must_use]
pub const fn may_have_added_a_pane(&self) -> bool {
matches!(
self,
Self::LayoutChanged { .. }
| Self::WindowAdded { .. }
| Self::UnlinkedWindowAdded { .. }
| Self::SessionsChanged
| Self::SessionChanged { .. }
| Self::Other { .. }
)
}
#[must_use]
pub const fn pane(&self) -> Option<&PaneId> {
match self {
Self::Output { pane, .. }
| Self::ExtendedOutput { pane, .. }
| Self::Paused { pane }
| Self::Continued { pane }
| Self::PaneModeChanged { pane }
| Self::WindowPaneChanged { pane, .. } => Some(pane),
Self::SubscriptionChanged { pane, .. } => pane.as_ref(),
_ => None,
}
}
#[must_use]
pub const fn window(&self) -> Option<&WindowId> {
match self {
Self::WindowAdded { window }
| Self::WindowClosed { window }
| Self::WindowRenamed { window, .. }
| Self::WindowPaneChanged { window, .. }
| Self::UnlinkedWindowAdded { window }
| Self::UnlinkedWindowClosed { window }
| Self::UnlinkedWindowRenamed { window, .. }
| Self::LayoutChanged { window, .. }
| Self::SessionWindowChanged { window, .. } => Some(window),
Self::SubscriptionChanged { window, .. } => window.as_ref(),
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BlockResult {
number: u64,
succeeded: bool,
output: Vec<TmuxText>,
}
impl BlockResult {
#[must_use]
pub const fn number(&self) -> u64 {
self.number
}
#[must_use]
pub const fn succeeded(&self) -> bool {
self.succeeded
}
#[must_use]
pub fn output(&self) -> &[TmuxText] {
&self.output
}
}
#[derive(Debug)]
pub struct ControlMode {
sender: ControlSender,
events: ControlEvents,
}
impl ControlMode {
pub async fn attach(server: &Server, session: &SessionId) -> Result<Self, Error> {
Self::attach_with_limits(server, session, ControlLimits::default()).await
}
pub async fn attach_with_limits(
server: &Server,
session: &SessionId,
limits: ControlLimits,
) -> Result<Self, Error> {
let pane_off_is_safe = server
.capabilities()
.await
.is_ok_and(|capabilities| capabilities.tmux_version().meets(&CONTROL_PANE_OFF));
let mut command = tokio::process::Command::new(server.tmux_executable());
command
.arg("-S")
.arg(server.socket_path())
.arg("-C")
.arg("attach")
.arg("-t")
.arg(session.to_string())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
let mut child = command.spawn().map_err(Error::control_mode)?;
let stdin = child.stdin.take().ok_or_else(Error::control_mode_pipes)?;
let stdout = child.stdout.take().ok_or_else(Error::control_mode_pipes)?;
let (commands, queue) = mpsc::channel(COMMAND_QUEUE);
let (events, received) = mpsc::channel(EVENT_QUEUE);
let (stop, stopped) = watch::channel(());
let mut connection = Connection {
child,
stdin,
stdout: BufReader::new(stdout),
limits,
line: Vec::new(),
commands: queue,
events,
stopped,
awaiting: VecDeque::new(),
};
if !connection.discard_opening_block().await? {
return Err(Error::control_mode_closed());
}
Ok(Self {
sender: ControlSender {
commands,
pane_off_is_safe,
},
events: ControlEvents {
events: received,
stop,
connection: tokio::spawn(connection.run()),
},
})
}
#[must_use]
pub fn split(self) -> (ControlSender, ControlEvents) {
(self.sender, self.events)
}
pub async fn send(&self, command: Command) -> Result<BlockResult, Error> {
self.sender.send(command).await
}
pub async fn next_event(&mut self) -> Option<Event> {
self.events.next_event().await
}
pub async fn shutdown(self) -> Result<(), Error> {
drop(self.sender);
self.events.shutdown().await
}
}
#[derive(Clone, Debug)]
pub struct ControlSender {
commands: mpsc::Sender<Request>,
pane_off_is_safe: bool,
}
impl ControlSender {
pub async fn send(&self, command: Command) -> Result<BlockResult, Error> {
let line = command
.control_mode_line()
.ok_or_else(Error::control_mode_unrepresentable)?;
let (result, answer) = oneshot::channel();
self.commands
.send(Request { line, result })
.await
.map_err(|_| Error::control_mode_closed())?;
answer.await.map_err(|_| Error::control_mode_closed())?
}
pub async fn mute_pane(&self, pane: &PaneId) -> Result<(), Error> {
self.set_pane_stream(
pane,
if self.pane_off_is_safe {
"off"
} else {
"pause"
},
)
.await
}
pub async fn unmute_pane(&self, pane: &PaneId) -> Result<(), Error> {
self.set_pane_stream(
pane,
if self.pane_off_is_safe {
"on"
} else {
"continue"
},
)
.await
}
pub async fn resume_pane(&self, pane: &PaneId) -> Result<(), Error> {
self.set_pane_stream(pane, "continue").await
}
pub async fn pause_after(&self, behind: Duration) -> Result<(), Error> {
self.send(
Command::new("refresh-client")
.arg("-f")
.arg(format!("pause-after={}", behind.as_secs())),
)
.await
.map(|_| ())
}
pub async fn watch_only(&self, panes: &[PaneId]) -> Result<(), Error> {
let listed = self
.send(
Command::new("list-panes")
.arg("-a")
.arg("-F")
.arg("#{pane_id}"),
)
.await?;
for line in listed.output() {
let Some(found) = line.as_str().ok().and_then(|id| id.parse::<PaneId>().ok()) else {
continue;
};
if !panes.contains(&found) {
self.mute_pane(&found).await?;
}
}
Ok(())
}
async fn set_pane_stream(&self, pane: &PaneId, state: &str) -> Result<(), Error> {
self.send(
Command::new("refresh-client")
.arg("-A")
.arg(format!("{pane}:{state}")),
)
.await
.map(|_| ())
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.commands.is_closed()
}
}
#[derive(Debug)]
pub struct ControlEvents {
events: mpsc::Receiver<Event>,
stop: watch::Sender<()>,
connection: tokio::task::JoinHandle<Result<(), Error>>,
}
impl ControlEvents {
pub async fn next_event(&mut self) -> Option<Event> {
self.events.recv().await
}
pub async fn shutdown(mut self) -> Result<(), Error> {
let _ = self.stop.send(());
self.events.close();
while self.events.recv().await.is_some() {}
self.connection
.await
.map_err(|_| Error::control_mode_closed())?
}
}
impl Stream for ControlEvents {
type Item = Event;
fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Event>> {
self.events.poll_recv(context)
}
}
const COMMAND_QUEUE: usize = 16;
const EVENT_QUEUE: usize = 256;
#[derive(Debug)]
pub struct PaneOutput {
pane: PaneId,
events: ControlEvents,
sender: ControlSender,
narrowing: Arc<AtomicBool>,
}
impl PaneOutput {
pub(crate) fn new(pane: PaneId, events: ControlEvents, sender: ControlSender) -> Self {
Self {
pane,
events,
sender,
narrowing: Arc::new(AtomicBool::new(false)),
}
}
fn narrow(&self) {
if self.narrowing.swap(true, Ordering::AcqRel) {
return;
}
let sender = self.sender.clone();
let pane = self.pane.clone();
let narrowing = Arc::clone(&self.narrowing);
tokio::spawn(async move {
let _ = sender.watch_only(&[pane]).await;
narrowing.store(false, Ordering::Release);
});
}
#[must_use]
pub const fn pane(&self) -> &PaneId {
&self.pane
}
pub async fn next_chunk(&mut self) -> Option<Vec<u8>> {
loop {
let event = self.events.next_event().await?;
match event {
Event::Output { pane, bytes } | Event::ExtendedOutput { pane, bytes, .. }
if pane == self.pane =>
{
return Some(bytes);
}
Event::Exit { .. } => return None,
event if event.may_have_added_a_pane() => self.narrow(),
_ => {}
}
}
}
pub async fn shutdown(self) -> Result<(), Error> {
drop(self.sender);
self.events.shutdown().await
}
}
impl Stream for PaneOutput {
type Item = Vec<u8>;
fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Vec<u8>>> {
loop {
match std::task::ready!(self.events.events.poll_recv(context)) {
Some(Event::Output { pane, bytes } | Event::ExtendedOutput { pane, bytes, .. })
if pane == self.pane =>
{
return Poll::Ready(Some(bytes));
}
Some(Event::Exit { .. }) | None => return Poll::Ready(None),
Some(event) => {
if event.may_have_added_a_pane() {
self.narrow();
}
}
}
}
}
}
#[derive(Debug)]
struct Request {
line: String,
result: oneshot::Sender<Result<BlockResult, Error>>,
}
enum Step {
Read(Result<Option<Line>, Error>),
Send(Option<Request>),
Unwatched {
asked: bool,
},
}
struct Connection {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
limits: ControlLimits,
line: Vec<u8>,
commands: mpsc::Receiver<Request>,
events: mpsc::Sender<Event>,
stopped: watch::Receiver<()>,
awaiting: VecDeque<oneshot::Sender<Result<BlockResult, Error>>>,
}
impl Connection {
async fn run(mut self) -> Result<(), Error> {
let outcome = self.serve().await;
let reason = match &outcome {
Err(Error::ControlModeFrameTooLarge { frame, limit }) => {
Some(Error::control_mode_frame_too_large(frame, *limit))
}
_ => None,
};
while let Some(result) = self.awaiting.pop_front() {
let _ = result.send(Err(reason.as_ref().map_or_else(
Error::control_mode_closed,
|error| match error {
Error::ControlModeFrameTooLarge { frame, limit } => {
Error::control_mode_frame_too_large(frame, *limit)
}
_ => Error::control_mode_closed(),
},
)));
}
drop(self.stdin);
let _ = self.child.wait().await;
outcome
}
async fn serve(&mut self) -> Result<(), Error> {
let mut sending = true;
let mut watching = true;
while sending || watching {
let step = tokio::select! {
line = read_line(&mut self.stdout, &mut self.line, self.limits.max_line_bytes) => Step::Read(line),
request = self.commands.recv(), if sending => Step::Send(request),
asked = self.stopped.changed(), if watching => Step::Unwatched {
asked: asked.is_ok(),
},
};
match step {
Step::Read(Err(error)) => return Err(error),
Step::Read(Ok(None)) | Step::Unwatched { asked: true } => return Ok(()),
Step::Read(Ok(Some(line))) => {
if !self.dispatch(line).await? {
return Ok(());
}
}
Step::Send(Some(request)) => {
if let Err(error) = write_line(&mut self.stdin, &request.line).await {
let _ = request.result.send(Err(Error::control_mode_closed()));
return Err(error);
}
self.awaiting.push_back(request.result);
}
Step::Send(None) => sending = false,
Step::Unwatched { asked: false } => watching = false,
}
}
Ok(())
}
async fn discard_opening_block(&mut self) -> Result<bool, Error> {
loop {
match read_line(&mut self.stdout, &mut self.line, self.limits.max_line_bytes).await? {
Some(Line::BlockStart(number)) => {
self.read_block(number).await?;
return Ok(true);
}
Some(Line::Event(exit @ Event::Exit { .. })) => {
self.report(exit).await;
return Ok(false);
}
Some(Line::Event(event)) => self.report(event).await,
Some(Line::Text(_) | Line::BlockEnd { .. }) => {}
None => return Ok(false),
}
}
}
async fn dispatch(&mut self, line: Line) -> Result<bool, Error> {
match line {
Line::BlockStart(number) => {
let block = self.read_block(number).await?;
if let Some(result) = self.awaiting.pop_front() {
let _ = result.send(Ok(block));
}
Ok(true)
}
Line::Event(exit @ Event::Exit { .. }) => {
self.report(exit).await;
Ok(false)
}
Line::Event(event) => {
self.report(event).await;
Ok(true)
}
Line::Text(_) | Line::BlockEnd { .. } => Ok(true),
}
}
async fn report(&self, event: Event) {
let _ = self.events.send(event).await;
}
async fn read_block(&mut self, number: u64) -> Result<BlockResult, Error> {
let mut output = Vec::new();
let mut accumulated = 0usize;
loop {
match read_line_within(
&mut self.stdout,
&mut self.line,
self.limits.max_line_bytes,
Some(number),
)
.await?
{
Some(Line::BlockEnd {
number: end,
succeeded,
}) if end == number => {
return Ok(BlockResult {
number,
succeeded,
output,
});
}
Some(Line::Text(text)) => {
accumulated = accumulated.saturating_add(text.as_bytes().len());
if accumulated > self.limits.max_block_bytes {
return Err(Error::control_mode_frame_too_large(
"block",
self.limits.max_block_bytes,
));
}
output.push(text);
}
Some(Line::Event(_) | Line::BlockStart(_) | Line::BlockEnd { .. }) => {}
None => return Err(Error::control_mode_closed()),
}
}
}
}
async fn read_line(
stdout: &mut BufReader<ChildStdout>,
pending: &mut Vec<u8>,
limit: usize,
) -> Result<Option<Line>, Error> {
read_line_within(stdout, pending, limit, None).await
}
async fn read_line_within(
stdout: &mut BufReader<ChildStdout>,
pending: &mut Vec<u8>,
limit: usize,
within: Option<u64>,
) -> Result<Option<Line>, Error> {
let read = stdout
.read_until(b'\n', pending)
.await
.map_err(Error::control_mode)?;
if read == 0 && pending.is_empty() {
return Ok(None);
}
if pending.len() > limit {
pending.clear();
return Err(Error::control_mode_frame_too_large("line", limit));
}
let bytes = pending.strip_suffix(b"\n").unwrap_or(pending);
let line = match within {
Some(number) => Line::parse_within_block(bytes, number),
None => Line::parse(bytes),
};
pending.clear();
Ok(Some(line))
}
async fn write_line(stdin: &mut ChildStdin, line: &str) -> Result<(), Error> {
stdin
.write_all(line.as_bytes())
.await
.map_err(Error::control_mode)?;
stdin.write_all(b"\n").await.map_err(Error::control_mode)?;
stdin.flush().await.map_err(Error::control_mode)?;
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Line {
BlockStart(u64),
BlockEnd { number: u64, succeeded: bool },
Event(Event),
Text(TmuxText),
}
impl Line {
fn parse_within_block(line: &[u8], number: u64) -> Self {
match Self::parse(line) {
end @ Self::BlockEnd { number: found, .. } if found == number => end,
_ => Self::Text(TmuxText::from_bytes(line)),
}
}
fn parse(line: &[u8]) -> Self {
let text = || Self::Text(TmuxText::from_bytes(line));
let Some(rest) = line.strip_prefix(b"%") else {
return text();
};
let (name, arguments) = split_once(rest, b' ');
let Ok(name) = std::str::from_utf8(name) else {
return text();
};
Self::framing(name, arguments, line)
.or_else(|| Self::about_output(name, arguments, line))
.or_else(|| Self::about_a_session(name, arguments, line))
.or_else(|| Self::about_a_window(name, arguments, line))
.or_else(|| Self::about_the_server(name, arguments, line))
.unwrap_or_else(|| {
Self::Event(Event::Other {
name: name.to_owned(),
rest: TmuxText::from_bytes(arguments),
})
})
}
fn framing(name: &str, arguments: &[u8], line: &[u8]) -> Option<Self> {
if !matches!(name, "begin" | "end" | "error") {
return None;
}
let number = std::str::from_utf8(arguments).ok().and_then(|arguments| {
arguments
.split_whitespace()
.nth(1)
.and_then(|value| value.parse().ok())
});
Some(match (name, number) {
("begin", Some(number)) => Self::BlockStart(number),
(_, Some(number)) => Self::BlockEnd {
number,
succeeded: name == "end",
},
(_, None) => Self::Text(TmuxText::from_bytes(line)),
})
}
fn about_output(name: &str, arguments: &[u8], line: &[u8]) -> Option<Self> {
let text = || Self::Text(TmuxText::from_bytes(line));
Some(match name {
"output" => {
let (pane, bytes) = split_once(arguments, b' ');
parsed(pane).map_or_else(text, |pane| {
Self::Event(Event::Output {
pane,
bytes: unescape_output(bytes),
})
})
}
"extended-output" => {
let (pane, rest) = split_once(arguments, b' ');
let (age, rest) = split_once(rest, b' ');
let bytes = rest.strip_prefix(b": ").unwrap_or(rest);
match (parsed(pane), parsed::<u64>(age)) {
(Some(pane), Some(age)) => Self::Event(Event::ExtendedOutput {
pane,
age: Duration::from_millis(age),
bytes: unescape_output(bytes),
}),
_ => text(),
}
}
"pause" => pane_event(arguments, text, |pane| Event::Paused { pane }),
"continue" => pane_event(arguments, text, |pane| Event::Continued { pane }),
"pane-mode-changed" => {
pane_event(arguments, text, |pane| Event::PaneModeChanged { pane })
}
_ => return None,
})
}
fn about_a_session(name: &str, arguments: &[u8], line: &[u8]) -> Option<Self> {
let text = || Self::Text(TmuxText::from_bytes(line));
Some(match name {
"session-changed" => {
let (session, _) = split_once(arguments, b' ');
parsed(session).map_or_else(text, |session| {
Self::Event(Event::SessionChanged { session })
})
}
"session-renamed" => {
let (session, new_name) = split_once(arguments, b' ');
parsed(session).map_or_else(text, |session| {
Self::Event(Event::SessionRenamed {
session,
name: TmuxText::from_bytes(new_name),
})
})
}
"session-window-changed" => {
let (session, window) = split_once(arguments, b' ');
match (parsed(session), parsed(window)) {
(Some(session), Some(window)) => {
Self::Event(Event::SessionWindowChanged { session, window })
}
_ => text(),
}
}
"sessions-changed" => Self::Event(Event::SessionsChanged),
_ => return None,
})
}
fn about_a_window(name: &str, arguments: &[u8], line: &[u8]) -> Option<Self> {
let text = || Self::Text(TmuxText::from_bytes(line));
Some(match name {
"window-add" => window_event(arguments, text, |window| Event::WindowAdded { window }),
"window-close" => {
window_event(arguments, text, |window| Event::WindowClosed { window })
}
"unlinked-window-add" => window_event(arguments, text, |window| {
Event::UnlinkedWindowAdded { window }
}),
"unlinked-window-close" => window_event(arguments, text, |window| {
Event::UnlinkedWindowClosed { window }
}),
"window-renamed" | "unlinked-window-renamed" => {
let (window, new_name) = split_once(arguments, b' ');
parsed(window).map_or_else(text, |window| {
let new_name = TmuxText::from_bytes(new_name);
Self::Event(if name == "window-renamed" {
Event::WindowRenamed {
window,
name: new_name,
}
} else {
Event::UnlinkedWindowRenamed {
window,
name: new_name,
}
})
})
}
"window-pane-changed" => {
let (window, pane) = split_once(arguments, b' ');
match (parsed(window), parsed(pane)) {
(Some(window), Some(pane)) => {
Self::Event(Event::WindowPaneChanged { window, pane })
}
_ => text(),
}
}
"layout-change" => {
let (window, rest) = split_once(arguments, b' ');
let (layout, rest) = split_once(rest, b' ');
let (visible_layout, flags) = split_once(rest, b' ');
parsed(window).map_or_else(text, |window| {
Self::Event(Event::LayoutChanged {
window,
layout: TmuxText::from_bytes(layout),
visible_layout: TmuxText::from_bytes(visible_layout),
flags: TmuxText::from_bytes(flags),
})
})
}
_ => return None,
})
}
fn about_the_server(name: &str, arguments: &[u8], line: &[u8]) -> Option<Self> {
let text = || Self::Text(TmuxText::from_bytes(line));
Some(match name {
"client-detached" => Self::Event(Event::ClientDetached {
client: TmuxText::from_bytes(arguments),
}),
"client-session-changed" => {
let (client, rest) = split_once(arguments, b' ');
let (session, session_name) = split_once(rest, b' ');
parsed(session).map_or_else(text, |session| {
Self::Event(Event::ClientSessionChanged {
client: TmuxText::from_bytes(client),
session,
name: TmuxText::from_bytes(session_name),
})
})
}
"paste-buffer-changed" => Self::Event(Event::PasteBufferChanged {
name: TmuxText::from_bytes(arguments),
}),
"paste-buffer-deleted" => Self::Event(Event::PasteBufferDeleted {
name: TmuxText::from_bytes(arguments),
}),
"subscription-changed" => Self::subscription(arguments).unwrap_or_else(text),
"config-error" => Self::Event(Event::ConfigError {
message: TmuxText::from_bytes(arguments),
}),
"message" => Self::Event(Event::Message {
message: TmuxText::from_bytes(arguments),
}),
"exit" => Self::Event(Event::Exit {
reason: (!arguments.is_empty()).then(|| TmuxText::from_bytes(arguments)),
}),
_ => return None,
})
}
fn subscription(arguments: &[u8]) -> Option<Self> {
let (name, rest) = split_once(arguments, b' ');
let (session, rest) = split_once(rest, b' ');
let (window, rest) = split_once(rest, b' ');
let (index, rest) = split_once(rest, b' ');
let (pane, rest) = split_once(rest, b' ');
Some(Self::Event(Event::SubscriptionChanged {
name: TmuxText::from_bytes(name),
session: parsed(session)?,
window: named(window),
index: named(index),
pane: named(pane),
value: TmuxText::from_bytes(rest.strip_prefix(b": ").unwrap_or(rest)),
}))
}
}
fn named<T: std::str::FromStr>(field: &[u8]) -> Option<T> {
if field == b"-" {
return None;
}
parsed(field)
}
fn parsed<T: std::str::FromStr>(field: &[u8]) -> Option<T> {
std::str::from_utf8(field).ok()?.parse().ok()
}
fn pane_event(
arguments: &[u8],
text: impl FnOnce() -> Line,
build: impl FnOnce(PaneId) -> Event,
) -> Line {
let (pane, _) = split_once(arguments, b' ');
parsed(pane).map_or_else(text, |pane| Line::Event(build(pane)))
}
fn window_event(
arguments: &[u8],
text: impl FnOnce() -> Line,
build: impl FnOnce(WindowId) -> Event,
) -> Line {
let (window, _) = split_once(arguments, b' ');
parsed(window).map_or_else(text, |window| Line::Event(build(window)))
}
fn split_once(bytes: &[u8], byte: u8) -> (&[u8], &[u8]) {
bytes
.iter()
.position(|found| *found == byte)
.map_or((bytes, [].as_slice()), |index| {
(&bytes[..index], &bytes[index + 1..])
})
}
fn unescape_output(source: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(source.len());
let mut index = 0;
while index < source.len() {
if source[index] != b'\\' {
bytes.push(source[index]);
index += 1;
continue;
}
match source.get(index + 1..index + 4) {
Some(digits) if digits.iter().all(|digit| (b'0'..=b'7').contains(digit)) => {
let value = digits
.iter()
.fold(0_u32, |value, digit| value * 8 + u32::from(digit - b'0'));
if let Ok(byte) = u8::try_from(value) {
bytes.push(byte);
index += 4;
continue;
}
bytes.push(source[index]);
index += 1;
}
_ => {
if source.get(index + 1) == Some(&b'\\') {
bytes.push(b'\\');
index += 2;
} else {
bytes.push(source[index]);
index += 1;
}
}
}
}
bytes
}
#[cfg(feature = "unstable-fuzzing")]
#[doc(hidden)]
pub fn __fuzz_parse_control_line(line: &[u8]) {
let _ = Line::parse(line);
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{Event, Line, unescape_output};
use crate::{PaneId, SessionId, TmuxText, WindowId};
#[test]
fn block_headers_correlate_by_the_number_tmux_assigns() {
assert_eq!(
Line::parse(b"%begin 1786582374 347 0"),
Line::BlockStart(347)
);
assert_eq!(
Line::parse(b"%end 1786582374 347 0"),
Line::BlockEnd {
number: 347,
succeeded: true,
},
);
assert_eq!(
Line::parse(b"%error 1786582374 353 1"),
Line::BlockEnd {
number: 353,
succeeded: false,
},
);
assert!(matches!(Line::parse(b"%begin bad"), Line::Text(_)));
}
fn event(line: &[u8]) -> Event {
match Line::parse(line) {
Line::Event(event) => event,
other => panic!("{other:?} is not an event"),
}
}
fn a_session() -> SessionId {
"$0".parse().expect("a session id parses")
}
fn a_window() -> WindowId {
"@2".parse().expect("a window id parses")
}
fn a_pane() -> PaneId {
"%3".parse().expect("a pane id parses")
}
#[test]
fn session_notifications_are_parsed() {
assert_eq!(
event(b"%session-changed $0 work"),
Event::SessionChanged {
session: a_session(),
},
);
assert_eq!(
event(b"%session-renamed $0 renamed"),
Event::SessionRenamed {
session: a_session(),
name: TmuxText::from_bytes(*b"renamed"),
},
);
assert_eq!(
event(b"%session-window-changed $0 @2"),
Event::SessionWindowChanged {
session: a_session(),
window: a_window(),
},
);
assert_eq!(event(b"%sessions-changed"), Event::SessionsChanged);
}
#[test]
fn window_notifications_are_parsed() {
assert_eq!(
event(b"%window-add @2"),
Event::WindowAdded { window: a_window() },
);
assert_eq!(
event(b"%window-close @2"),
Event::WindowClosed { window: a_window() },
);
assert_eq!(
event(b"%window-renamed @2 build"),
Event::WindowRenamed {
window: a_window(),
name: TmuxText::from_bytes(*b"build"),
},
);
assert_eq!(
event(b"%window-pane-changed @2 %3"),
Event::WindowPaneChanged {
window: a_window(),
pane: a_pane(),
},
);
assert_eq!(
event(b"%unlinked-window-add @2"),
Event::UnlinkedWindowAdded { window: a_window() },
);
assert_eq!(
event(b"%unlinked-window-close @2"),
Event::UnlinkedWindowClosed { window: a_window() },
);
assert_eq!(
event(b"%unlinked-window-renamed @2 build"),
Event::UnlinkedWindowRenamed {
window: a_window(),
name: TmuxText::from_bytes(*b"build"),
},
);
}
#[test]
fn a_layout_change_is_parsed() {
assert_eq!(
event(b"%layout-change @2 bc62,80x24,0,0,0 bc62,80x24,0,0,0 *"),
Event::LayoutChanged {
window: a_window(),
layout: TmuxText::from_bytes(*b"bc62,80x24,0,0,0"),
visible_layout: TmuxText::from_bytes(*b"bc62,80x24,0,0,0"),
flags: TmuxText::from_bytes(*b"*"),
},
);
}
#[test]
fn output_and_flow_control_notifications_are_parsed() {
assert_eq!(
event(b"%output %3 hi"),
Event::Output {
pane: a_pane(),
bytes: b"hi".to_vec(),
},
);
assert_eq!(
event(b"%extended-output %3 1500 : hi"),
Event::ExtendedOutput {
pane: a_pane(),
age: Duration::from_millis(1500),
bytes: b"hi".to_vec(),
},
);
assert_eq!(event(b"%pause %3"), Event::Paused { pane: a_pane() });
assert_eq!(event(b"%continue %3"), Event::Continued { pane: a_pane() });
assert_eq!(
event(b"%pane-mode-changed %3"),
Event::PaneModeChanged { pane: a_pane() },
);
}
#[test]
fn client_buffer_and_server_notifications_are_parsed() {
assert_eq!(
event(b"%client-detached /dev/pts/4"),
Event::ClientDetached {
client: TmuxText::from_bytes(*b"/dev/pts/4"),
},
);
assert_eq!(
event(b"%client-session-changed /dev/pts/4 $0 work"),
Event::ClientSessionChanged {
client: TmuxText::from_bytes(*b"/dev/pts/4"),
session: a_session(),
name: TmuxText::from_bytes(*b"work"),
},
);
assert_eq!(
event(b"%paste-buffer-changed buffer0"),
Event::PasteBufferChanged {
name: TmuxText::from_bytes(*b"buffer0"),
},
);
assert_eq!(
event(b"%paste-buffer-deleted buffer0"),
Event::PasteBufferDeleted {
name: TmuxText::from_bytes(*b"buffer0"),
},
);
assert_eq!(
event(b"%config-error /etc/tmux.conf:3: unknown command"),
Event::ConfigError {
message: TmuxText::from_bytes(*b"/etc/tmux.conf:3: unknown command"),
},
);
assert_eq!(
event(b"%message hello"),
Event::Message {
message: TmuxText::from_bytes(*b"hello"),
},
);
assert_eq!(event(b"%exit"), Event::Exit { reason: None });
assert_eq!(
event(b"%exit too far behind"),
Event::Exit {
reason: Some(TmuxText::from_bytes(*b"too far behind")),
},
);
}
#[test]
fn a_subscription_change_is_parsed_with_and_without_its_optional_fields() {
assert_eq!(
event(b"%subscription-changed watched $0 @2 7 %3 : value"),
Event::SubscriptionChanged {
name: TmuxText::from_bytes(*b"watched"),
session: a_session(),
window: Some(a_window()),
index: Some(7),
pane: Some(a_pane()),
value: TmuxText::from_bytes(*b"value"),
},
);
assert_eq!(
event(b"%subscription-changed watched $0 - - - : value"),
Event::SubscriptionChanged {
name: TmuxText::from_bytes(*b"watched"),
session: a_session(),
window: None,
index: None,
pane: None,
value: TmuxText::from_bytes(*b"value"),
},
);
}
#[test]
fn an_unmodelled_notification_is_kept() {
assert_eq!(
event(b"%invented-later @2 build"),
Event::Other {
name: "invented-later".to_owned(),
rest: TmuxText::from_bytes(*b"@2 build"),
},
);
}
#[test]
fn a_block_line_that_looks_like_a_notification_is_output() {
assert_eq!(
Line::parse_within_block(b"%0", 12),
Line::Text(TmuxText::from_bytes(*b"%0")),
);
assert_eq!(
Line::parse_within_block(b"%output %3 hi", 12),
Line::Text(TmuxText::from_bytes(*b"%output %3 hi")),
);
assert_eq!(
Line::parse_within_block(b"%end 1786582374 12 0", 12),
Line::BlockEnd {
number: 12,
succeeded: true,
},
);
assert_eq!(
Line::parse_within_block(b"%end 1786582374 13 0", 12),
Line::Text(TmuxText::from_bytes(*b"%end 1786582374 13 0")),
);
}
#[test]
fn a_malformed_notification_is_text_rather_than_a_guess() {
let cases: [&[u8]; 5] = [
b"%window-add nonsense",
b"%pause nonsense",
b"%extended-output %3 notanumber : hi",
b"%session-window-changed $0 nonsense",
b"%begin bad",
];
for line in cases {
assert_eq!(
Line::parse(line),
Line::Text(TmuxText::from_bytes(line)),
"{}",
String::from_utf8_lossy(line),
);
}
}
#[test]
fn an_event_says_whether_a_listing_is_now_stale() {
let stale = |line: &[u8]| match Line::parse(line) {
Line::Event(event) => event.invalidates_listings(),
other => panic!("{other:?} is not an event"),
};
assert!(!stale(b"%output %3 hi"));
assert!(!stale(b"%extended-output %3 10 : hi"));
assert!(!stale(b"%pause %3"));
assert!(stale(b"%window-add @2"));
assert!(stale(b"%window-close @2"));
assert!(stale(b"%sessions-changed"));
assert!(stale(b"%window-pane-changed @2 %3"));
assert!(stale(b"%invented-later whatever"));
}
#[test]
fn a_line_is_bytes_because_tmux_does_not_promise_text() {
let line = Line::parse(b"%output %0 \xff\xc3(");
assert_eq!(
line,
Line::Event(Event::Output {
pane: "%0".parse().expect("a pane id parses"),
bytes: vec![0xff, 0xc3, b'('],
}),
);
assert_eq!(
Line::parse(b"%window-renamed @2 \xff"),
Line::Event(Event::WindowRenamed {
window: "@2".parse().expect("a window id parses"),
name: TmuxText::from_bytes(*b"\xff"),
}),
);
}
#[test]
fn output_escaping_round_trips_the_bytes_tmux_sends() {
assert_eq!(unescape_output(b"plain"), b"plain");
assert_eq!(unescape_output(br"a\015b"), b"a\rb");
assert_eq!(unescape_output(br"\377"), vec![0xff]);
assert_eq!(unescape_output(br"a\\b"), b"a\\b");
assert_eq!(unescape_output(br"a\zb"), b"a\\zb");
}
}