const MAX_CSI_SEQUENCE: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParserState {
Ground,
Escape,
Csi,
String(StringKind),
StringEscape(StringKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StringKind {
Osc,
Other,
}
#[derive(Debug)]
pub(crate) struct ScreenModeTracker {
state: ParserState,
csi: Vec<u8>,
}
impl ScreenModeTracker {
pub(crate) fn new() -> Self {
Self {
state: ParserState::Ground,
csi: Vec::with_capacity(16),
}
}
pub(crate) fn observe(&mut self, input: &[u8]) -> Option<bool> {
let mut current = None;
for &byte in input {
match self.state {
ParserState::Ground => self.consume_ground(byte),
ParserState::Escape => self.consume_escape(byte),
ParserState::Csi => {
if let Some(is_set) = self.consume_csi(byte) {
current = Some(is_set);
}
}
ParserState::String(kind) => self.consume_string(kind, byte),
ParserState::StringEscape(kind) => self.consume_string_escape(kind, byte),
}
}
current
}
fn consume_ground(&mut self, byte: u8) {
if byte == b'\x1b' {
self.state = ParserState::Escape;
}
}
fn consume_escape(&mut self, byte: u8) {
match byte {
b'[' => self.start_csi(),
b']' => self.state = ParserState::String(StringKind::Osc),
b'P' | b'X' | b'^' | b'_' => {
self.state = ParserState::String(StringKind::Other);
}
b'\x1b' => {}
_ => self.state = ParserState::Ground,
}
}
fn start_csi(&mut self) {
self.csi.clear();
self.state = ParserState::Csi;
}
fn consume_csi(&mut self, byte: u8) -> Option<bool> {
match byte {
0x18 | 0x1a => {
self.csi.clear();
self.state = ParserState::Ground;
return None;
}
b'\x1b' => {
self.csi.clear();
self.state = ParserState::Escape;
return None;
}
_ => self.csi.push(byte),
}
if self.csi.len() > MAX_CSI_SEQUENCE {
self.csi.clear();
self.state = ParserState::Ground;
return None;
}
if !(0x40..=0x7e).contains(&byte) {
return None;
}
let selection = parse_1049_selection(&self.csi);
self.csi.clear();
self.state = ParserState::Ground;
selection
}
fn consume_string(&mut self, kind: StringKind, byte: u8) {
match byte {
b'\x07' if kind == StringKind::Osc => self.state = ParserState::Ground,
b'\x1b' => self.state = ParserState::StringEscape(kind),
_ => {}
}
}
fn consume_string_escape(&mut self, kind: StringKind, byte: u8) {
match byte {
b'\\' => self.state = ParserState::Ground,
b'\x07' if kind == StringKind::Osc => self.state = ParserState::Ground,
b'\x1b' => {}
_ => self.state = ParserState::String(kind),
}
}
}
fn parse_1049_selection(sequence: &[u8]) -> Option<bool> {
let (&final_byte, body) = sequence.split_last()?;
let is_set = match final_byte {
b'h' => true,
b'l' => false,
_ => return None,
};
let parameters = body.strip_prefix(b"?")?;
if parameters.is_empty()
|| !parameters
.iter()
.all(|byte| byte.is_ascii_digit() || *byte == b';')
{
return None;
}
parameters
.split(|byte| *byte == b';')
.any(|mode| mode == b"1049")
.then_some(is_set)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracks_fragmented_1049_transitions_and_last_value() {
let mut tracker = ScreenModeTracker::new();
assert_eq!(tracker.observe(b"text\x1b[?10"), None);
assert_eq!(tracker.observe(b"49h\x1b[?25;1049l"), Some(false));
assert_eq!(tracker.observe(b"\x1b[?47h"), None);
}
#[test]
fn skips_osc_dcs_and_tmux_payload_false_positives() {
let mut tracker = ScreenModeTracker::new();
assert_eq!(tracker.observe(b"\x1b]0;title\x1b[?1049h\x07"), None);
assert_eq!(tracker.observe(b"\x1bPpayload\x1b[?1049l\x1b\\"), None);
assert_eq!(tracker.observe(b"\x1bPtmux;\x1b\x1b[?1049h\x1b\\"), None);
assert_eq!(tracker.observe(b"\x1b[?1049h"), Some(true));
}
#[test]
fn non_osc_strings_ignore_bel_and_embedded_1049_until_fragmented_st() {
let mut tracker = ScreenModeTracker::new();
for introducer in [
b"\x1bP".as_slice(),
b"\x1b_".as_slice(),
b"\x1b^".as_slice(),
b"\x1bX".as_slice(),
] {
assert_eq!(tracker.observe(introducer), None);
assert_eq!(tracker.observe(b"payload\x07\x1b[?1049h\x1b"), None);
assert_eq!(tracker.observe(b"\\"), None);
}
assert_eq!(tracker.observe(b"\x1b[?1049l"), Some(false));
}
#[test]
fn osc_bel_terminates_before_following_1049() {
let mut tracker = ScreenModeTracker::new();
assert_eq!(tracker.observe(b"\x1b]0;title\x07\x1b[?1049h"), Some(true));
}
#[test]
fn rejects_invalid_csi_parameters() {
let mut tracker = ScreenModeTracker::new();
assert_eq!(tracker.observe(b"\x1b[?1049:1h"), None);
assert_eq!(tracker.observe(b"\x1b[?1049$h"), None);
assert_eq!(tracker.observe(b"\x1b[?x;1049h"), None);
assert_eq!(tracker.observe(b"\x1b[?25;1049h"), Some(true));
}
#[test]
fn does_not_treat_utf8_c1_continuation_as_csi() {
let mut tracker = ScreenModeTracker::new();
assert_eq!(tracker.observe(b"\xc2\x9b?1049h"), None);
assert_eq!(tracker.observe(b"\x9b?1049h"), None);
}
}