// ansi.rs --- T M6.3 ANSI parser. //! ANSI/VT100 escape-sequence parser for the REPL streaming pipeline. //! //! This module is a pure stream filter: bytes go in, [`AnsiEvent`]s //! come out. The parser is responsible for the support/unsupport //! split spelled out in spec §sec:ansi-scope --- SGR + intra-line //! cursor motion + line-level erase + bracketed paste + OSC //! title-setting are *supported*; alternate-screen, mouse tracking, //! character-set switching, scrolling regions, and cursor save/restore //! are *parsed but discarded*. Unknown sequences recover per the //! per-state byte cap rule documented on [`AnsiParserConfig`]. //! //! # State machine //! //! Hand-rolled, modeled on the canonical vt100.net / ECMA-48 parser //! table. Hand-rolling (rather than vendoring a third-party crate //! like `vte`) keeps the recovery rule from spec §sec:ansi-scope //! --- 1 KiB cap, drop-to-ground on next `ESC`, never crash --- //! directly inspectable as code instead of pinned to a dependency's //! choices about failure modes. //! //! # Output stream split //! //! Style annotations are emitted as a separate stream from text //! ([`AnsiEvent::SetStyle`] interleaved with [`AnsiEvent::Text`]): //! the rope sees only literal text, and the consumer (the M6.4 REPL //! view) maintains a single piece of state ("the active style is //! whatever the most recent [`SetStyle`] said") and translates to //! byte-range style annotations as it appends to the rope. //! //! # CSI parameters //! //! The parser collects CSI parameters as a list of *parameter //! groups*, each a list of subparameters. This shape is committed //! to from the start because retrofitting subparameter support //! after the SGR mapper is written is painful: the modern variants //! (`ESC [4:3 m` for curly underline, `ESC [38:2::R:G:B m` for //! 24-bit color in the colon-separated form) require subparameter //! parsing that the legacy `;`-only approach can't represent. With //! [`CsiParam`] in hand both legacy `38;2;R;G;B` and modern //! `38:2::R:G:B` become two cases of the same SGR loop. use crate::cell::{Color, Style, UnderlineStyle}; /// Parser compatibility profile. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AnsiParserProfile { /// Preserve the compile/REPL byte-stream contract. #[default] LineOriented, /// Emit terminal operations for a stateful full-screen consumer. FullScreen, } #[allow(missing_docs)] /// Erase direction for display and line operations. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EraseMode { ToEnd, ToStart, All, Saved, } #[allow(missing_docs)] /// DEC alternate-screen selector. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AlternateScreenMode { Mode47, Mode1047, Mode1049, } #[allow(missing_docs)] /// Terminal modes understood by the screen/input core. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TerminalMode { Insert, Origin, AutoWrap, ApplicationCursor, ApplicationKeypad, CursorVisible, BracketedPaste, FocusReporting, SynchronizedOutput, MouseX10, MouseButton, MouseAny, MouseSgr, } #[allow(missing_docs)] /// G0/G1 designation target and supported character set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CharacterSetSlot { G0, G1, } /// Character set designated into a DEC G0/G1 slot. #[allow(missing_docs)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CharacterSet { Ascii, DecSpecialGraphics, } #[allow(missing_docs)] /// Typed terminal query. Only these requests may generate PTY input. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DeviceRequest { PrimaryAttributes, SecondaryAttributes, OperatingStatus, CursorPosition, } // --------------------------------------------------------------------------- // Public output // --------------------------------------------------------------------------- /// One observable effect of feeding bytes to an [`AnsiParser`]. /// /// One byte may produce zero or more events; one event is emitted /// at the moment the parser has enough context to commit to it /// (e.g., `Text` is emitted at every transition out of Ground, not /// per byte). #[allow(missing_docs)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnsiEvent { /// Append literal text to the consumer's rope. Text never /// contains escape bytes (`0x1B`) --- that property is what makes /// stream-separation testable. Text(String), /// Set the active style. Subsequent `Text` events are rendered /// at this style until the next `SetStyle`. Spec §sec:ansi-scope. SetStyle(Style), /// Carriage return: move the input position to the start of the /// current input region. The REPL view interprets "input region" /// per spec §sec:repl-view. CarriageReturn, /// Backspace: move the input position one column left within /// the current input region. Backspace, /// Erase from the input position to the end of the line /// (`CSI K` or `CSI 0 K`). EraseToEol, /// Erase the entire current line (`CSI 2 K`). EraseLine, /// Set the window title (OSC 0 / OSC 2 with terminator). /// Exposed as a per-buffer attribute by the M6.4 REPL view. SetTitle(String), /// `OSC 133;A`: shell prompt begins. PromptStart, /// `OSC 133;B`: shell prompt ends. PromptEnd, /// `OSC 133;C`: command input begins. CommandStart, /// `OSC 133;D`: command output begins / command finished marker. OutputStart, /// `CSI 200 ~`: a process-emitted bracketed-paste begin marker. BracketedPasteBegin, /// `CSI 201 ~`: a process-emitted bracketed-paste end marker. BracketedPasteEnd, /// `CSI ? 1049 h`: alternate-screen entered. Subsequent `Text` /// and `SetStyle` events are *suppressed* until /// [`AnsiEvent::AlternateScreenExit`] --- the parser still /// advances state correctly but emits no text payload, per /// spec §sec:ansi-scope. AlternateScreenEnter, /// `CSI ? 1049 l`: alternate-screen exited. `Text` and /// `SetStyle` resume. AlternateScreenExit, /// Full-screen-only terminal operations. Bell, LineFeed, /// `ESC D`: advance one row, scrolling at the bottom margin. Index, /// `ESC E`: return to column zero and advance one row. NextLine, /// `ESC M`: move up one row, scrolling down at the top margin. ReverseIndex, HorizontalTab, SetTabStop, ClearTabStop, ClearAllTabStops, CursorUp(u32), CursorDown(u32), CursorForward(u32), CursorBackward(u32), CursorNextLine(u32), CursorPreviousLine(u32), CursorHorizontalAbsolute(u32), CursorVerticalAbsolute(u32), CursorPosition { row: u32, col: u32, }, EraseDisplay(EraseMode), EraseLineMode(EraseMode), EraseCharacters(u32), InsertCharacters(u32), DeleteCharacters(u32), InsertLines(u32), DeleteLines(u32), ScrollUp(u32), ScrollDown(u32), SetScrollingRegion { top: u32, bottom: Option, }, SaveCursor, RestoreCursor, AlternateScreen { mode: AlternateScreenMode, enabled: bool, }, SetMode { mode: TerminalMode, enabled: bool, }, DesignateCharacterSet { slot: CharacterSetSlot, charset: CharacterSet, }, ShiftOut, ShiftIn, DeviceRequest(DeviceRequest), } /// Tunable knobs for [`AnsiParser`]. #[derive(Clone, Copy, Debug)] pub struct AnsiParserConfig { /// Per-state byte budget for `Ignore`/`OscIgnore`/`DcsIgnore` /// states. *Reset to zero on every entry to an Ignore state* /// --- this is a per-state cap, not a global counter, so a /// feed containing N malformed sequences each gets its own /// budget. At the limit, the parser force-returns to `Ground` /// and discards the malformed sequence; subsequent input is /// processed normally as ordinary text. Default 1 KiB per /// spec §sec:ansi-scope. pub unknown_sequence_byte_limit: usize, } impl Default for AnsiParserConfig { fn default() -> Self { Self { unknown_sequence_byte_limit: 1024, } } } // --------------------------------------------------------------------------- // CSI parameter representation // --------------------------------------------------------------------------- /// One parameter group inside a CSI sequence. /// /// `main` is the primary parameter value (digits before any `:`); /// `sub` is the sequence of subparameters (digits between `:`s /// after `main`, in order). Empty digits resolve to `0` per /// ECMA-48 default-parameter convention. Empty `sub` is the common /// case (any legacy `;`-only sequence). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct CsiParam { /// Primary parameter. Empty input resolves to `0`. pub main: u32, /// Subparameters (the `:`-separated tail). Empty for legacy /// `;`-only sequences. pub sub: Vec, } /// Sequence of [`CsiParam`]s collected from a single CSI command. pub type CsiParams = Vec; // --------------------------------------------------------------------------- // Internal state machine // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] // DCS sub-states wire up in stage 3; stage 1 only enters DcsEntry. enum State { Ground, Escape, EscapeIntermediate, EscapeIgnore, CsiEntry, CsiParam, CsiIntermediate, CsiIgnore, OscString, OscIgnore, /// Sub-state of `OscString`: just saw `ESC` inside an OSC body; /// the next byte is the terminator-check (`\` ⇒ ST, else /// restart-from-Escape). OscEscPending, DcsEntry, DcsParam, DcsIntermediate, DcsPassthrough, DcsIgnore, SosPmApcString, } /// CSI accumulator. Holds in-progress parameters as they arrive /// byte-by-byte, plus the private-marker prefix (`?`, `<`, `=`, /// `>`) and any intermediate bytes seen before the final byte. #[derive(Default)] struct CsiCollector { params: CsiParams, /// In-progress `main` value. `None` until the first digit; /// flushed into `params` on `;` or final byte. current_main: Option, /// In-progress sub list for the current group. current_subs: Vec, /// In-progress sub digit accumulator. `None` after a `:` but /// before the next digit; some `(0)` after a `:` if no digits /// yet have been seen for that sub (the empty-sub case, e.g. /// `38:2::R:G:B`). current_sub_acc: Option, /// `true` once we've seen at least one `:` in the current /// group --- subsequent digits accumulate into a sub, not main. in_subs: bool, /// Private marker byte (`?`, `<`, `=`, `>`) if the CSI started /// with one; `None` for ordinary CSI. private_marker: Option, /// Intermediate bytes (range `0x20..=0x2F`) seen between params /// and the final byte. intermediates: Vec, } impl CsiCollector { fn reset(&mut self) { self.params.clear(); self.current_main = None; self.current_subs.clear(); self.current_sub_acc = None; self.in_subs = false; self.private_marker = None; self.intermediates.clear(); } /// Accumulate a digit into `main` (before any `:`) or into the /// current sub (after a `:`). fn push_digit(&mut self, d: u32) { if self.in_subs { let cur = self.current_sub_acc.unwrap_or(0); self.current_sub_acc = Some(cur.saturating_mul(10).saturating_add(d)); } else { let cur = self.current_main.unwrap_or(0); self.current_main = Some(cur.saturating_mul(10).saturating_add(d)); } } /// `:` between subparameters. Flush the in-progress sub digit /// (or `0` if empty) into `current_subs`. fn push_colon(&mut self) { if self.in_subs { // Flush prior sub. self.current_subs.push(self.current_sub_acc.unwrap_or(0)); self.current_sub_acc = None; } else { // First `:` of this group: flush main if started, then // switch into sub mode. Note `main` may be `None` if the // group started with `:` (rare but legal). self.in_subs = true; } } /// `;` between parameter groups. Flush the in-progress group /// into `params` and reset for the next group. fn push_semicolon(&mut self) { // Close the in-progress sub if any. if self.in_subs { self.current_subs.push(self.current_sub_acc.unwrap_or(0)); self.current_sub_acc = None; } let main = self.current_main.unwrap_or(0); let subs = std::mem::take(&mut self.current_subs); self.params.push(CsiParam { main, sub: subs }); self.current_main = None; self.in_subs = false; } /// Final byte arrived: flush any in-progress group and return /// the collected parameters. fn finalize(&mut self) -> CsiParams { // Flush the last group, if any digits or subs are pending. let pending_main = self.current_main.is_some(); let pending_subs = self.in_subs || !self.current_subs.is_empty(); if pending_main || pending_subs { if self.in_subs { self.current_subs.push(self.current_sub_acc.unwrap_or(0)); self.current_sub_acc = None; } let main = self.current_main.unwrap_or(0); let subs = std::mem::take(&mut self.current_subs); self.params.push(CsiParam { main, sub: subs }); } std::mem::take(&mut self.params) } } // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /// Stateful ANSI parser. Feed bytes; collect [`AnsiEvent`]s. /// /// State persists across `feed` calls --- a CSI started in one /// chunk continues in the next. M6.2's coalescing makes per-tick /// chunk boundaries common, so this property is load-bearing for /// correctness, not just convenience. pub struct AnsiParser { state: State, /// Current SGR state. Mutated by SGR parameters; emitted as /// [`AnsiEvent::SetStyle`] when it changes. current_style: Style, /// The style the CONSUMER last received via an emitted /// `SetStyle` event. Outside alternate-screen this always equals /// `current_style` (every SGR emits immediately); inside, SGR /// events are suppressed while `current_style` keeps advancing, /// so the two drift apart — and alternate-screen exit (ordinary /// or via [`Self::finish`]) must resynchronize the consumer from /// this field, not from an internal comparison against default /// (PR #113 round-4 finding 2). emitted_style: Style, /// Byte count consumed in the current Ignore state. Reset to /// zero on every entry to an Ignore state: this is a per-state /// budget, not a global counter, so each malformed sequence /// gets its own [`AnsiParserConfig::unknown_sequence_byte_limit`]. ignore_byte_count: usize, /// In-progress text run accumulator. Flushed as a single /// [`AnsiEvent::Text`] when we leave Ground for any non-Ground /// state. Holds only complete (valid UTF-8) characters; partial /// multi-byte sequences live in `utf8_buf` until they complete. text_run: String, /// Pending UTF-8 bytes that haven't yet decoded to a complete /// scalar. Cross-feed buffer: a multi-byte sequence split across /// `feed()` calls accumulates here until the trailing byte /// arrives (or until a non-continuation byte invalidates the /// sequence, at which point we emit U+FFFD for the malformed /// bytes and continue). The buffer holds at most a few bytes /// (the longest valid UTF-8 sequence is 4 bytes; we cap at 8 /// defensively). This replaces the M6.3-stage-1 shortcut where /// every non-ASCII byte was emitted as U+FFFD regardless of /// whether it was actually malformed. utf8_buf: Vec, /// Suppress `Text` and `SetStyle` events while alternate-screen /// is active. Spec §sec:ansi-scope: parser advances state /// normally but emits no payload. alt_screen_active: bool, csi: CsiCollector, /// In-progress OSC body bytes. osc_body: Vec, /// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F). escape_intermediates: Vec, profile: AnsiParserProfile, config: AnsiParserConfig, } impl Default for AnsiParser { fn default() -> Self { Self::new() } } impl AnsiParser { /// Construct a parser with default configuration. #[must_use] pub fn new() -> Self { Self::with_profile(AnsiParserProfile::LineOriented) } /// Construct a parser using the selected compatibility profile. #[must_use] pub fn with_profile(profile: AnsiParserProfile) -> Self { Self::with_profile_and_config(profile, AnsiParserConfig::default()) } /// Construct a line-oriented parser with custom configuration. #[must_use] pub fn with_config(config: AnsiParserConfig) -> Self { Self::with_profile_and_config(AnsiParserProfile::LineOriented, config) } /// Construct a parser with both an explicit profile and configuration. #[must_use] pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self { Self { state: State::Ground, current_style: Style::default(), emitted_style: Style::default(), ignore_byte_count: 0, text_run: String::new(), utf8_buf: Vec::new(), alt_screen_active: false, csi: CsiCollector::default(), osc_body: Vec::new(), escape_intermediates: Vec::new(), profile, config, } } /// Reset the parser to ground state. The running style is *not* /// reset --- callers that want a clean style should pair this /// with their own `SetStyle(Style::default())`. Neither is /// `emitted_style`: a mid-stream reset changes nothing about /// what the consumer has already been shown. pub fn reset(&mut self) { self.state = State::Ground; self.ignore_byte_count = 0; self.text_run.clear(); self.utf8_buf.clear(); self.csi.reset(); self.osc_body.clear(); self.escape_intermediates.clear(); } /// Feed a slice of bytes to the parser. Returns every event the /// bytes produced in order. pub fn feed(&mut self, bytes: &[u8]) -> Vec { let mut events = Vec::new(); for &b in bytes { self.feed_byte(b, &mut events); } // Flush any pending text run at the end of the feed, but // only if we're in Ground (mid-CSI/mid-OSC text shouldn't // be emitted yet --- the in-flight escape might still // resolve and the bytes belong to it). Since we only build // text_run while in Ground, this is safe to flush // unconditionally as a final step. // // We do NOT flush `utf8_buf` here: an incomplete multi-byte // sequence at the feed boundary is exactly the case the // cross-feed buffer was added to handle --- the trailing // bytes are expected to arrive in the next feed. The state // transition path (flush_text_run) does emit U+FFFD for // pending bytes because a non-text byte genuinely // interrupts the sequence; feed-boundary doesn't. if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } events } /// Stream-end finalization. The feed-boundary contract keeps an /// incomplete UTF-8 sequence buffered because its trailing bytes /// are expected in the next feed — but at process EOF there IS no /// next feed, so the pending prefix can never complete. Emit /// U+FFFD for it (the same posture `flush_text_run` takes when a /// control byte interrupts a sequence) and flush the resulting /// text run. /// /// The parser is then fully reset — in-flight CSI/OSC/escape /// state, alt-screen suppression, AND the running SGR style — so /// a `feed` after `finish` parses a NEW stream from a clean /// slate rather than continuing a pre-EOF escape sequence, /// staying suppressed, or inheriting stale color (PR #113 /// round-2 finding 4; round-3 finding 2). The reset is /// OBSERVABLE: consumers that mirror parser state from the event /// stream receive balancing events — an `AlternateScreenExit` /// for an unclosed enter, a default `SetStyle` whenever the /// style the consumer LAST RECEIVED was non-default. The /// comparison is against `emitted_style`, not `current_style`: /// an SGR reset inside the alternate screen leaves the internal /// style default while the consumer still shows pre-enter color /// (round-4 finding 2). Idempotent once drained. First consumer: /// compile-mode's terminal-event path (Q#CM4). pub fn finish(&mut self) -> Vec { let mut events = Vec::new(); self.flush_pending_utf8_as_replacement(); if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } if self.profile == AnsiParserProfile::LineOriented { if self.alt_screen_active { self.alt_screen_active = false; events.push(AnsiEvent::AlternateScreenExit); } if self.emitted_style != Style::default() { events.push(AnsiEvent::SetStyle(Style::default())); } } self.alt_screen_active = false; self.current_style = Style::default(); self.emitted_style = Style::default(); self.reset(); events } fn feed_byte(&mut self, b: u8, events: &mut Vec) { // ESC-anywhere rule: ECMA-48 §10.2 ("Cancel"). Aborts any // in-progress sequence and starts a fresh Escape state. The // exception is OscString, where ESC begins a two-byte ST // terminator check (handled in OscString below). if b == 0x1B && !matches!(self.state, State::OscString | State::OscEscPending) { self.flush_text_run(events); self.start_new_sequence(); return; } // CAN (0x18) and SUB (0x1A) cancel any in-progress sequence // and return to Ground. if matches!(b, 0x18 | 0x1A) { self.flush_text_run(events); self.recover_to_ground(); return; } // Bound retained control-string payload without ever exposing its // overflow as printable text. Once capped, remain in a zero-storage // ignore state until BEL/ST or a fresh ESC sequence provides a safe // recovery boundary. if self.state != State::Ground && !matches!( self.state, State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore ) { self.ignore_byte_count = self.ignore_byte_count.saturating_add(1); if self.ignore_byte_count > self.config.unknown_sequence_byte_limit { match self.state { State::OscString | State::OscEscPending => { self.osc_body.clear(); self.state = State::OscIgnore; self.ignore_byte_count = 0; } State::DcsEntry | State::DcsParam | State::DcsIntermediate | State::DcsPassthrough | State::SosPmApcString => { self.state = State::DcsIgnore; self.ignore_byte_count = 0; } State::Escape | State::EscapeIntermediate => { self.escape_intermediates.clear(); self.state = State::EscapeIgnore; self.ignore_byte_count = 0; } State::CsiEntry | State::CsiParam | State::CsiIntermediate => { self.csi.reset(); self.state = State::CsiIgnore; self.ignore_byte_count = 0; } _ => self.recover_to_ground(), } return; } } match self.state { State::Ground => self.feed_ground(b, events), State::Escape => self.feed_escape(b, events), State::EscapeIntermediate => self.feed_escape_intermediate(b, events), State::EscapeIgnore => self.feed_escape_ignore(b), State::CsiEntry => self.feed_csi_entry(b, events), State::CsiParam => self.feed_csi_param(b, events), State::CsiIntermediate => self.feed_csi_intermediate(b, events), State::CsiIgnore => self.feed_csi_ignore(b), State::OscString => self.feed_osc_string(b, events), State::OscEscPending => self.feed_osc_esc_pending(b, events), State::OscIgnore => self.feed_osc_ignore(b), State::DcsEntry | State::DcsParam | State::DcsIntermediate | State::DcsPassthrough | State::DcsIgnore | State::SosPmApcString => { // DCS / SOS / PM / APC are parsed-and-discarded // per spec §sec:ansi-scope. The body bytes drop on // the floor; the ESC-anywhere rule terminates the // sequence on the next ESC, and the per-state byte // cap is a backstop. } } } // ----------------------------------------------------------------------- // Text run buffering // ----------------------------------------------------------------------- fn flush_text_run(&mut self, events: &mut Vec) { // Any pending UTF-8 prefix at this point is interrupted by // a non-text byte (control byte, CSI start, etc.); the // sequence can't continue across that boundary. Emit U+FFFD // for the incomplete bytes before committing the run. self.flush_pending_utf8_as_replacement(); if self.text_run.is_empty() { return; } let run = std::mem::take(&mut self.text_run); if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } fn emit_set_style(&mut self, events: &mut Vec) { if self.suppress_visible() { return; } self.emitted_style = self.current_style; events.push(AnsiEvent::SetStyle(self.current_style)); } /// Helper: emit `ev` if alt-screen is not active; drop otherwise. /// Used for visible side-effects (CR / BS / Erase / Bracketed /// paste / `SetTitle`). The alt-screen markers themselves /// bypass this. fn push_visible(&self, ev: AnsiEvent, events: &mut Vec) { if !self.suppress_visible() { events.push(ev); } } fn suppress_visible(&self) -> bool { self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active } /// Begin a fresh escape sequence (called from ESC-anywhere). /// Resets the byte budget and all in-flight sequence state. fn start_new_sequence(&mut self) { self.csi.reset(); self.osc_body.clear(); self.escape_intermediates.clear(); self.ignore_byte_count = 0; self.state = State::Escape; } /// Force-recover to Ground. Discards in-flight sequence state /// and resets the byte budget. Called on CAN/SUB, on byte-cap /// overflow, and after a successful sequence dispatch. fn recover_to_ground(&mut self) { self.csi.reset(); self.osc_body.clear(); self.escape_intermediates.clear(); self.ignore_byte_count = 0; self.state = State::Ground; } // ----------------------------------------------------------------------- // Ground // ----------------------------------------------------------------------- fn feed_ground(&mut self, b: u8, events: &mut Vec) { if self.profile == AnsiParserProfile::LineOriented { match b { 0x0D => { self.flush_text_run(events); self.push_visible(AnsiEvent::CarriageReturn, events); } 0x08 => { self.flush_text_run(events); self.push_visible(AnsiEvent::Backspace, events); } 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { self.push_text_byte(b); } 0x00..=0x1F | 0x7F => {} } return; } match b { 0x07 => { self.flush_text_run(events); events.push(AnsiEvent::Bell); } 0x08 => { self.flush_text_run(events); events.push(AnsiEvent::Backspace); } 0x09 => { self.flush_text_run(events); events.push(AnsiEvent::HorizontalTab); } 0x0A..=0x0C => { self.flush_text_run(events); events.push(AnsiEvent::LineFeed); } 0x0D => { self.flush_text_run(events); events.push(AnsiEvent::CarriageReturn); } 0x0E => { self.flush_text_run(events); events.push(AnsiEvent::ShiftOut); } 0x0F => { self.flush_text_run(events); events.push(AnsiEvent::ShiftIn); } 0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b), 0x00..=0x1F | 0x7F => {} } } /// Append a single text byte, handling multi-byte UTF-8 /// correctly across feed boundaries. /// /// ASCII bytes (0x00..=0x7F) take a fast path directly into /// `text_run` when no partial sequence is pending. Non-ASCII /// bytes (0x80..=0xFF) and any byte arriving while a partial /// sequence is pending go through `utf8_buf`, which is then /// greedily decoded by `try_decode_utf8_buf`. Complete scalars /// flush to `text_run`; an incomplete trailing sequence stays /// in `utf8_buf` for the next byte (whether next-feed or /// later in the same feed). /// /// Malformed sequences (lone continuation, invalid start byte, /// non-continuation arriving where one was expected, overlong /// encoding) emit `U+FFFD` for the offending bytes only and /// resume decoding the rest --- they do not corrupt subsequent /// valid UTF-8. fn push_text_byte(&mut self, b: u8) { if self.utf8_buf.is_empty() && b < 0x80 { self.text_run.push(b as char); return; } self.utf8_buf.push(b); self.try_decode_utf8_buf(); } /// Greedy UTF-8 decoder over `utf8_buf`. Pushes complete chars /// to `text_run`; emits `U+FFFD` for malformed bytes; leaves /// the trailing incomplete prefix in `utf8_buf` for the next /// byte to complete. /// /// Caps the buffer at 8 bytes defensively: a valid UTF-8 /// sequence is at most 4 bytes, so any trailing run longer /// than that is malformed (we'd have hit either a complete /// scalar or a `from_utf8` error before reaching 8). The cap /// bounds the pathological-input memory cost. fn try_decode_utf8_buf(&mut self) { loop { if self.utf8_buf.is_empty() { return; } match std::str::from_utf8(&self.utf8_buf) { Ok(s) => { // Whole buffer is valid UTF-8: flush all of it. self.text_run.push_str(s); self.utf8_buf.clear(); return; } Err(e) => { let valid = e.valid_up_to(); if valid > 0 { // Push the valid prefix. let prefix = std::str::from_utf8(&self.utf8_buf[..valid]) .expect("invariant: valid_up_to bytes are valid UTF-8"); self.text_run.push_str(prefix); self.utf8_buf.drain(..valid); } match e.error_len() { None => { // Trailing incomplete sequence; wait for // more bytes (next push_text_byte or next // feed). The 8-byte defensive cap below // guards against a pathological producer // that never finishes a sequence. if self.utf8_buf.len() >= 8 { self.text_run.push('\u{FFFD}'); self.utf8_buf.clear(); } return; } Some(n) => { // n bytes after the valid prefix are an // invalid sequence: emit U+FFFD for them // and continue with whatever follows. self.text_run.push('\u{FFFD}'); self.utf8_buf.drain(..n); // Loop to retry. } } } } } } /// Drain any pending UTF-8 prefix as `U+FFFD`. Called from /// `flush_text_run` when the text run is being committed /// because we're transitioning out of Ground (a control byte, /// a CSI start, etc.). At that boundary, an unfinished /// multi-byte sequence is genuinely interrupted --- it can't /// continue across the non-text bytes --- so we emit the /// replacement character and clear. /// /// Not called at end-of-feed: a sequence interrupted by feed /// boundary may legitimately resume in the next feed. fn flush_pending_utf8_as_replacement(&mut self) { if !self.utf8_buf.is_empty() { self.text_run.push('\u{FFFD}'); self.utf8_buf.clear(); } } // ----------------------------------------------------------------------- // Escape // ----------------------------------------------------------------------- fn feed_escape(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => { self.escape_intermediates.push(b); self.state = State::EscapeIntermediate; } b'[' => { self.csi.reset(); self.state = State::CsiEntry; } b']' => { self.osc_body.clear(); self.state = State::OscString; } b'P' => self.state = State::DcsEntry, b'X' | b'^' | b'_' => self.state = State::SosPmApcString, b'7' | b'8' | b'D' | b'E' | b'H' | b'M' | b'=' | b'>' if self.profile == AnsiParserProfile::FullScreen => { let event = match b { b'7' => AnsiEvent::SaveCursor, b'8' => AnsiEvent::RestoreCursor, b'D' => AnsiEvent::Index, b'E' => AnsiEvent::NextLine, b'H' => AnsiEvent::SetTabStop, b'M' => AnsiEvent::ReverseIndex, b'=' => AnsiEvent::SetMode { mode: TerminalMode::ApplicationKeypad, enabled: true, }, _ => AnsiEvent::SetMode { mode: TerminalMode::ApplicationKeypad, enabled: false, }, }; events.push(event); self.recover_to_ground(); } b'\\' | 0x30..=0x7E => self.recover_to_ground(), _ => {} } } fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => self.escape_intermediates.push(b), 0x30..=0x7E => { if self.profile == AnsiParserProfile::FullScreen { let slot = match self.escape_intermediates.as_slice() { [b'('] => Some(CharacterSetSlot::G0), [b')'] => Some(CharacterSetSlot::G1), _ => None, }; let charset = match b { b'0' => Some(CharacterSet::DecSpecialGraphics), b'B' => Some(CharacterSet::Ascii), _ => None, }; if let (Some(slot), Some(charset)) = (slot, charset) { events.push(AnsiEvent::DesignateCharacterSet { slot, charset }); } } self.recover_to_ground(); } _ => {} } } fn feed_escape_ignore(&mut self, b: u8) { if matches!(b, 0x30..=0x7E) { self.recover_to_ground(); } } // ----------------------------------------------------------------------- // CSI // ----------------------------------------------------------------------- fn feed_csi_entry(&mut self, b: u8, events: &mut Vec) { match b { 0x3C..=0x3F => { self.csi.private_marker = Some(b); self.state = State::CsiParam; } b'0'..=b'9' => { self.csi.push_digit(u32::from(b - b'0')); self.state = State::CsiParam; } b':' => { self.csi.push_colon(); self.state = State::CsiParam; } b';' => { self.csi.push_semicolon(); self.state = State::CsiParam; } 0x20..=0x2F => { self.csi.intermediates.push(b); self.state = State::CsiIntermediate; } 0x40..=0x7E => { self.dispatch_csi(b, events); self.recover_to_ground(); } _ => self.state = State::CsiIgnore, } } fn feed_csi_param(&mut self, b: u8, events: &mut Vec) { match b { b'0'..=b'9' => self.csi.push_digit(u32::from(b - b'0')), b':' => self.csi.push_colon(), b';' => self.csi.push_semicolon(), 0x20..=0x2F => { self.csi.intermediates.push(b); self.state = State::CsiIntermediate; } 0x40..=0x7E => { self.dispatch_csi(b, events); self.recover_to_ground(); } _ => self.state = State::CsiIgnore, } } fn feed_csi_intermediate(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => self.csi.intermediates.push(b), 0x40..=0x7E => { self.dispatch_csi(b, events); self.recover_to_ground(); } _ => self.state = State::CsiIgnore, } } fn feed_csi_ignore(&mut self, b: u8) { // The per-state byte cap is enforced at the top of // `feed_byte`; here we only watch for the structural // terminator. if matches!(b, 0x40..=0x7E) { self.recover_to_ground(); } } /// Dispatch a fully-collected CSI sequence. `final_byte` is the /// terminating byte (`0x40..=0x7E`). The collected parameters /// are taken from `self.csi`. #[allow(clippy::too_many_lines)] fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec) { let private = self.csi.private_marker; let intermediates = self.csi.intermediates.clone(); let params = self.csi.finalize(); if private.is_none() && final_byte == b'm' { self.dispatch_sgr(¶ms, events); self.csi.reset(); return; } if self.profile == AnsiParserProfile::LineOriented { match (private, final_byte) { (None, b'K') => match param(¶ms, 0, 0) { 0 => self.push_visible(AnsiEvent::EraseToEol, events), 2 => self.push_visible(AnsiEvent::EraseLine, events), _ => {} }, (None, b'~') => match param(¶ms, 0, 0) { 200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events), 201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events), _ => {} }, (Some(b'?'), b'h' | b'l') => { let set = final_byte == b'h'; for p in ¶ms { if p.main == 1049 { if set && !self.alt_screen_active { self.alt_screen_active = true; events.push(AnsiEvent::AlternateScreenEnter); } else if !set && self.alt_screen_active { self.alt_screen_active = false; events.push(AnsiEvent::AlternateScreenExit); if self.current_style != self.emitted_style { self.emit_set_style(events); } } } } } _ => {} } self.csi.reset(); return; } let count = || param(¶ms, 0, 1).max(1); let event = match (private, intermediates.as_slice(), final_byte) { (None, [], b'A') => Some(AnsiEvent::CursorUp(count())), (None, [], b'B') => Some(AnsiEvent::CursorDown(count())), (None, [], b'C' | b'a') => Some(AnsiEvent::CursorForward(count())), (None, [], b'D') => Some(AnsiEvent::CursorBackward(count())), (None, [], b'E') => Some(AnsiEvent::CursorNextLine(count())), (None, [], b'F') => Some(AnsiEvent::CursorPreviousLine(count())), (None, [], b'G' | b'`') => Some(AnsiEvent::CursorHorizontalAbsolute( param(¶ms, 0, 1).max(1), )), (None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute( param(¶ms, 0, 1).max(1), )), (None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition { row: param(¶ms, 0, 1).max(1), col: param(¶ms, 1, 1).max(1), }), (None, [], b'J') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseDisplay), (None, [], b'K') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseLineMode), (None, [], b'X') => Some(AnsiEvent::EraseCharacters(count())), (None, [], b'@') => Some(AnsiEvent::InsertCharacters(count())), (None, [], b'P') => Some(AnsiEvent::DeleteCharacters(count())), (None, [], b'L') => Some(AnsiEvent::InsertLines(count())), (None, [], b'M') => Some(AnsiEvent::DeleteLines(count())), (None, [], b'S') => Some(AnsiEvent::ScrollUp(count())), (None, [], b'T') => Some(AnsiEvent::ScrollDown(count())), (None, [], b'r') => Some(AnsiEvent::SetScrollingRegion { top: param(¶ms, 0, 1).max(1), bottom: params.get(1).map(|p| p.main).filter(|&n| n != 0), }), (None, [], b's') => Some(AnsiEvent::SaveCursor), (None, [], b'u') => Some(AnsiEvent::RestoreCursor), (None, [], b'g') => match param(¶ms, 0, 0) { 0 => Some(AnsiEvent::ClearTabStop), 3 => Some(AnsiEvent::ClearAllTabStops), _ => None, }, (None, [], b'~') => match param(¶ms, 0, 0) { 200 => Some(AnsiEvent::BracketedPasteBegin), 201 => Some(AnsiEvent::BracketedPasteEnd), _ => None, }, (None, [], b'h' | b'l') => { let enabled = final_byte == b'h'; for p in ¶ms { if p.main == 4 { events.push(AnsiEvent::SetMode { mode: TerminalMode::Insert, enabled, }); } } None } (Some(b'?'), [], b'h' | b'l') => { let enabled = final_byte == b'h'; for p in ¶ms { if let Some(ev) = private_mode_event(p.main, enabled) { events.push(ev); } } None } (None, [], b'c') => Some(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)), (Some(b'>'), [], b'c') => { Some(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes)) } (None, [], b'n') => match param(¶ms, 0, 0) { 5 => Some(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)), 6 => Some(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)), _ => None, }, _ => None, }; if let Some(event) = event { events.push(event); } self.csi.reset(); } // ----------------------------------------------------------------------- // SGR --- stage 1 subset (reset, bold/italic/reverse, basic 8-color) // ----------------------------------------------------------------------- // Each SGR pattern carries its own per-pattern comment // documenting the spec policy (faint, blink-as-bold, // strikethrough, etc.); collapsing the no-op patterns into one // arm would erase that documentation. #[allow(clippy::match_same_arms)] fn dispatch_sgr(&mut self, params: &CsiParams, events: &mut Vec) { // Empty parameter list = reset (CSI m). if params.is_empty() { self.current_style = Style::default(); self.emit_set_style(events); return; } let mut i = 0; while i < params.len() { let p = ¶ms[i]; let mut consumed_extra = 0usize; match p.main { 0 => self.current_style = Style::default(), 1 => self.current_style.bold = true, // 2 (faint): no-op. The cell `Style` has no faint // field; advance state without modifying running // style. Spec lists faint among supported attrs but // our representation collapses it; preserved as a // possible future `Style` field per the M11 // measurement-driven discipline. 2 => {} 3 => self.current_style.italic = true, 4 => { self.current_style.underline = match p.sub.first().copied() { Some(0) => UnderlineStyle::None, None | Some(1) => UnderlineStyle::Single, Some(2) => UnderlineStyle::Double, Some(3) => UnderlineStyle::Curly, Some(4) => UnderlineStyle::Dotted, Some(5) => UnderlineStyle::Dashed, _ => UnderlineStyle::Single, }; } // Line-oriented compile/REPL consumers historically render // blink as bold. Full-screen preserves the shared Style // contract: blink is unsupported and leaves it unchanged. 5 | 6 if self.profile == AnsiParserProfile::LineOriented => { self.current_style.bold = true; } 5 | 6 => {} 7 => self.current_style.reverse = true, // 8 (concealed/invisible): no-op. Out of scope. 8 => {} // 9 (strikethrough): no-op. Spec §sec:ansi-scope // lists it among supported attrs; the cell `Style` // has no strikethrough field, so advance state // without modifying running style. Stage 4 // regression test asserts the running style is // *unchanged* (not corrupted) by SGR 9. 9 => {} 22 => self.current_style.bold = false, 23 => self.current_style.italic = false, 24 => self.current_style.underline = UnderlineStyle::None, // 25 (blink off): unsupported. It must not unset bold // acquired through SGR 1. 25 => {} 27 => self.current_style.reverse = false, 28 => {} 29 => {} 30..=37 => { self.current_style.fg = Color::Indexed(u8::try_from(p.main - 30).unwrap_or(0)); } 38 => { if let Some((color, extra)) = parse_extended_color(p, ¶ms[i + 1..]) { self.current_style.fg = color; consumed_extra = extra; } } 39 => self.current_style.fg = Color::Default, 40..=47 => { self.current_style.bg = Color::Indexed(u8::try_from(p.main - 40).unwrap_or(0)); } 48 => { if let Some((color, extra)) = parse_extended_color(p, ¶ms[i + 1..]) { self.current_style.bg = color; consumed_extra = extra; } } 49 => self.current_style.bg = Color::Default, // 58/59 (underline color, kitty/mintty extension): // same extended-color grammar as 38/48 (T M4.6). 58 => { if let Some((color, extra)) = parse_extended_color(p, ¶ms[i + 1..]) { self.current_style.underline_color = color; consumed_extra = extra; } } 59 => self.current_style.underline_color = Color::Default, 90..=97 => { self.current_style.fg = Color::Indexed(u8::try_from(p.main - 90 + 8).unwrap_or(0)); } 100..=107 => { self.current_style.bg = Color::Indexed(u8::try_from(p.main - 100 + 8).unwrap_or(0)); } _ => {} // Unknown SGR: drop silently, advance state. } i += 1 + consumed_extra; } self.emit_set_style(events); } // ----------------------------------------------------------------------- // OSC (stage 2 will fill this in) // ----------------------------------------------------------------------- fn feed_osc_string(&mut self, b: u8, events: &mut Vec) { match b { // BEL: terminator (xterm convention). 0x07 => { self.dispatch_osc(events); self.recover_to_ground(); } // ESC: begin ST-terminator check (ESC \). 0x1B => self.state = State::OscEscPending, // OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII, // DEL (compatibility), and all high bytes; lossy UTF-8 decoding at // dispatch replaces malformed sequences. The per-state cap bounds // retained storage. 0x20..=0xFF => self.osc_body.push(b), // Other C0 controls: drop silently, stay in OSC. _ => {} } } fn feed_osc_esc_pending(&mut self, b: u8, events: &mut Vec) { if b == b'\\' { self.dispatch_osc(events); self.recover_to_ground(); } else { // Not ST: restart from Escape with this byte as the // first byte of a fresh sequence. self.osc_body.clear(); self.ignore_byte_count = 0; self.state = State::Escape; self.feed_byte(b, events); } } fn feed_osc_ignore(&mut self, b: u8) { // Per-state byte cap is enforced at the top of `feed_byte`; // here we only watch for the structural terminator. match b { 0x07 => self.recover_to_ground(), 0x1B => self.state = State::OscEscPending, _ => {} } } fn dispatch_osc(&mut self, events: &mut Vec) { let body = std::mem::take(&mut self.osc_body); // OSC body is `;`. Parse the numeric prefix; the // tail (after the first `;`) is the OSC payload. let (num_part, text_part) = match body.iter().position(|&b| b == b';') { Some(idx) => (&body[..idx], &body[idx + 1..]), None => (body.as_slice(), &b""[..]), }; let num: Option = std::str::from_utf8(num_part) .ok() .and_then(|s| s.parse().ok()); if matches!(num, Some(133)) && !self.suppress_visible() { match text_part.first().copied() { Some(b'A') => events.push(AnsiEvent::PromptStart), Some(b'B') => events.push(AnsiEvent::PromptEnd), Some(b'C') => events.push(AnsiEvent::CommandStart), Some(b'D') => events.push(AnsiEvent::OutputStart), _ => {} } return; } // Only OSC 0 (set icon name + window title), OSC 2 (set // window title), and the OSC 133 shell integration markers // above produce events. Other OSC numbers are parsed and // discarded per spec §sec:ansi-scope, with the critical // guarantee that state alignment is preserved. if matches!(num, Some(0 | 2)) && !self.suppress_visible() { let title = String::from_utf8_lossy(text_part).into_owned(); events.push(AnsiEvent::SetTitle(title)); } } } fn param(params: &CsiParams, index: usize, default: u32) -> u32 { params .get(index) .map_or(default, |p| if p.main == 0 { default } else { p.main }) } fn erase_mode(value: u32) -> Option { match value { 0 => Some(EraseMode::ToEnd), 1 => Some(EraseMode::ToStart), 2 => Some(EraseMode::All), 3 => Some(EraseMode::Saved), _ => None, } } fn private_mode_event(value: u32, enabled: bool) -> Option { let mode = match value { 47 => { return Some(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode47, enabled, }); } 1047 => { return Some(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1047, enabled, }); } 1049 => { return Some(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled, }); } 1 => TerminalMode::ApplicationCursor, 6 => TerminalMode::Origin, 7 => TerminalMode::AutoWrap, 25 => TerminalMode::CursorVisible, 66 => TerminalMode::ApplicationKeypad, 1000 => TerminalMode::MouseX10, 1002 => TerminalMode::MouseButton, 1003 => TerminalMode::MouseAny, 1004 => TerminalMode::FocusReporting, 1006 => TerminalMode::MouseSgr, 2004 => TerminalMode::BracketedPaste, 2026 => TerminalMode::SynchronizedOutput, _ => return None, }; Some(AnsiEvent::SetMode { mode, enabled }) } /// Parse a CSI 38/48 extended-color suffix into a `Color` plus /// the number of *additional* params consumed (legacy form only; /// the modern subparam form keeps everything inside `p.sub` so /// `consumed_extra` is 0). /// /// - Modern: `38:5:N` → sub = `[5, N]`, `38:2::R:G:B` → sub = /// `[2, 0, R, G, B]` (the empty colorspace ID at index 1 is the /// distinguishing feature of the canonical form). /// - Legacy: `38;5;N` → next param `main=5`, then param `main=N`; /// `38;2;R;G;B` → next params `main=2`, `R`, `G`, `B`. /// /// Returns `None` if the suffix is malformed (truncated, unknown /// kind byte). The caller silently drops the SGR token in that /// case --- state stays aligned. fn parse_extended_color(p: &CsiParam, rest: &[CsiParam]) -> Option<(Color, usize)> { if !p.sub.is_empty() { let kind = p.sub[0]; return match kind { 5 => { let n = *p.sub.get(1)?; Some((Color::Indexed(u8::try_from(n).unwrap_or(255)), 0)) } 2 => { // 5-element form has the colorspace ID at index 1 // (typically 0); 4-element form omits it. let (r, g, b) = if p.sub.len() >= 5 { (p.sub[2], p.sub[3], p.sub[4]) } else if p.sub.len() >= 4 { (p.sub[1], p.sub[2], p.sub[3]) } else { return None; }; Some(( Color::Rgb( u8::try_from(r).unwrap_or(255), u8::try_from(g).unwrap_or(255), u8::try_from(b).unwrap_or(255), ), 0, )) } _ => None, }; } let kind = rest.first()?.main; match kind { 5 => { let n = rest.get(1)?.main; Some((Color::Indexed(u8::try_from(n).unwrap_or(255)), 2)) } 2 => { let r = rest.get(1)?.main; let g = rest.get(2)?.main; let b = rest.get(3)?.main; Some(( Color::Rgb( u8::try_from(r).unwrap_or(255), u8::try_from(g).unwrap_or(255), u8::try_from(b).unwrap_or(255), ), 4, )) } _ => None, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Helper: collect every text-payload byte across `Text` events /// in `evs`. Used to assert acceptance bullet 3 ("rope stays /// clean text") --- the rope-bound stream must never contain /// escape bytes. fn collect_text(evs: &[AnsiEvent]) -> String { let mut out = String::new(); for ev in evs { if let AnsiEvent::Text(s) = ev { out.push_str(s); } } out } /// Helper: locate the indices of all `SetStyle` events in `evs`, /// in arrival order, paired with their `Style` payloads. fn collect_styles(evs: &[AnsiEvent]) -> Vec