Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
use crate::util::parse_ascii_u32;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::bytes::Regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// The below strings are used as a tag/prefix at the beginning of a response
|
||||
// from tmux to determine the type of response. These strings must be unique
|
||||
// and should not contain any special characters (alpha-num, dash, underline,
|
||||
// and space are acceptable).
|
||||
// This is because the response is parsed using regex, as well as to avoid
|
||||
// messing up tmux's shell-like parsing.
|
||||
const PRIMARY_WINDOW_PANE_PREFIX: &str = "primary window pane";
|
||||
pub const BACKGROUND_WINDOW_PREFIX: &str = "background window";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TmuxCommand {
|
||||
/// Gets the window id and pane id of the primary window pane.
|
||||
GetPrimaryWindowPane,
|
||||
/// Runs a command in the background in a new temporary window.
|
||||
RunInBackgroundWindow {
|
||||
command_id: String,
|
||||
current_directory_path: Option<String>,
|
||||
command: String,
|
||||
environment_variables: Option<HashMap<String, String>>,
|
||||
},
|
||||
/// Refreshes the client in Control Mode, at a target size of rows and cols.
|
||||
UpdateClientSize { num_rows: usize, num_cols: usize },
|
||||
/// Configures tmux to automatically terminate any sessions that don't have clients attached.
|
||||
SetDestroyUnattached,
|
||||
/// Forces the tmux session to inherit the smallest dimensions of any attached client.
|
||||
SetWindowSizeToSmallest,
|
||||
}
|
||||
|
||||
fn safe_env_var_name(name: &str) -> bool {
|
||||
lazy_static! {
|
||||
static ref SAFE_NAME: Regex = Regex::new(r"^[[:word:]]+$").expect("Invalid regex!");
|
||||
}
|
||||
SAFE_NAME.is_match(name.as_bytes())
|
||||
}
|
||||
|
||||
impl TmuxCommand {
|
||||
pub fn get_command_string(&self) -> String {
|
||||
// All commands must end with `\n`
|
||||
match self {
|
||||
TmuxCommand::GetPrimaryWindowPane => format!(
|
||||
"list-panes -F \"#{{?pane_active,{PRIMARY_WINDOW_PANE_PREFIX}: ,}}#{{window_id}} #{{pane_id}}\"\n"
|
||||
),
|
||||
TmuxCommand::RunInBackgroundWindow {
|
||||
current_directory_path,
|
||||
command,
|
||||
environment_variables,
|
||||
command_id,
|
||||
} => {
|
||||
// We pass the command to tmux wrapped in single quotes. The tmux control mode interface
|
||||
// interprets escapes in a bash style, so bash-escape any single quotes in the command.
|
||||
// Note that we should always use bash-style escapes here regardless of the current shell,
|
||||
// because this command is going to tmux control mode.
|
||||
let escaped_command = escape_single_quotes(command);
|
||||
let has_new_line = escaped_command.contains('\n');
|
||||
debug_assert!(
|
||||
!has_new_line,
|
||||
"Tmux control mode commands must take place on one line: `{escaped_command}`"
|
||||
);
|
||||
if has_new_line {
|
||||
log::error!(
|
||||
"Tmux control mode command contains a newline: `{escaped_command}`"
|
||||
);
|
||||
}
|
||||
// It's highly unreliable to try to strip newlines and make sure commands are still
|
||||
// valid, so we'd rather ensure no commands have newlines in them.
|
||||
let escaped_command = escaped_command.replace('\n', r"\n");
|
||||
|
||||
let set_directory = if let Some(current_directory_path) = current_directory_path {
|
||||
let escaped_path = escape_single_quotes(current_directory_path);
|
||||
format!("-c '{escaped_path}'")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut set_env_vars = String::new();
|
||||
for (name, value) in environment_variables.iter().flatten() {
|
||||
if safe_env_var_name(name) {
|
||||
set_env_vars.push_str("-e ");
|
||||
set_env_vars.push_str(name);
|
||||
set_env_vars.push_str("='");
|
||||
set_env_vars.push_str(&escape_single_quotes(value));
|
||||
set_env_vars.push_str("' ");
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a tmux command string. Tmux control mode will first parse this string with a sh-like
|
||||
// parser. The parsed command will be executed in a new interactive terminal session.
|
||||
// Some things to note:
|
||||
// - This also prints formatted window info with the new window id and pane id with `-PF "background window: #{window_id} #{pane_id}"`.
|
||||
// - This must be kept in sync with command output parsing in TmuxPerformer::tmux_message.
|
||||
// - The output is piped through `cat` to prevent the command from being directly attached
|
||||
// to a pty (and therefore risking it running in an interactive mode).
|
||||
// - This sleeps for 1 second after execution to work around tmux bug which clips largs outputs
|
||||
// when the window exits immediately.
|
||||
// - There's a newline at the end so control mode will start running the command.
|
||||
let newline = "\n";
|
||||
format!(
|
||||
r#"new-window -d {set_directory} {set_env_vars} -PF "{BACKGROUND_WINDOW_PREFIX}: #{{window_id}} #{{pane_id}}" '(builtin echo -n "^^^{command_id}|||"; {escaped_command}; builtin echo "|||$?\$\$\$")|command cat; command sleep 1'{newline}"#
|
||||
)
|
||||
}
|
||||
TmuxCommand::UpdateClientSize { num_rows, num_cols } => {
|
||||
format!("refresh-client -C {num_cols},{num_rows}\n")
|
||||
}
|
||||
TmuxCommand::SetDestroyUnattached => "set destroy-unattached on\n".to_string(),
|
||||
TmuxCommand::SetWindowSizeToSmallest => "set window-size smallest\n".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TmuxCommandResponse {
|
||||
SetPrimaryWindowPane { window_id: u32, pane_id: u32 },
|
||||
BackgroundWindow { window_id: u32, pane_id: u32 },
|
||||
}
|
||||
|
||||
pub fn parse_command(line: Vec<u8>) -> Option<TmuxCommandResponse> {
|
||||
lazy_static! {
|
||||
pub static ref PRIMARY_WINDOW_PANE_REGEX: Regex = {
|
||||
let pattern = format!(
|
||||
r"^{PRIMARY_WINDOW_PANE_PREFIX}: @([[:digit:]]+) %([[:digit:]]+)$"
|
||||
);
|
||||
Regex::new(&pattern).expect("invalid regex")
|
||||
};
|
||||
|
||||
// Must be kept in sync with the tmux command in TmuxExecutor::execute_command_internal.
|
||||
pub static ref BACKGROUND_WINDOW_REGEX: Regex = {
|
||||
let pattern = format!(
|
||||
r"^{BACKGROUND_WINDOW_PREFIX}: @([[:digit:]]+) %([[:digit:]]+)$"
|
||||
);
|
||||
Regex::new(&pattern).expect("invalid regex")
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(captures) = BACKGROUND_WINDOW_REGEX.captures(&line) {
|
||||
let window_id: u32 = parse_ascii_u32(&captures[1])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
let pane_id: u32 = parse_ascii_u32(&captures[2])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
return Some(TmuxCommandResponse::BackgroundWindow { window_id, pane_id });
|
||||
} else if let Some(captures) = PRIMARY_WINDOW_PANE_REGEX.captures(&line) {
|
||||
let window_id: u32 = parse_ascii_u32(&captures[1])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
let pane_id: u32 = parse_ascii_u32(&captures[2])
|
||||
.expect("impossible: encountered non-ASCII digit in ASCII digit pattern");
|
||||
return Some(TmuxCommandResponse::SetPrimaryWindowPane { window_id, pane_id });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Tmux control mode uses bash-style escaping, which replaces each single
|
||||
/// quote with a '"'"' sequence. The first single quote completes the
|
||||
/// single quoted string to the left, the next three characters: "'" evaluate
|
||||
/// to a literal single quote in bash/zsh, and then the final single quote
|
||||
/// starts a new single-quoted string to the right. Effectively, this
|
||||
/// concatenates the left single-quoted string, a literal single quote char,
|
||||
/// and the right single-quoted string.
|
||||
fn escape_single_quotes(command: &str) -> String {
|
||||
command.replace('\'', r#"'"'"'"#)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
pub mod commands;
|
||||
pub mod parser;
|
||||
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
||||
use crate::util::parse_ascii_u32;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::bytes::{Regex, RegexBuilder};
|
||||
|
||||
pub enum ControlModeEvent {
|
||||
/// This event is sent when the control mode has started
|
||||
/// we don't have enough info from the tmux output to know
|
||||
/// the primary pane or window yet.
|
||||
Starting,
|
||||
/// Control Mode will inform us of the primary pane and window,
|
||||
/// at which point we can safely direct input to the appropriate
|
||||
/// panel.
|
||||
ControlModeReady {
|
||||
primary_window: u32,
|
||||
primary_pane: u32,
|
||||
},
|
||||
/// This event is sent when Control Mode informs us of pane output
|
||||
/// that is coming from a pane which is not the primary pane.
|
||||
BackgroundPaneOutput { pane: u32, byte: u8 },
|
||||
/// This event is sent when Control Mode has been exited.
|
||||
Exited,
|
||||
}
|
||||
|
||||
pub fn format_input(pane: u32, input: &[u8]) -> String {
|
||||
let mut formatted = String::new();
|
||||
|
||||
for chunk in input.chunks(1000) {
|
||||
formatted.push_str(&format!("send-keys -Ht %{pane}"));
|
||||
for byte in chunk {
|
||||
formatted.push_str(&format!(" {byte:X}"));
|
||||
}
|
||||
formatted.push('\n');
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
pub fn parse_generator_output(input: &[u8]) -> Option<ExecutedExecutorCommandEvent> {
|
||||
lazy_static! {
|
||||
static ref GENERATOR_OUTPUT_REGEX: Regex =
|
||||
RegexBuilder::new(r"\^\^\^(.+?)\|\|\|(.*?)\|\|\|(\d+)\$\$\$")
|
||||
.dot_matches_new_line(true)
|
||||
.unicode(false)
|
||||
.build()
|
||||
.unwrap();
|
||||
/// tmux adds a carriage return to newlines that it prints, so remove that here.
|
||||
static ref NEWLINE_REGEX: Regex = Regex::new(r"\r\n").expect("Invalid regex");
|
||||
}
|
||||
|
||||
GENERATOR_OUTPUT_REGEX.captures(input).and_then(|caps| {
|
||||
let command_id = std::str::from_utf8(&caps[1]).ok()?.to_string();
|
||||
let output = NEWLINE_REGEX.replace_all(&caps[2], b"\n").to_vec();
|
||||
let exit_code = parse_ascii_u32(&caps[3])? as usize;
|
||||
|
||||
Some(ExecutedExecutorCommandEvent {
|
||||
command_id,
|
||||
output,
|
||||
exit_code,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_generator_output() {
|
||||
let input = b"^^^cmd1|||output|||0$$$";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_some());
|
||||
let event = result.unwrap();
|
||||
assert_eq!(event.command_id, "cmd1");
|
||||
assert_eq!(event.output, b"output");
|
||||
assert_eq!(event.exit_code, 0);
|
||||
|
||||
let input = b"^^^cmd2|||multi\nline\noutput|||255$$$";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_some());
|
||||
let event = result.unwrap();
|
||||
assert_eq!(event.command_id, "cmd2");
|
||||
assert_eq!(event.output, b"multi\nline\noutput");
|
||||
assert_eq!(event.exit_code, 255);
|
||||
|
||||
let input = b"invalid input";
|
||||
let result = parse_generator_output(input);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! This module parses output from tmux control mode.
|
||||
//!
|
||||
//! Control mode is a special interface to tmux that allows programs to interact with tmux
|
||||
//! in a structured manner, receiving information about windows, panes, and the state
|
||||
//! of the server in a machine-readable format.
|
||||
//!
|
||||
//! Refer to the tmux control mode protocol documentation for more details:
|
||||
//! https://github.com/tmux/tmux/wiki/Control-Mode
|
||||
|
||||
use crate::util::AsciiDebug;
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum TmuxMessage {
|
||||
/// This is output from a tmux command, like send-keys or new-window. This is different from
|
||||
/// pane output, which is handled separately.
|
||||
CommandOutput {
|
||||
/// Commands can succeed or fail, which is captured by the Result variant, and can have
|
||||
/// multiple lines of output.
|
||||
output_lines: Result<Vec<Vec<u8>>, Vec<Vec<u8>>>,
|
||||
},
|
||||
WindowClose {
|
||||
window_id: u32,
|
||||
},
|
||||
Exit,
|
||||
/// Only used in development.
|
||||
Unknown {
|
||||
tag: Vec<u8>,
|
||||
rest: Vec<u8>,
|
||||
},
|
||||
ParseError {
|
||||
message: &'static str,
|
||||
byte: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Debug impl which formats the byte vecs as readable unicode strings.
|
||||
impl std::fmt::Debug for TmuxMessage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TmuxMessage::CommandOutput {
|
||||
output_lines: output,
|
||||
} => match output {
|
||||
Ok(v) => {
|
||||
let output: Vec<_> = v.iter().map(|vec| AsciiDebug(vec)).collect();
|
||||
f.debug_struct("CommandOutput")
|
||||
.field("output", &format!("Ok({output:?})"))
|
||||
.finish()
|
||||
}
|
||||
Err(e) => {
|
||||
let errors: Vec<_> = e.iter().map(|vec| AsciiDebug(vec)).collect();
|
||||
f.debug_struct("CommandOutput")
|
||||
.field("output", &format!("Err({errors:?})"))
|
||||
.finish()
|
||||
}
|
||||
},
|
||||
TmuxMessage::Exit => write!(f, "Exit"),
|
||||
TmuxMessage::Unknown { tag, rest } => f
|
||||
.debug_struct("Unknown")
|
||||
.field("tag", &AsciiDebug(tag))
|
||||
.field("rest", &AsciiDebug(rest))
|
||||
.finish(),
|
||||
TmuxMessage::ParseError { message, byte } => f
|
||||
.debug_struct("ParseError")
|
||||
.field("message", message)
|
||||
.field("byte", byte)
|
||||
.finish(),
|
||||
TmuxMessage::WindowClose { window_id } => f
|
||||
.debug_struct("WindowClose")
|
||||
.field("window_id", window_id)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs actions sent by `TmuxControlModeParser`.
|
||||
///
|
||||
/// The functions in this trait are called by the parser immediately upon parsing relevant input.
|
||||
pub trait TmuxControlModeHandler {
|
||||
/// This is called by the parser whenever a byte of output from a particular pane is parsed.
|
||||
fn pane_output(&mut self, pane: u32, byte: u8);
|
||||
|
||||
/// This is called by the parser for all other tmux messages, as soon as they've been parsed.
|
||||
/// See `TmuxMessage` for more details.
|
||||
fn tmux_control_mode_message(&mut self, message: TmuxMessage);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ParserState {
|
||||
BeginningOfLine,
|
||||
ReadingTag {
|
||||
// The longest known tag is 'unlinked-window-renamed', which is 23 characters long.
|
||||
tag: [u8; 23],
|
||||
len: u8,
|
||||
},
|
||||
|
||||
TagExit,
|
||||
TagUnknown {
|
||||
tag: Vec<u8>, // Only set in debug builds
|
||||
args: Vec<u8>, // Only set in debug builds
|
||||
},
|
||||
|
||||
TagBegin,
|
||||
ReadingCommandOutput {
|
||||
current_line: Vec<u8>,
|
||||
lines: Vec<Vec<u8>>,
|
||||
},
|
||||
|
||||
TagOutput {
|
||||
maybe_pane: Option<u32>,
|
||||
},
|
||||
ReadingPaneOutput {
|
||||
pane: u32,
|
||||
maybe_escape_sequence: Option<EscapeSequence>,
|
||||
},
|
||||
|
||||
TagWindowClose {
|
||||
maybe_window: Option<u32>,
|
||||
},
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EscapeSequence {
|
||||
char: u8,
|
||||
remaining_digits: u8,
|
||||
}
|
||||
|
||||
/// This parses output from tmux control mode. It expects the output to be well formed and does a
|
||||
/// best-effort job to identify, report, and recover from errors.
|
||||
/// Specifically:
|
||||
/// - The parser correctly parses all well-formed output.
|
||||
/// - The parser will not crash or infinite loop, whether or not the input is well-formed.
|
||||
/// - Some mal-formed input may result in an incorrect parse, but most will result in a parse error.
|
||||
/// - If the parser continues to get input after a parse error, it will attempt to recover after
|
||||
/// the next newline.
|
||||
///
|
||||
/// N.B. When working with control mode output, it's important to understand the distinction
|
||||
/// between tmux command output (e.g. the output of running the `list-windows` tmux command) with
|
||||
/// tmux pane output (e.g. the output produced by running `ls` in pane 0).
|
||||
///
|
||||
/// For more information on the expected output format, see: https://github.com/tmux/tmux/wiki/Control-Mode
|
||||
#[derive(Debug)]
|
||||
pub struct TmuxControlModeParser {
|
||||
state: ParserState,
|
||||
}
|
||||
|
||||
impl TmuxControlModeParser {
|
||||
pub fn new() -> Self {
|
||||
TmuxControlModeParser {
|
||||
state: ParserState::BeginningOfLine,
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary interface to the parser. Takes a handler and the next byte of output, and
|
||||
/// calls one of the handler methods when it parses a byte of pane output or a complete tmux
|
||||
/// message.
|
||||
pub fn advance(&mut self, handler: &mut impl TmuxControlModeHandler, byte: u8) {
|
||||
if byte == b'\r' {
|
||||
// This should only ever appear directly before a \n and it simplifies parsing
|
||||
// if we just discard carriage returns and only look for newlines.
|
||||
return;
|
||||
}
|
||||
match &mut self.state {
|
||||
ParserState::BeginningOfLine => {
|
||||
// Input prior to this state: ""
|
||||
// Input parsed in this state: "%"
|
||||
if byte != b'%' {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Received non-% character at the beginning of a line",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = ParserState::ReadingTag {
|
||||
tag: Default::default(),
|
||||
len: 0,
|
||||
};
|
||||
}
|
||||
ParserState::ReadingTag { tag, len } => {
|
||||
// Input prior to this state: "%"
|
||||
// Input parsed in this state: "%[tag]"
|
||||
let current_len = *len as usize;
|
||||
if byte == b' ' || byte == b'\n' {
|
||||
// A space or a new line means we've reached the end of the tag (e.g.
|
||||
// "%something").
|
||||
match &tag[..current_len] {
|
||||
b"begin" => {
|
||||
self.state = ParserState::TagBegin;
|
||||
}
|
||||
b"exit" => {
|
||||
self.state = ParserState::TagExit;
|
||||
}
|
||||
b"output" => {
|
||||
self.state = ParserState::TagOutput { maybe_pane: None };
|
||||
}
|
||||
b"window-close" | b"unlinked-window-close" => {
|
||||
self.state = ParserState::TagWindowClose { maybe_window: None };
|
||||
}
|
||||
_ => {
|
||||
self.state = ParserState::TagUnknown {
|
||||
tag: if cfg!(debug_assertions) {
|
||||
// We only care about the contents of unknown tags in debug
|
||||
// builds.
|
||||
tag[..current_len].to_owned()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
args: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
// The parser is now set up to parse the particular message based on the tag we
|
||||
// matched. That message may be parsed differently depending on whether we've
|
||||
// encountered a space or a newline, so we call into this method again to
|
||||
// continue the parse with the same byte.
|
||||
return self.advance(handler, byte);
|
||||
}
|
||||
|
||||
// Ignore any bits that are longer than our max tag length.
|
||||
if current_len < tag.len() {
|
||||
tag[current_len] = byte;
|
||||
*len += 1;
|
||||
}
|
||||
}
|
||||
ParserState::Error => {
|
||||
// Input prior to this state: "[any unexpected input]"
|
||||
// Input parsed in this state: "[any more input]\n"
|
||||
//
|
||||
// Discard input until we see a newline and then try to recover.
|
||||
if byte == b'\n' {
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
}
|
||||
|
||||
ParserState::TagExit => {
|
||||
// Input prior to this state: "%exit"
|
||||
// Input parsed in this state: "\n"
|
||||
if byte != b'\n' {
|
||||
report_parse_error(handler, "Extraneous byte after %exit", byte);
|
||||
self.state = ParserState::Error;
|
||||
return;
|
||||
}
|
||||
|
||||
handler.tmux_control_mode_message(TmuxMessage::Exit);
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
|
||||
ParserState::TagUnknown { tag, args } => {
|
||||
// Input prior to this state: "%[unknown tag]"
|
||||
// Input parsed in this state: " [rest of the line]\n"
|
||||
if byte == b'\n' {
|
||||
if cfg!(debug_assertions) {
|
||||
handler.tmux_control_mode_message(TmuxMessage::Unknown {
|
||||
tag: std::mem::take(tag),
|
||||
rest: std::mem::take(args),
|
||||
});
|
||||
}
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
return;
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
// We only care about the contents of unknown tags in debug builds.
|
||||
args.push(byte);
|
||||
}
|
||||
}
|
||||
ParserState::TagBegin => {
|
||||
// Input prior to this state: "%begin"
|
||||
// Input parsed in this state: " [seconds from epoch] [unique command number] [flags]\n"
|
||||
// We don't care about any of the arguments, so discard input until we get a newline.
|
||||
if byte == b'\n' {
|
||||
self.state = ParserState::ReadingCommandOutput {
|
||||
current_line: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
ParserState::ReadingCommandOutput {
|
||||
current_line,
|
||||
lines,
|
||||
} => {
|
||||
// Input prior to this state: "%begin [seconds from epoch] [unique command number] [flags]\n"
|
||||
// Input parsed in this state: some number of "[command output line]\n" followed by "%end\n" or "%error\n"
|
||||
if byte == b'\n' {
|
||||
// Check to see if command output is complete.
|
||||
if current_line.starts_with(b"%end") {
|
||||
handler.tmux_control_mode_message(TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(std::mem::take(lines)),
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else if current_line.starts_with(b"%error") {
|
||||
handler.tmux_control_mode_message(TmuxMessage::CommandOutput {
|
||||
output_lines: Err(std::mem::take(lines)),
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else {
|
||||
// Command output still ongoing -- append to list of lines.
|
||||
lines.push(std::mem::take(current_line));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In the middle of a line.
|
||||
current_line.push(byte);
|
||||
}
|
||||
ParserState::TagOutput { maybe_pane } => {
|
||||
// Input prior to this state: "%output"
|
||||
// Input parsed in this state: " %[pane id] "
|
||||
if let &mut Some(pane) = maybe_pane {
|
||||
// We're parsing the pane number.
|
||||
if byte.is_ascii_digit() {
|
||||
// Got another digit of the pane number.
|
||||
*maybe_pane = Some(pane * 10 + (byte - b'0') as u32);
|
||||
} else if byte == b' ' {
|
||||
// Pane number finished.
|
||||
self.state = ParserState::ReadingPaneOutput {
|
||||
pane,
|
||||
maybe_escape_sequence: None,
|
||||
};
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-digit character in %output pane number",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
} else {
|
||||
// We haven't started parsing the pane number yet.
|
||||
if byte == b'%' {
|
||||
// Pane number starting.
|
||||
*maybe_pane = Some(0);
|
||||
} else if byte == b' ' {
|
||||
// Ignore spaces.
|
||||
} else {
|
||||
report_parse_error(handler, "Unexpected character after %output", byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParserState::ReadingPaneOutput {
|
||||
pane,
|
||||
maybe_escape_sequence,
|
||||
} => {
|
||||
// Input prior to this state: "%output %[pane id] "
|
||||
// Input parsed in this state: "[escaped command output]\n"
|
||||
//
|
||||
// The escaped output format, according to the docs:
|
||||
// The output has any characters less than ASCII 32 and the \ character replaced
|
||||
// with their octal equivalent, so \ becomes \134. Otherwise, it is exactly what
|
||||
// the application running in the pane sent to tmux. It may not be valid UTF-8 and
|
||||
// may contain escape sequences which will be as expected by tmux (so for
|
||||
// TERM=screen or TERM=tmux).
|
||||
if let Some(escape_sequence) = maybe_escape_sequence {
|
||||
match byte {
|
||||
b'0'..=b'8' => {
|
||||
escape_sequence.remaining_digits -= 1;
|
||||
let octal_digit = byte - b'0';
|
||||
// Put each octal digit in the right spot.
|
||||
// The left shift is equivalent to a multiplication by
|
||||
// (8^remaining_digits).
|
||||
escape_sequence.char |=
|
||||
octal_digit << (escape_sequence.remaining_digits * 3);
|
||||
|
||||
if escape_sequence.remaining_digits == 0 {
|
||||
handler.pane_output(*pane, escape_sequence.char);
|
||||
*maybe_escape_sequence = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Not 0-8 in escape sequence
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-octal digit found in output escape sequence",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match byte {
|
||||
b'\n' => {
|
||||
// Pane output over
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
}
|
||||
b'\\' => {
|
||||
// Begin char escape
|
||||
*maybe_escape_sequence = Some(EscapeSequence {
|
||||
char: 0,
|
||||
remaining_digits: 3,
|
||||
})
|
||||
}
|
||||
byte if byte < 32 => {
|
||||
// All bytes < 32 are supposed to be escaped in output mode.
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Unescaped character < ASCII 32 found in output",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
byte => {
|
||||
// Standard input character
|
||||
handler.pane_output(*pane, byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ParserState::TagWindowClose { maybe_window } => {
|
||||
// Input prior to this state: "%window-close" | "%unlinked-window-close"
|
||||
// Input parsed in this state: " @[window id]\n"
|
||||
if let &mut Some(window) = maybe_window {
|
||||
// We're parsing the window number.
|
||||
if byte.is_ascii_digit() {
|
||||
// Got another digit of the pane number.
|
||||
*maybe_window = Some(window * 10 + (byte - b'0') as u32);
|
||||
} else if byte == b'\n' {
|
||||
// Message finished.
|
||||
handler.tmux_control_mode_message(TmuxMessage::WindowClose {
|
||||
window_id: window,
|
||||
});
|
||||
self.state = ParserState::BeginningOfLine;
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Non-digit character in %window-close window number",
|
||||
byte,
|
||||
);
|
||||
self.state = ParserState::Error;
|
||||
}
|
||||
} else {
|
||||
// We haven't started parsing the pane number yet.
|
||||
if byte == b'@' {
|
||||
// Window number starting.
|
||||
*maybe_window = Some(0);
|
||||
} else if byte == b' ' {
|
||||
// Ignore spaces.
|
||||
} else {
|
||||
report_parse_error(
|
||||
handler,
|
||||
"Unexpected character after %window-close",
|
||||
byte,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TmuxControlModeParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn report_parse_error(handler: &mut impl TmuxControlModeHandler, message: &'static str, byte: u8) {
|
||||
handler.tmux_control_mode_message(TmuxMessage::ParseError { message, byte })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parser_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,326 @@
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct TestHandler {
|
||||
output: HashMap<u32, Vec<u8>>,
|
||||
messages: Vec<TmuxMessage>,
|
||||
}
|
||||
|
||||
impl TestHandler {
|
||||
fn new() -> Self {
|
||||
TestHandler {
|
||||
output: HashMap::new(),
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TmuxControlModeHandler for TestHandler {
|
||||
fn pane_output(&mut self, pane: u32, byte: u8) {
|
||||
self.output.entry(pane).or_default().push(byte);
|
||||
}
|
||||
|
||||
fn tmux_control_mode_message(&mut self, message: TmuxMessage) {
|
||||
self.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_output_ok() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\ndummy output\n%end\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(vec![b"dummy output".to_vec()])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_command_output_err() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\ndummy error\n%error\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Err(vec![b"dummy error".to_vec()])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%exit\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(&handler.messages[0], &TmuxMessage::Exit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%unknown something\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"unknown".to_vec(),
|
||||
rest: b" something".to_vec(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_message() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"non-percent\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'n');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 here is some output\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
handler.output.get(&0),
|
||||
Some(&b"here is some output".to_vec())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_escape_sequence() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %1 \\1345\\015\\012\\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.output.get(&1), Some(&b"\\5\r\n".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_multiline() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 first line\\012second line\\012third line\\012\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
handler.output.get(&0),
|
||||
Some(&b"first line\nsecond line\nthird line\n".to_vec())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_split() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 one\n%output %0 two\n%output %0 three\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.output.get(&0), Some(&b"onetwothree".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_incomplete() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 incomplete";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
// Output should be written immediately.
|
||||
assert_eq!(handler.output.get(&0), Some(&b"incomplete".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_incomplete_escape() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 incomplete\\01";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
// Output should be written as soon as it's ready. Incomplete escape sequences should not be
|
||||
// written.
|
||||
assert_eq!(handler.output.get(&0), Some(&b"incomplete".to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_invalid_escape_sequence() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 \\a";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'a');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_output_invalid_character() {
|
||||
// Characters below ASCII 32 should be escaped in the output. Tab is ASCII 9.
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%output %0 \t";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 1);
|
||||
match &handler.messages[0] {
|
||||
&TmuxMessage::ParseError { message: _, byte } => {
|
||||
assert_eq!(byte, b'\t');
|
||||
}
|
||||
_ => panic!("Expected Error message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_begin_without_end() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = b"%begin 1622462330 1\nsome output\n";
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_example_output() {
|
||||
let mut parser = TmuxControlModeParser::new();
|
||||
let mut handler = TestHandler::new();
|
||||
|
||||
let input = br#"%begin 1578920019 258 0
|
||||
%end 1578920019 258 0
|
||||
%window-add @1
|
||||
%sessions-changed
|
||||
%session-changed $1 1
|
||||
%window-renamed @1 tmux
|
||||
%output %1 nicholas@yelena:~$
|
||||
%window-renamed @1 ksh
|
||||
%exit
|
||||
"#;
|
||||
for &byte in input {
|
||||
parser.advance(&mut handler, byte);
|
||||
}
|
||||
|
||||
assert_eq!(handler.messages.len(), 7);
|
||||
assert_eq!(
|
||||
&handler.messages[0],
|
||||
&TmuxMessage::CommandOutput {
|
||||
output_lines: Ok(vec![])
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[1],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-add".to_vec(),
|
||||
rest: b" @1".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[2],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"sessions-changed".to_vec(),
|
||||
rest: b"".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[3],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"session-changed".to_vec(),
|
||||
rest: b" $1 1".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[4],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-renamed".to_vec(),
|
||||
rest: b" @1 tmux".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
&handler.messages[5],
|
||||
&TmuxMessage::Unknown {
|
||||
tag: b"window-renamed".to_vec(),
|
||||
rest: b" @1 ksh".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(&handler.messages[6], &TmuxMessage::Exit);
|
||||
assert_eq!(
|
||||
handler.output.get(&1),
|
||||
Some(&b"nicholas@yelena:~$".to_vec())
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user