use rmux_proto::TerminalSize;
use crate::input::{InputParser, InputState};
use crate::screen::Screen;
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 {
Self {
parser: InputParser::new(),
screen: Screen::new(size, history_limit),
}
}
#[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 set_utf8_config(&mut self, config: Utf8Config) {
self.screen.set_utf8_config(config);
}
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);
}
#[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()
}
#[must_use]
pub(crate) fn pending_bytes(&self) -> Vec<u8> {
self.parser.pending_bytes()
}
#[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) {
self.parser = InputParser::new();
}
}
#[cfg(test)]
mod tests {
use super::{TerminalParser, TerminalParserState};
use crate::utf8::Utf8Config;
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 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));
}
}