use rmux_proto::TerminalSize;
use crate::input::{InputParser, InputState, OscColourSlot};
use crate::screen::Screen;
use crate::terminal_passthrough::TerminalPassthrough;
use crate::utf8::Utf8Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) enum TerminalParserState {
Ground,
Escape,
Csi,
Dcs,
String,
Ignore,
}
impl From<InputState> for TerminalParserState {
fn from(state: InputState) -> Self {
match state {
InputState::Ground => Self::Ground,
InputState::EscEnter | InputState::EscIntermediate => Self::Escape,
InputState::CsiEnter | InputState::CsiParameter | InputState::CsiIntermediate => {
Self::Csi
}
InputState::CsiIgnore | InputState::DcsIgnore => Self::Ignore,
InputState::DcsEnter
| InputState::DcsParameter
| InputState::DcsIntermediate
| InputState::DcsHandler
| InputState::DcsEscape => Self::Dcs,
InputState::OscString
| InputState::ApcString
| InputState::RenameString
| InputState::ConsumeSt => Self::String,
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) struct TerminalParser {
parser: InputParser,
screen: Screen,
}
#[cfg_attr(not(test), allow(dead_code))]
impl TerminalParser {
#[must_use]
pub(crate) fn new(size: TerminalSize, history_limit: usize) -> Self {
let mut screen = Screen::new(size, history_limit);
screen.set_preserve_alternate_screen_cursor(true);
Self {
parser: InputParser::new(),
screen,
}
}
#[must_use]
pub(crate) fn screen(&self) -> &Screen {
&self.screen
}
pub(crate) fn screen_mut(&mut self) -> &mut Screen {
&mut self.screen
}
pub(crate) fn plain_output_forwarding_safe(&self) -> bool {
self.parser.plain_output_forwarding_safe() && self.screen.plain_output_forwarding_safe()
}
pub(crate) fn set_utf8_config(&mut self, config: Utf8Config) {
self.screen.set_utf8_config(config);
}
pub(crate) fn set_alternate_screen_enabled(&mut self, enabled: bool) {
self.screen.set_alternate_screen_enabled(enabled);
}
pub(crate) fn set_title_rename_enabled(&mut self, enabled: bool) {
self.screen.set_title_rename_enabled(enabled);
}
pub(crate) fn set_input_buffer_limit(&mut self, limit: usize) {
self.parser.set_input_buffer_limit(limit);
}
pub(crate) fn resize(&mut self, size: TerminalSize) {
self.screen.resize(size);
}
pub(crate) fn feed(&mut self, bytes: &[u8]) {
self.parser.parse(bytes, &mut self.screen);
}
pub(crate) fn take_recovery_rebase_required(&mut self) -> bool {
self.parser.take_recovery_rebase_required()
}
#[must_use]
pub(crate) fn parser_state(&self) -> TerminalParserState {
self.parser.state().into()
}
pub(crate) fn take_replies(&mut self) -> Vec<u8> {
self.parser.take_replies()
}
pub(crate) fn take_terminal_passthrough(&mut self) -> Vec<TerminalPassthrough> {
self.screen.take_terminal_passthrough()
}
pub(crate) fn take_terminal_passthrough_dropped_count(&mut self) -> u64 {
self.parser
.take_terminal_passthrough_dropped_count()
.saturating_add(self.screen.take_terminal_passthrough_dropped_count())
}
#[must_use]
pub(crate) fn pending_bytes(&self) -> Vec<u8> {
self.parser.pending_bytes()
}
pub(crate) fn pending_bytes_ref(&self) -> &[u8] {
self.parser.pending_bytes_ref()
}
pub(crate) fn clone_recovery_screen(&self) -> Screen {
self.screen.clone_recovery_state()
}
pub(crate) fn active_cell_state_ansi(&self) -> Vec<u8> {
self.cell_state_ansi(self.parser.cell_state())
}
pub(crate) fn active_cell_state_ansi_bounded(
&self,
max_hyperlink_bytes: usize,
) -> (Vec<u8>, bool) {
self.cell_state_ansi_bounded(self.parser.cell_state(), max_hyperlink_bytes)
}
pub(crate) fn saved_cell_state_ansi(&self) -> Vec<u8> {
self.cell_state_ansi(self.parser.saved_state().cell())
}
pub(crate) fn saved_cell_state_ansi_bounded(
&self,
max_hyperlink_bytes: usize,
) -> (Vec<u8>, bool) {
self.cell_state_ansi_bounded(self.parser.saved_state().cell(), max_hyperlink_bytes)
}
pub(crate) fn saved_cursor_state(&self) -> (u32, u32, bool) {
let saved = self.parser.saved_state();
let (x, y) = saved.cursor_position();
(x, y, saved.origin_mode())
}
pub(crate) fn recovery_parser_state_ansi(&self) -> Vec<u8> {
self.parser.recovery_dynamic_colours_ansi()
}
pub(crate) fn dynamic_colour(&self, slot: OscColourSlot) -> Option<&str> {
self.parser.osc_colour(slot)
}
fn cell_state_ansi(&self, cell: &crate::input::CellState) -> Vec<u8> {
let mut out = self.screen.render_cell_state_ansi(cell);
append_character_sets(cell, &mut out);
out
}
fn cell_state_ansi_bounded(
&self,
cell: &crate::input::CellState,
max_hyperlink_bytes: usize,
) -> (Vec<u8>, bool) {
let (mut out, complete) = self
.screen
.render_cell_state_ansi_bounded(cell, max_hyperlink_bytes);
append_character_sets(cell, &mut out);
(out, complete)
}
#[must_use]
pub(crate) fn ground_timer_active(&self) -> bool {
self.parser.ground_timer_active()
}
pub(crate) fn ground_timer_expired(&mut self) {
self.parser.ground_timer_expired();
}
pub(crate) fn reset_to_ground(&mut self) {
self.parser.reset_to_ground();
}
pub(crate) fn reset_parser(&mut self) {
let input_buffer_limit = self.parser.configured_input_buffer_limit();
self.parser = InputParser::new();
self.parser.set_input_buffer_limit(input_buffer_limit);
}
}
fn append_character_sets(cell: &crate::input::CellState, out: &mut Vec<u8>) {
out.extend_from_slice(if cell.g0set != 0 {
b"\x1b(0"
} else {
b"\x1b(B"
});
out.extend_from_slice(if cell.g1set != 0 {
b"\x1b)0"
} else {
b"\x1b)B"
});
out.push(if cell.set == 0 { 0x0f } else { 0x0e });
}
#[cfg(test)]
mod tests {
use super::{TerminalParser, TerminalParserState};
use crate::input::{mode, ScreenWriter};
use crate::utf8::Utf8Config;
use crate::Screen;
use rmux_proto::TerminalSize;
fn size(cols: u16, rows: u16) -> TerminalSize {
TerminalSize { cols, rows }
}
#[test]
fn ascii_feed_updates_screen() {
let mut parser = TerminalParser::new(size(5, 2), 10);
parser.feed(b"hello");
let screen = parser.screen();
assert_eq!(screen.cursor_position(), (4, 0));
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
}
#[test]
fn pending_bytes_reflect_unfinished_csi() {
let mut parser = TerminalParser::new(size(5, 2), 10);
parser.feed(b"\x1b[1;");
assert_ne!(parser.parser_state(), TerminalParserState::Ground);
let pending = parser.pending_bytes();
assert!(!pending.is_empty(), "pending bytes should buffer mid-CSI");
parser.reset_to_ground();
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
}
#[test]
fn replies_drain_through_take_replies() {
let mut parser = TerminalParser::new(size(5, 2), 10);
parser.feed(b"\x1b[c");
let replies = parser.take_replies();
assert!(!replies.is_empty());
assert!(parser.take_replies().is_empty());
}
#[test]
fn kitty_graphics_passthrough_drains_once() {
let mut parser = TerminalParser::new(size(20, 4), 10);
parser.feed(b"\x1b[2;3H\x1b_Gf=100;AAAA\x1b\\");
let passthroughs = parser.take_terminal_passthrough();
assert_eq!(passthroughs.len(), 1);
assert_eq!(passthroughs[0].cursor_x(), 2);
assert_eq!(passthroughs[0].cursor_y(), 1);
assert_eq!(passthroughs[0].payload(), b"Gf=100;AAAA");
assert!(parser.take_terminal_passthrough().is_empty());
}
#[test]
fn sixel_passthrough_drains_once() {
let mut parser = TerminalParser::new(size(20, 4), 10);
parser.feed(b"\x1b[2;3H\x1bPq#0!10~\x1b\\");
let passthroughs = parser.take_terminal_passthrough();
assert_eq!(passthroughs.len(), 1);
assert_eq!(passthroughs[0].cursor_x(), 2);
assert_eq!(passthroughs[0].cursor_y(), 1);
assert_eq!(passthroughs[0].payload(), b"q#0!10~");
assert!(parser.take_terminal_passthrough().is_empty());
}
#[test]
fn dcs_passthrough_drains_once() {
let mut parser = TerminalParser::new(size(20, 4), 10);
parser.feed(b"\x1b[2;3H\x1bPtmux;\x1b]52;c;QQ==\x07\x1b\\");
let passthroughs = parser.take_terminal_passthrough();
assert_eq!(passthroughs.len(), 1);
assert_eq!(passthroughs[0].cursor_x(), 2);
assert_eq!(passthroughs[0].cursor_y(), 1);
assert_eq!(passthroughs[0].payload(), b"\x1b]52;c;QQ==\x07");
assert!(parser.take_terminal_passthrough().is_empty());
}
#[test]
fn resize_propagates_to_screen() {
let mut parser = TerminalParser::new(size(5, 2), 10);
parser.feed(b"abc");
parser.resize(size(8, 3));
assert_eq!(parser.screen().size().cols, 8);
assert_eq!(parser.screen().size().rows, 3);
}
#[test]
fn empty_feed_keeps_state_ground() {
let mut parser = TerminalParser::new(size(4, 2), 4);
parser.feed(b"");
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
assert!(parser.take_replies().is_empty());
assert_eq!(parser.screen().cursor_position(), (0, 0));
assert_eq!(parser.screen().history_size(), 0);
}
#[test]
fn split_utf8_wide_glyph_byte_by_byte() {
let mut a = TerminalParser::new(size(4, 1), 4);
let bytes = "æ—¥".as_bytes();
for byte in bytes {
a.feed(std::slice::from_ref(byte));
}
assert_eq!(a.parser_state(), TerminalParserState::Ground);
assert!(a.pending_bytes().is_empty());
let mut b = TerminalParser::new(size(4, 1), 4);
b.feed(bytes);
assert_eq!(a.screen().cursor_position(), b.screen().cursor_position());
assert_eq!(a.screen().size(), b.screen().size());
}
#[test]
fn pending_clears_after_completing_sequence() {
let mut parser = TerminalParser::new(size(8, 1), 4);
parser.feed(b"\x1b[1;");
assert_ne!(parser.parser_state(), TerminalParserState::Ground);
parser.feed(b"31m!");
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
}
#[test]
fn screen_mut_allows_history_clear() {
let mut parser = TerminalParser::new(size(4, 1), 8);
parser.feed(b"L1\nL2\nL3\nL4\n");
assert!(parser.screen().history_size() > 0);
parser.screen_mut().clear_history_and_hyperlinks(true);
assert_eq!(parser.screen().history_size(), 0);
}
#[test]
fn set_utf8_config_propagates_to_screen() {
let mut parser = TerminalParser::new(size(8, 1), 4);
parser.set_utf8_config(Utf8Config::default());
parser.set_utf8_config(Utf8Config::default());
parser.feed(b"hi");
assert_eq!(parser.screen().cursor_position(), (2, 0));
}
#[test]
fn ground_timer_lifecycle_is_idempotent() {
let mut parser = TerminalParser::new(size(4, 1), 4);
parser.ground_timer_expired();
parser.reset_to_ground();
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
let _ = parser.ground_timer_active();
}
#[test]
fn resize_to_one_by_one_is_safe() {
let mut parser = TerminalParser::new(size(8, 4), 4);
parser.feed(b"hello");
parser.resize(size(1, 1));
assert_eq!(parser.screen().size().cols, 1);
assert_eq!(parser.screen().size().rows, 1);
parser.feed(b"X");
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
}
#[test]
fn da1_reply_is_drained_only_once() {
let mut parser = TerminalParser::new(size(4, 1), 2);
parser.feed(b"\x1b[c");
let first = parser.take_replies();
assert!(!first.is_empty(), "DA1 must produce a reply");
assert!(parser.take_replies().is_empty());
parser.feed(b"\x1b[c");
assert_eq!(parser.take_replies(), first);
}
#[test]
fn zero_history_limit_does_not_retain_lines() {
let mut parser = TerminalParser::new(size(2, 2), 0);
parser.feed(b"AB\nCD\nEF\n");
assert_eq!(parser.screen().history_size(), 0);
}
#[test]
fn alternate_screen_entry_sets_flag_and_exit_clears_it() {
let mut parser = TerminalParser::new(size(8, 3), 4);
parser.feed(b"primary");
assert!(!parser.screen().is_alternate());
parser.feed(b"\x1b[?1049h");
assert!(parser.screen().is_alternate());
parser.feed(b"\x1b[?1049l");
assert!(!parser.screen().is_alternate());
}
#[test]
fn arbitrary_chunk_boundaries_match_single_feed() {
let bytes: &[u8] = b"abc\x1b[1;31mX\x1b[0m\x1b[?25l\x1b[?25h!";
let mut baseline = TerminalParser::new(size(20, 1), 4);
baseline.feed(bytes);
let baseline_cursor = baseline.screen().cursor_position();
assert_eq!(baseline.parser_state(), TerminalParserState::Ground);
for split in 0..=bytes.len() {
let mut parser = TerminalParser::new(size(20, 1), 4);
parser.feed(&bytes[..split]);
parser.feed(&bytes[split..]);
assert_eq!(
parser.parser_state(),
TerminalParserState::Ground,
"chunked feed split={split} must end in Ground",
);
assert!(
parser.pending_bytes().is_empty(),
"chunked feed split={split} must drain pending bytes",
);
assert_eq!(
parser.screen().cursor_position(),
baseline_cursor,
"chunked feed split={split} diverged from single-shot baseline",
);
}
}
#[test]
fn reset_to_ground_after_partial_csi_drops_pending_without_screen_output() {
let mut parser = TerminalParser::new(size(6, 1), 2);
parser.feed(b"\x1b[31");
assert_ne!(parser.parser_state(), TerminalParserState::Ground);
let cursor_before = parser.screen().cursor_position();
parser.reset_to_ground();
assert_eq!(parser.parser_state(), TerminalParserState::Ground);
assert!(parser.pending_bytes().is_empty());
assert_eq!(parser.screen().cursor_position(), cursor_before);
parser.feed(b"X");
assert_eq!(parser.screen().cursor_position(), (1, 0));
}
#[test]
fn plain_output_forwarding_rejects_pending_wrap_and_non_default_modes() {
let mut pending_wrap = TerminalParser::new(size(8, 3), 4);
assert!(pending_wrap.plain_output_forwarding_safe());
pending_wrap.feed(b"ABCDEFGH");
assert!(!pending_wrap.plain_output_forwarding_safe());
pending_wrap.feed(b"\r");
assert!(pending_wrap.plain_output_forwarding_safe());
for (enable, disable, label) in [
(b"\x1b[4h".as_slice(), b"\x1b[4l".as_slice(), "IRM"),
(
b"\x1b[?2026h".as_slice(),
b"\x1b[?2026l".as_slice(),
"synchronized output",
),
] {
let mut parser = TerminalParser::new(size(8, 3), 4);
parser.feed(enable);
assert!(
!parser.plain_output_forwarding_safe(),
"{label} must disable raw output forwarding"
);
parser.feed(disable);
assert!(
parser.plain_output_forwarding_safe(),
"clearing {label} must restore the safe default state"
);
}
let mut linefeed_newline_mode = TerminalParser::new(size(8, 3), 4);
<Screen as ScreenWriter>::mode_set(linefeed_newline_mode.screen_mut(), mode::MODE_CRLF);
assert!(!linefeed_newline_mode.plain_output_forwarding_safe());
<Screen as ScreenWriter>::mode_clear(linefeed_newline_mode.screen_mut(), mode::MODE_CRLF);
assert!(linefeed_newline_mode.plain_output_forwarding_safe());
let mut input_only_modes = TerminalParser::new(size(8, 3), 4);
input_only_modes.feed(b"\x1b[?1000h\x1b[?2004h");
assert!(
input_only_modes.plain_output_forwarding_safe(),
"mouse tracking and bracketed paste do not change plain output semantics"
);
}
#[test]
fn plain_output_forwarding_rejects_style_hyperlink_and_parser_pending_state() {
let mut sgr = TerminalParser::new(size(8, 3), 4);
sgr.feed(b"\x1b[31m");
assert!(!sgr.plain_output_forwarding_safe());
sgr.feed(b"\x1b[0m");
assert!(sgr.plain_output_forwarding_safe());
let mut hyperlink = TerminalParser::new(size(8, 3), 4);
hyperlink.feed(b"\x1b]8;;https://example.test\x1b\\");
assert!(!hyperlink.plain_output_forwarding_safe());
hyperlink.feed(b"\x1b]8;;\x1b\\");
assert!(hyperlink.plain_output_forwarding_safe());
let mut pending_csi = TerminalParser::new(size(8, 3), 4);
pending_csi.feed(b"\x1b[1;");
assert!(!pending_csi.plain_output_forwarding_safe());
pending_csi.reset_to_ground();
assert!(pending_csi.plain_output_forwarding_safe());
}
#[test]
fn plain_output_forwarding_rejects_non_default_scroll_margins() {
let mut parser = TerminalParser::new(size(8, 4), 4);
parser.feed(b"\x1b[2;3r");
assert_eq!(parser.screen().scroll_region(), (1, 2));
assert!(!parser.plain_output_forwarding_safe());
parser.feed(b"\x1b[r");
assert_eq!(parser.screen().scroll_region(), (0, 3));
assert!(parser.plain_output_forwarding_safe());
}
}