From ec55a23398dc9348c081175800810b681e4ff8f8 Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Sun, 1 Nov 2020 18:43:44 -0800 Subject: [PATCH] tmux: add tmux-cc crate This crate knows how to parse data returned from the `tmux -CC` protocol. refs: https://github.com/wez/wezterm/issues/336 --- Cargo.lock | 146 ++++++++++++++- Cargo.toml | 2 +- tmux-cc/Cargo.toml | 17 ++ tmux-cc/src/lib.rs | 419 ++++++++++++++++++++++++++++++++++++++++++ tmux-cc/src/tmux.pest | 27 +++ 5 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 tmux-cc/Cargo.toml create mode 100644 tmux-cc/src/lib.rs create mode 100644 tmux-cc/src/tmux.pest diff --git a/Cargo.lock b/Cargo.lock index 2c24705e5ef..154d892dcf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,13 +441,34 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array 0.12.3", +] + [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.4", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", ] [[package]] @@ -502,6 +523,12 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820" +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + [[package]] name = "bytemuck" version = "1.4.1" @@ -952,13 +979,22 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.3", +] + [[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.4", ] [[package]] @@ -1114,6 +1150,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + [[package]] name = "fastrand" version = "1.4.0" @@ -1381,6 +1423,15 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.4" @@ -1922,6 +1973,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "match_cfg" version = "0.1.0" @@ -2348,6 +2405,12 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "260e51e7efe62b592207e9e13a68e43692a7a279171d6ba57abd208bf23645ad" +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + [[package]] name = "opaque-debug" version = "0.3.0" @@ -2524,6 +2587,49 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2 1.0.24", + "quote 1.0.7", + "syn 1.0.42", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1", +] + [[package]] name = "phf" version = "0.8.0" @@ -3248,17 +3354,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + [[package]] name = "sha2" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2933378ddfeda7ea26f48c555bdad8bb446bf8a3d17832dc83e380d444cfb8c1" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpuid-bool", - "digest", - "opaque-debug", + "digest 0.9.0", + "opaque-debug 0.3.0", ] [[package]] @@ -3688,6 +3806,18 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "238ce071d267c5710f9d31451efec16c5ee22de34df17cc05e56cbc92e967117" +[[package]] +name = "tmux-cc" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pest", + "pest_derive", + "pretty_assertions", + "pretty_env_logger", +] + [[package]] name = "toml" version = "0.5.6" @@ -3709,6 +3839,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + [[package]] name = "uds_windows" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index f79e489299c..a1aad867880 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["wezterm-mux-server", "wezterm", "wezterm-gui", "strip-ansi-escapes"] +members = ["wezterm-mux-server", "wezterm", "wezterm-gui", "strip-ansi-escapes", "tmux-cc"] [profile.release] opt-level = 3 diff --git a/tmux-cc/Cargo.toml b/tmux-cc/Cargo.toml new file mode 100644 index 00000000000..cee3204da04 --- /dev/null +++ b/tmux-cc/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tmux-cc" +version = "0.1.0" +authors = ["Wez Furlong "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +log = "0.4" +pest = "2.1" +pest_derive = "2.1" + +[dev-dependencies] +pretty_env_logger = "0.4" +pretty_assertions = "0.6" diff --git a/tmux-cc/src/lib.rs b/tmux-cc/src/lib.rs new file mode 100644 index 00000000000..b6d31fd33e5 --- /dev/null +++ b/tmux-cc/src/lib.rs @@ -0,0 +1,419 @@ +use parser::Rule; +use pest::Parser as _; + +mod parser { + use pest_derive::Parser; + #[derive(Parser)] + #[grammar = "tmux.pest"] + pub struct TmuxParser; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Begin { + timestamp: i64, + number: u64, + flags: i64, + }, + End { + timestamp: i64, + number: u64, + flags: i64, + }, + Error(String), + Output { + pane: u64, + text: String, + }, + Exit, + SessionsChanged, + SessionChanged { + session: u64, + name: String, + }, + PaneModeChanged { + pane: u64, + }, + WindowAdd { + window: u64, + }, +} + +fn parse_line(line: &str) -> anyhow::Result { + let mut pairs = parser::TmuxParser::parse(Rule::line_entire, line)?; + let pair = pairs.next().ok_or_else(|| anyhow::anyhow!("no pairs!?"))?; + match pair.as_rule() { + Rule::begin => { + let mut pairs = pair.into_inner(); + let timestamp = pairs.next().unwrap().as_str().parse::()?; + let number = pairs.next().unwrap().as_str().parse::()?; + let flags = pairs.next().unwrap().as_str().parse::()?; + Ok(Event::Begin { + timestamp, + number, + flags, + }) + } + Rule::end => { + let mut pairs = pair.into_inner(); + let timestamp = pairs.next().unwrap().as_str().parse::()?; + let number = pairs.next().unwrap().as_str().parse::()?; + let flags = pairs.next().unwrap().as_str().parse::()?; + Ok(Event::End { + timestamp, + number, + flags, + }) + } + Rule::error => { + let mut pairs = pair.into_inner(); + Ok(Event::Error(unvis(pairs.next().unwrap().as_str())?)) + } + Rule::exit => Ok(Event::Exit), + Rule::sessions_changed => Ok(Event::SessionsChanged), + Rule::pane_mode_changed => { + let mut pairs = pair.into_inner(); + let pane = pairs.next().unwrap().as_str().parse()?; + Ok(Event::PaneModeChanged { pane }) + } + Rule::window_add => { + let mut pairs = pair.into_inner(); + let window = pairs.next().unwrap().as_str().parse()?; + Ok(Event::WindowAdd { window }) + } + Rule::output => { + let mut pairs = pair.into_inner(); + let pane = pairs.next().unwrap().as_str().parse()?; + let text = unvis(pairs.next().unwrap().as_str())?; + Ok(Event::Output { pane, text }) + } + Rule::session_changed => { + let mut pairs = pair.into_inner(); + let session = pairs.next().unwrap().as_str().parse()?; + let name = unvis(pairs.next().unwrap().as_str())?; + Ok(Event::SessionChanged { session, name }) + } + Rule::any_text | Rule::line | Rule::line_entire | Rule::EOI | Rule::number => { + unreachable!() + } + } +} + +/// Decode OpenBSD `vis` encoded strings +/// See: https://github.com/tmux/tmux/blob/486ce9b09855ae30a2bf5e576cb6f7ad37792699/compat/unvis.c +fn unvis(s: &str) -> anyhow::Result { + enum State { + Ground, + Start, + Meta, + Meta1, + Ctrl(u8), + Octal2(u8), + Octal3(u8), + } + + let mut state = State::Ground; + let mut result: Vec = vec![]; + let mut bytes = s.as_bytes().iter(); + + fn is_octal(b: u8) -> bool { + b >= b'0' && b <= b'7' + } + + fn unvis_byte(b: u8, state: &mut State, result: &mut Vec) -> anyhow::Result { + match state { + State::Ground => { + if b == b'\\' { + *state = State::Start; + } else { + result.push(b); + } + } + + State::Start => { + match b { + b'\\' => { + result.push(b'\\'); + *state = State::Ground; + } + b'0' | b'1' | b'2' | b'3' | b'4' | b'5' | b'6' | b'7' => { + let value = b - b'0'; + *state = State::Octal2(value); + } + b'M' => { + *state = State::Meta; + } + b'^' => { + *state = State::Ctrl(0); + } + b'n' => { + result.push(b'\n'); + *state = State::Ground; + } + b'r' => { + result.push(b'\r'); + *state = State::Ground; + } + b'b' => { + result.push(b'\x08'); + *state = State::Ground; + } + b'a' => { + result.push(b'\x07'); + *state = State::Ground; + } + b'v' => { + result.push(b'\x0b'); + *state = State::Ground; + } + b't' => { + result.push(b'\t'); + *state = State::Ground; + } + b'f' => { + result.push(b'\x0c'); + *state = State::Ground; + } + b's' => { + result.push(b' '); + *state = State::Ground; + } + b'E' => { + result.push(b'\x1b'); + *state = State::Ground; + } + b'\n' => { + // Hidden newline + // result.push(b'\n'); + *state = State::Ground; + } + b'$' => { + // Hidden marker + *state = State::Ground; + } + _ => { + // Invalid syntax + anyhow::bail!("Invalid \\ escape: {}", b); + } + } + } + + State::Meta => { + if b == b'-' { + *state = State::Meta1; + } else if b == b'^' { + *state = State::Ctrl(0200); + } else { + anyhow::bail!("invalid \\M escape: {}", b); + } + } + + State::Meta1 => { + result.push(b | 0200); + *state = State::Ground; + } + + State::Ctrl(c) => { + if b == b'?' { + result.push(*c | 0177); + } else { + result.push((b & 037) | *c); + } + *state = State::Ground; + } + + State::Octal2(prior) => { + if is_octal(b) { + // It's the second in a 2 or 3 byte octal sequence + let value = (*prior << 3) + (b - b'0'); + *state = State::Octal3(value); + } else { + // Prior character was a single octal value + result.push(*prior); + *state = State::Ground; + // re-process the current byte + return Ok(true); + } + } + + State::Octal3(prior) => { + if is_octal(b) { + // It's the third in a 3 byte octal sequence + let value = (*prior << 3) + (b - b'0'); + result.push(value); + *state = State::Ground; + } else { + // Prior was a 2-byte octal sequence + result.push(*prior); + *state = State::Ground; + // re-process the current byte + return Ok(true); + } + } + } + // Don't process this byte again + Ok(false) + } + + while let Some(&b) = bytes.next() { + let again = unvis_byte(b, &mut state, &mut result)?; + if again { + unvis_byte(b, &mut state, &mut result)?; + } + } + + String::from_utf8(result) + .map_err(|err| anyhow::anyhow!("Unescaped string is not valid UTF8: {}", err)) +} + +pub struct Parser { + buffer: Vec, +} + +impl Parser { + pub fn new() -> Self { + Self { buffer: vec![] } + } + + pub fn advance_byte(&mut self, c: u8) -> Option { + if c == b'\n' { + self.process_line() + } else { + self.buffer.push(c); + None + } + } + + pub fn advance_string(&mut self, s: &str) -> Vec { + self.advance_bytes(s.as_bytes()) + } + + pub fn advance_bytes(&mut self, bytes: &[u8]) -> Vec { + let mut events = vec![]; + for &b in bytes { + if let Some(event) = self.advance_byte(b) { + events.push(event); + } + } + events + } + + fn process_line(&mut self) -> Option { + if self.buffer.last() == Some(&b'\r') { + self.buffer.pop(); + } + let result = match std::str::from_utf8(&self.buffer) { + Ok(line) => match parse_line(line) { + Ok(event) => Some(event), + Err(err) => { + log::error!("Unrecognized tmux cc line: {}", err); + None + } + }, + Err(err) => { + log::error!("Failed to parse line from tmux: {}", err); + None + } + }; + self.buffer.clear(); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_parse_line() { + let _ = pretty_env_logger::formatted_builder() + .is_test(true) + .filter_level(log::LevelFilter::Trace) + .try_init(); + + assert_eq!( + Event::Error("doh".to_owned()), + parse_line("%error doh").unwrap() + ); + + assert_eq!( + Event::Begin { + timestamp: 12345, + number: 321, + flags: 0, + }, + parse_line("%begin 12345 321 0").unwrap() + ); + + assert_eq!( + Event::End { + timestamp: 12345, + number: 321, + flags: 0, + }, + parse_line("%end 12345 321 0").unwrap() + ); + } + + #[test] + fn test_parse_sequence() { + let input = b"%sessions-changed +%pane-mode-changed %0 +%begin 1604279270 310 0 +%end 1604279270 310 0 +%window-add @1 +%sessions-changed +%session-changed $1 1 +%output %1 \\033[1m\\033[7m%\\033[27m\\033[1m\\033[0m \\015 \\015 +%output %1 \\033kwez@cube-localdomain:~\\033\\134\\033]2;wez@cube-localdomain:~\\033\\134 +%output %1 \\033]7;file://cube-localdomain/home/wez\\033\\134 +%output %1 \\033[K\\033[?2004h +%exit +"; + + let mut p = Parser::new(); + let events = p.advance_bytes(input); + assert_eq!( + vec![ + Event::SessionsChanged, + Event::PaneModeChanged { pane: 0 }, + Event::Begin { + timestamp: 1604279270, + number: 310, + flags: 0 + }, + Event::End { + timestamp: 1604279270, + number: 310, + flags: 0 + }, + Event::WindowAdd { window: 1 }, + Event::SessionsChanged, + Event::SessionChanged { + session: 1, + name: "1".to_owned(), + }, + Event::Output { + pane: 1, + text: "\x1b[1m\x1b[7m%\x1b[27m\x1b[1m\x1b[0m \r \r".to_owned() + }, + Event::Output { + pane: 1, + text: "\x1bkwez@cube-localdomain:~\x1b\\\x1b]2;wez@cube-localdomain:~\x1b\\" + .to_owned() + }, + Event::Output { + pane: 1, + text: "\x1b]7;file://cube-localdomain/home/wez\x1b\\".to_owned(), + }, + Event::Output { + pane: 1, + text: "\x1b[K\x1b[?2004h".to_owned(), + }, + Event::Exit, + ], + events + ); + } +} diff --git a/tmux-cc/src/tmux.pest b/tmux-cc/src/tmux.pest new file mode 100644 index 00000000000..3811b486dee --- /dev/null +++ b/tmux-cc/src/tmux.pest @@ -0,0 +1,27 @@ +number = { ASCII_DIGIT+ } +any_text = { ANY* } + + +begin = { "%begin" ~ " " ~ number ~ " " ~ number ~ " " ~ number } +end = { "%end" ~ " " ~ number ~ " " ~ number ~ " " ~ number } +error = { "%error" ~ " " ~ any_text } +output = { "%output" ~ " %" ~ number ~ " " ~ any_text } +exit = { "%exit" } +sessions_changed = { "%sessions-changed" } +pane_mode_changed = { "%pane-mode-changed %" ~ number } +window_add = { "%window-add @" ~ number } +session_changed = { "%session-changed $" ~ number ~ " " ~ any_text } + +line = _{ ( + begin | + end | + error | + exit | + output | + pane_mode_changed | + session_changed | + sessions_changed | + window_add +) } + +line_entire = _{ SOI ~ line ~ EOI }