1764 lines
70 KiB
Rust
1764 lines
70 KiB
Rust
// 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};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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).
|
|
#[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,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
}
|
|
|
|
/// Sequence of [`CsiParam`]s collected from a single CSI command.
|
|
pub type CsiParams = Vec<CsiParam>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
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<u32>,
|
|
/// In-progress sub list for the current group.
|
|
current_subs: Vec<u32>,
|
|
/// 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<u32>,
|
|
/// `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<u8>,
|
|
/// Intermediate bytes (range `0x20..=0x2F`) seen between params
|
|
/// and the final byte.
|
|
intermediates: Vec<u8>,
|
|
}
|
|
|
|
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,
|
|
/// 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<u8>,
|
|
/// 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<u8>,
|
|
/// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F).
|
|
escape_intermediates: Vec<u8>,
|
|
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_config(AnsiParserConfig::default())
|
|
}
|
|
|
|
/// Construct a parser with custom configuration.
|
|
#[must_use]
|
|
pub fn with_config(config: AnsiParserConfig) -> Self {
|
|
Self {
|
|
state: State::Ground,
|
|
current_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(),
|
|
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())`.
|
|
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<AnsiEvent> {
|
|
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() && !self.alt_screen_active {
|
|
let run = std::mem::take(&mut self.text_run);
|
|
events.push(AnsiEvent::Text(run));
|
|
} else {
|
|
self.text_run.clear();
|
|
}
|
|
events
|
|
}
|
|
|
|
fn feed_byte(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
|
// 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;
|
|
}
|
|
|
|
// Per-state byte cap. The counter increments for every byte
|
|
// consumed in any non-Ground state, and is reset to zero at
|
|
// every transition into a fresh sequence (ESC-anywhere) or
|
|
// back to Ground (normal dispatch / force-recover). At the
|
|
// limit, the parser drops the in-flight sequence and
|
|
// returns to Ground; the *current* byte is dropped on the
|
|
// floor, but subsequent bytes are processed normally as
|
|
// ordinary text. Spec §sec:ansi-scope: "drops back to ground
|
|
// state at the next ESC or after a bounded number of bytes
|
|
// (1 KiB), whichever comes first."
|
|
if self.state != State::Ground {
|
|
self.ignore_byte_count = self.ignore_byte_count.saturating_add(1);
|
|
if self.ignore_byte_count > self.config.unknown_sequence_byte_limit {
|
|
self.recover_to_ground();
|
|
return;
|
|
}
|
|
}
|
|
|
|
match self.state {
|
|
State::Ground => self.feed_ground(b, events),
|
|
State::Escape => self.feed_escape(b),
|
|
State::EscapeIntermediate => self.feed_escape_intermediate(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<AnsiEvent>) {
|
|
// 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.alt_screen_active {
|
|
events.push(AnsiEvent::Text(run));
|
|
}
|
|
}
|
|
|
|
fn emit_set_style(&mut self, events: &mut Vec<AnsiEvent>) {
|
|
if self.alt_screen_active {
|
|
return;
|
|
}
|
|
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<AnsiEvent>) {
|
|
if !self.alt_screen_active {
|
|
events.push(ev);
|
|
}
|
|
}
|
|
|
|
/// 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<AnsiEvent>) {
|
|
match b {
|
|
// CR: flush text, emit CarriageReturn.
|
|
0x0D => {
|
|
self.flush_text_run(events);
|
|
if !self.alt_screen_active {
|
|
events.push(AnsiEvent::CarriageReturn);
|
|
}
|
|
}
|
|
// BS: flush text, emit Backspace.
|
|
0x08 => {
|
|
self.flush_text_run(events);
|
|
if !self.alt_screen_active {
|
|
events.push(AnsiEvent::Backspace);
|
|
}
|
|
}
|
|
// BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF
|
|
// (0x0A): pass through to text alongside printable
|
|
// ASCII (0x20..=0x7E). The REPL view treats LF as a
|
|
// line break in the rope; HT as a literal tab. Other
|
|
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
|
|
// (0x7F) are dropped silently.
|
|
//
|
|
// 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
|
|
// through `push_text_byte`'s stateful decoder so
|
|
// multi-byte sequences across feeds are buffered until
|
|
// complete.
|
|
//
|
|
// All text bytes route through `push_text_byte` (not
|
|
// just non-ASCII): an ASCII byte arriving while a
|
|
// partial UTF-8 sequence is pending invalidates that
|
|
// sequence (the partial prefix's expected continuation
|
|
// didn't arrive), and `push_text_byte` is the only
|
|
// place that knows to flush the partial as `U+FFFD`.
|
|
// The fast path inside `push_text_byte` keeps the
|
|
// pure-ASCII case allocation-free.
|
|
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 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) {
|
|
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;
|
|
}
|
|
// DCS / SOS / PM / APC introducers --- parse and discard.
|
|
b'P' => {
|
|
self.state = State::DcsEntry;
|
|
}
|
|
b'X' | b'^' | b'_' => {
|
|
self.state = State::SosPmApcString;
|
|
}
|
|
// ESC \ in Escape state is a stray ST; final byte for
|
|
// a bare ESC sequence (0x30..=0x7E) lands here too. We
|
|
// don't dispatch any single-byte ESC commands in v0.1
|
|
// (cursor save/restore `ESC 7`/`ESC 8` are deliberately
|
|
// unsupported per spec); both cases consume and return
|
|
// to Ground.
|
|
b'\\' | 0x30..=0x7E => {
|
|
self.recover_to_ground();
|
|
}
|
|
// C0 controls inside Escape: drop, stay in Escape.
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn feed_escape_intermediate(&mut self, b: u8) {
|
|
match b {
|
|
0x20..=0x2F => {
|
|
self.escape_intermediates.push(b);
|
|
}
|
|
// Final byte: drop the sequence (no ESC + intermediate
|
|
// dispatches in v0.1 --- charsets are deliberately
|
|
// unsupported per spec) and return to Ground.
|
|
0x30..=0x7E => {
|
|
self.recover_to_ground();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// CSI
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn feed_csi_entry(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
|
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<AnsiEvent>) {
|
|
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<AnsiEvent>) {
|
|
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`.
|
|
fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec<AnsiEvent>) {
|
|
let private_marker = self.csi.private_marker;
|
|
let params = self.csi.finalize();
|
|
|
|
match (private_marker, final_byte) {
|
|
// SGR.
|
|
(None, b'm') => self.dispatch_sgr(¶ms, events),
|
|
// Erase in line: `CSI [n] K`. n=0 (default) →
|
|
// EraseToEol; n=2 → EraseLine; n=1 (start to cursor)
|
|
// and others: parsed and ignored.
|
|
(None, b'K') => {
|
|
let n = params.first().map_or(0, |p| p.main);
|
|
match n {
|
|
0 => self.push_visible(AnsiEvent::EraseToEol, events),
|
|
2 => self.push_visible(AnsiEvent::EraseLine, events),
|
|
_ => {}
|
|
}
|
|
}
|
|
// Bracketed paste markers: `CSI 200 ~` / `CSI 201 ~`.
|
|
(None, b'~') => {
|
|
let n = params.first().map_or(0, |p| p.main);
|
|
match n {
|
|
200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events),
|
|
201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events),
|
|
_ => {}
|
|
}
|
|
}
|
|
// DEC private mode set / reset: `CSI ? <num> h` / `l`.
|
|
// Of these, only ?1049 (alternate screen) produces an
|
|
// event; mouse modes (?1000, ?1006), bracketed-paste
|
|
// mode (?2004), and the long tail are parsed and
|
|
// discarded per spec §sec:ansi-scope.
|
|
(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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Cursor motions (A/B/C/D/E/F/G/H/J/f) and other CSI
|
|
// commands: parsed and discarded for M6.3. The M6.4
|
|
// view layer handles intra-line motion (CR / BS) at
|
|
// its own level; cross-region motion via CSI is in the
|
|
// "parsed but ignored when it would cross region
|
|
// boundaries" bucket from §sec:ansi-scope.
|
|
_ => {}
|
|
}
|
|
|
|
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<AnsiEvent>) {
|
|
// 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,
|
|
};
|
|
}
|
|
// 5/6 (blink, rapid blink): mapped to bold per spec
|
|
// §sec:ansi-scope ("blink-as-bold").
|
|
5 | 6 => self.current_style.bold = true,
|
|
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): no-op. Symmetry with 5/6 → bold:
|
|
// we do not unset bold here, since that would also
|
|
// unset bold acquired via 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<AnsiEvent>) {
|
|
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,
|
|
// 0x20..=0x7F: body bytes. The per-state byte cap
|
|
// (enforced at the top of `feed_byte`) bounds how many
|
|
// bytes we'll accept before force-recovering.
|
|
0x20..=0x7F => self.osc_body.push(b),
|
|
// Other C0/C1 controls: drop silently, stay in OSC.
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn feed_osc_esc_pending(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
|
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<AnsiEvent>) {
|
|
let body = std::mem::take(&mut self.osc_body);
|
|
// OSC body is `<num>;<text>`. 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<u32> = std::str::from_utf8(num_part)
|
|
.ok()
|
|
.and_then(|s| s.parse().ok());
|
|
if matches!(num, Some(133)) && !self.alt_screen_active {
|
|
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.alt_screen_active {
|
|
let title = String::from_utf8_lossy(text_part).into_owned();
|
|
events.push(AnsiEvent::SetTitle(title));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Style> {
|
|
evs.iter()
|
|
.filter_map(|e| {
|
|
if let AnsiEvent::SetStyle(s) = e {
|
|
Some(*s)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Acceptance bullet 1: ls/git diff/grep --color render correctly
|
|
// -----------------------------------------------------------------
|
|
|
|
/// `ls --color=always` emits SGR with bold + indexed color
|
|
/// (e.g. directory = bold blue, executable = bold green).
|
|
/// Source pattern captured 2026-05-03 from
|
|
/// `/bin/ls --color=always /tmp` on a stock GNU ls runner.
|
|
#[test]
|
|
fn m6_3_sgr_ls_color_render() {
|
|
let mut p = AnsiParser::new();
|
|
// \x1b[01;34m = bold + blue fg; "dir" text; reset; newline.
|
|
let evs = p.feed(b"\x1b[01;34mdir\x1b[0m\n\x1b[01;31mfile\x1b[0m\n");
|
|
let styles = collect_styles(&evs);
|
|
// Expected order: bold+blue, reset, bold+red, reset.
|
|
assert_eq!(styles.len(), 4, "expected 4 SetStyle events, got {evs:?}");
|
|
assert!(
|
|
styles[0].bold && styles[0].fg == Color::Indexed(4),
|
|
"first style should be bold + blue, got {:?}",
|
|
styles[0]
|
|
);
|
|
assert_eq!(styles[1], Style::default(), "second style should be reset");
|
|
assert!(
|
|
styles[2].bold && styles[2].fg == Color::Indexed(1),
|
|
"third style should be bold + red, got {:?}",
|
|
styles[2]
|
|
);
|
|
assert_eq!(styles[3], Style::default(), "fourth style should be reset");
|
|
assert_eq!(collect_text(&evs), "dir\nfile\n");
|
|
}
|
|
|
|
/// `git diff --color` emits red for deletions and green for
|
|
/// additions, with surrounding context in default style.
|
|
#[test]
|
|
fn m6_3_sgr_git_diff_render() {
|
|
let mut p = AnsiParser::new();
|
|
let input = b"\
|
|
\x1b[31m-old line\x1b[0m\n\
|
|
\x1b[32m+new line\x1b[0m\n\
|
|
context\n";
|
|
let evs = p.feed(input);
|
|
let styles = collect_styles(&evs);
|
|
assert!(
|
|
styles.iter().any(|s| s.fg == Color::Indexed(1)),
|
|
"must observe red SGR for deletion line; got {styles:?}"
|
|
);
|
|
assert!(
|
|
styles.iter().any(|s| s.fg == Color::Indexed(2)),
|
|
"must observe green SGR for addition line; got {styles:?}"
|
|
);
|
|
let text = collect_text(&evs);
|
|
assert!(text.contains("-old line"));
|
|
assert!(text.contains("+new line"));
|
|
assert!(text.contains("context"));
|
|
}
|
|
|
|
/// `grep --color=always` highlights matches with bold reverse-or-red.
|
|
#[test]
|
|
fn m6_3_sgr_grep_color_render() {
|
|
let mut p = AnsiParser::new();
|
|
// \x1b[01;31m\x1b[Khello\x1b[m = bold red + erase-to-eol +
|
|
// text + SGR reset (the final `\x1b[m` is empty-param SGR).
|
|
let evs = p.feed(b"prefix \x1b[01;31m\x1b[Khello\x1b[m suffix");
|
|
let styles = collect_styles(&evs);
|
|
assert!(
|
|
styles.iter().any(|s| s.bold && s.fg == Color::Indexed(1)),
|
|
"grep match should be bold red; got {styles:?}"
|
|
);
|
|
// The \x1b[K (erase-to-eol) must surface as its own event.
|
|
assert!(
|
|
evs.iter().any(|e| matches!(e, AnsiEvent::EraseToEol)),
|
|
"must observe EraseToEol from \\x1b[K; got {evs:?}"
|
|
);
|
|
let text = collect_text(&evs);
|
|
assert!(text.contains("prefix "));
|
|
assert!(text.contains("hello"));
|
|
assert!(text.contains(" suffix"));
|
|
}
|
|
|
|
/// 24-bit truecolor: `\x1b[38;2;R;G;B m`.
|
|
#[test]
|
|
fn m6_3_truecolor_24bit() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[38;2;255;128;0morange");
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(
|
|
styles.first().map(|s| s.fg),
|
|
Some(Color::Rgb(255, 128, 0)),
|
|
"expected truecolor RGB(255,128,0); got {styles:?}"
|
|
);
|
|
assert_eq!(collect_text(&evs), "orange");
|
|
}
|
|
|
|
/// 256-color indexed: `\x1b[38;5;N m`.
|
|
#[test]
|
|
fn m6_3_color_256_indexed() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[38;5;124m"); // 256-color shade of red.
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(
|
|
styles.first().map(|s| s.fg),
|
|
Some(Color::Indexed(124)),
|
|
"expected 256-color index 124; got {styles:?}"
|
|
);
|
|
}
|
|
|
|
/// Underline variants via subparam: bare 4 (Single), 4:2
|
|
/// (Double), 4:3 (Curly), 4:4 (Dotted), 4:5 (Dashed), 24 (off).
|
|
#[test]
|
|
fn m6_3_underline_styles() {
|
|
let cases: &[(&[u8], UnderlineStyle)] = &[
|
|
(b"\x1b[4m", UnderlineStyle::Single),
|
|
(b"\x1b[4:2m", UnderlineStyle::Double),
|
|
(b"\x1b[4:3m", UnderlineStyle::Curly),
|
|
(b"\x1b[4:4m", UnderlineStyle::Dotted),
|
|
(b"\x1b[4:5m", UnderlineStyle::Dashed),
|
|
];
|
|
for (input, expected) in cases {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(input);
|
|
let style = collect_styles(&evs);
|
|
assert_eq!(
|
|
style.first().map(|s| s.underline),
|
|
Some(*expected),
|
|
"input {input:?} should produce underline {expected:?}; got {evs:?}"
|
|
);
|
|
}
|
|
// 24 must reset underline to None.
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[4:3m\x1b[24m");
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(
|
|
styles.last().map(|s| s.underline),
|
|
Some(UnderlineStyle::None)
|
|
);
|
|
}
|
|
|
|
/// Underline color via SGR 58 (both colon-subparam and semicolon
|
|
/// grammars, mirroring 38/48), reset via SGR 59 (T M4.6).
|
|
#[test]
|
|
fn m4_6_underline_color() {
|
|
let cases: &[(&[u8], Color)] = &[
|
|
(b"\x1b[58:5:1m", Color::Indexed(1)),
|
|
(b"\x1b[58;5;124m", Color::Indexed(124)),
|
|
(b"\x1b[58:2::255:0:0m", Color::Rgb(255, 0, 0)),
|
|
];
|
|
for (input, expected) in cases {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(input);
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(
|
|
styles.first().map(|s| s.underline_color),
|
|
Some(*expected),
|
|
"input {input:?} should produce underline color {expected:?}; got {evs:?}"
|
|
);
|
|
}
|
|
// 59 resets to follow-text-color.
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[58:5:1m\x1b[59m");
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(
|
|
styles.last().map(|s| s.underline_color),
|
|
Some(Color::Default)
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Intra-line motion and erase
|
|
// -----------------------------------------------------------------
|
|
|
|
/// `progress\rdone\x1b[K` --- a typical shell progress-bar
|
|
/// pattern: print "progress", carriage-return to overwrite,
|
|
/// print "done", erase rest of line.
|
|
#[test]
|
|
fn m6_3_carriage_return_and_erase_to_eol() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"progress\rdone\x1b[K");
|
|
// Expected event sequence:
|
|
// Text("progress"), CarriageReturn, Text("done"), EraseToEol
|
|
let mut iter = evs.into_iter();
|
|
match iter.next() {
|
|
Some(AnsiEvent::Text(s)) => assert_eq!(s, "progress"),
|
|
other => panic!("expected Text(\"progress\"), got {other:?}"),
|
|
}
|
|
assert_eq!(iter.next(), Some(AnsiEvent::CarriageReturn));
|
|
match iter.next() {
|
|
Some(AnsiEvent::Text(s)) => assert_eq!(s, "done"),
|
|
other => panic!("expected Text(\"done\"), got {other:?}"),
|
|
}
|
|
assert_eq!(iter.next(), Some(AnsiEvent::EraseToEol));
|
|
assert!(iter.next().is_none(), "no extra events expected");
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Acceptance bullet 2 + 5: malformed input recovery
|
|
// -----------------------------------------------------------------
|
|
|
|
/// An unknown CSI is consumed silently; subsequent legitimate
|
|
/// SGR still parses correctly. Validates ESC-anywhere recovery
|
|
/// without state corruption.
|
|
#[test]
|
|
fn m6_3_unknown_csi_recovers() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[?123x\x1b[31mhi\x1b[0m");
|
|
let text = collect_text(&evs);
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(text, "hi", "unknown CSI must not leak bytes into text");
|
|
assert!(
|
|
styles.iter().any(|s| s.fg == Color::Indexed(1)),
|
|
"post-unknown SGR red must be observed; got {styles:?}"
|
|
);
|
|
}
|
|
|
|
/// A CSI split across two `feed` calls produces the same events
|
|
/// as the single-call version. M6.2 chunk-boundary safety.
|
|
#[test]
|
|
fn m6_3_truncated_csi_resumes_across_feed_calls() {
|
|
let mut p = AnsiParser::new();
|
|
let mut evs = p.feed(b"\x1b[3");
|
|
evs.extend(p.feed(b"1mhi\x1b[0m"));
|
|
let mut p2 = AnsiParser::new();
|
|
let evs_joined = p2.feed(b"\x1b[31mhi\x1b[0m");
|
|
assert_eq!(
|
|
evs, evs_joined,
|
|
"split-feed must produce identical events to single-feed"
|
|
);
|
|
}
|
|
|
|
/// An OSC with no terminator beyond the per-state byte cap
|
|
/// triggers force-recovery; subsequent input parses normally.
|
|
/// Validates the spec's per-state 1 KiB cap.
|
|
#[test]
|
|
fn m6_3_osc_no_terminator_recovers_at_byte_cap() {
|
|
let mut p = AnsiParser::new();
|
|
// Open OSC, emit 5 KiB of body, no terminator.
|
|
let mut input = Vec::new();
|
|
input.extend_from_slice(b"\x1b]0;");
|
|
input.extend(std::iter::repeat_n(b'A', 5 * 1024));
|
|
// Then a fresh SGR sequence after the malformed OSC.
|
|
input.extend_from_slice(b"\x1b[31mhi");
|
|
let evs = p.feed(&input);
|
|
let styles = collect_styles(&evs);
|
|
let text = collect_text(&evs);
|
|
// The OSC produced no SetTitle event (malformed, discarded).
|
|
assert!(
|
|
!evs.iter().any(|e| matches!(e, AnsiEvent::SetTitle(_))),
|
|
"malformed OSC must not produce SetTitle; got {evs:?}"
|
|
);
|
|
// Post-recovery: the SGR red parses; "hi" arrives as Text.
|
|
assert!(
|
|
styles.iter().any(|s| s.fg == Color::Indexed(1)),
|
|
"post-recovery SGR red must be observed; got {styles:?}"
|
|
);
|
|
assert!(text.ends_with("hi"));
|
|
}
|
|
|
|
/// OSC 0 (set window title) with BEL terminator → `SetTitle`.
|
|
#[test]
|
|
fn m6_3_osc_title_set() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b]0;mytitle\x07hello");
|
|
let titles: Vec<&str> = evs
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AnsiEvent::SetTitle(t) = e {
|
|
Some(t.as_str())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
assert_eq!(titles, vec!["mytitle"]);
|
|
assert_eq!(collect_text(&evs), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn m6_3_osc_133_prompt_markers_are_structured_events() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07");
|
|
let kinds: Vec<&str> = evs
|
|
.iter()
|
|
.map(|ev| match ev {
|
|
AnsiEvent::PromptStart => "prompt_start",
|
|
AnsiEvent::Text(s) if s == "$ " => "text",
|
|
AnsiEvent::PromptEnd => "prompt_end",
|
|
AnsiEvent::CommandStart => "command_start",
|
|
AnsiEvent::OutputStart => "output_start",
|
|
other => panic!("unexpected event: {other:?}"),
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
kinds,
|
|
vec![
|
|
"prompt_start",
|
|
"text",
|
|
"prompt_end",
|
|
"command_start",
|
|
"output_start"
|
|
]
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Acceptance bullet 4: alternate-screen suppression
|
|
// -----------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_3_alt_screen_suppresses_output() {
|
|
let mut p = AnsiParser::new();
|
|
let mut input = Vec::new();
|
|
input.extend_from_slice(b"before");
|
|
// Enter alt-screen.
|
|
input.extend_from_slice(b"\x1b[?1049h");
|
|
// Garbage TUI output that must NOT surface as events.
|
|
input.extend_from_slice(b"\x1b[31mblah\x1b[2J\x1b[Hsome stuff");
|
|
// Exit alt-screen.
|
|
input.extend_from_slice(b"\x1b[?1049l");
|
|
// Resume normal output.
|
|
input.extend_from_slice(b"after");
|
|
let evs = p.feed(&input);
|
|
let text = collect_text(&evs);
|
|
assert!(
|
|
text.contains("before") && text.contains("after"),
|
|
"outside-alt-screen text must surface; got {text:?}"
|
|
);
|
|
assert!(
|
|
!text.contains("blah") && !text.contains("some stuff"),
|
|
"alt-screen text must be suppressed; got {text:?}"
|
|
);
|
|
assert!(
|
|
evs.iter()
|
|
.any(|e| matches!(e, AnsiEvent::AlternateScreenEnter)),
|
|
"AlternateScreenEnter event must be emitted"
|
|
);
|
|
assert!(
|
|
evs.iter()
|
|
.any(|e| matches!(e, AnsiEvent::AlternateScreenExit)),
|
|
"AlternateScreenExit event must be emitted"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Bracketed paste markers
|
|
// -----------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_3_bracketed_paste_markers_emitted() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(b"\x1b[200~hello\x1b[201~");
|
|
let mut iter = evs.into_iter();
|
|
assert_eq!(iter.next(), Some(AnsiEvent::BracketedPasteBegin));
|
|
match iter.next() {
|
|
Some(AnsiEvent::Text(s)) => assert_eq!(s, "hello"),
|
|
other => panic!("expected Text(\"hello\"), got {other:?}"),
|
|
}
|
|
assert_eq!(iter.next(), Some(AnsiEvent::BracketedPasteEnd));
|
|
assert!(iter.next().is_none());
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Acceptance bullet 3: rope stream stays clean text
|
|
// -----------------------------------------------------------------
|
|
|
|
/// Across a corpus of valid + malformed sequences, no `Text`
|
|
/// event ever contains an escape byte.
|
|
#[test]
|
|
fn m6_3_text_stream_carries_no_escape_bytes() {
|
|
let corpus: &[&[u8]] = &[
|
|
b"\x1b[31mhello\x1b[0m world",
|
|
b"\x1b[?123x\x1b[m unknown CSI ignored",
|
|
b"\x1b]0;title\x07ordinary",
|
|
b"\x1b[200~paste\x1b[201~",
|
|
b"a\x1b[A\x1b[B\x1b[Cb cursor motions ignored",
|
|
b"trunc\x1b[3", // no terminator; test asserts safety
|
|
];
|
|
for input in corpus {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(input);
|
|
for ev in &evs {
|
|
if let AnsiEvent::Text(s) = ev {
|
|
assert!(
|
|
!s.bytes().any(|b| b == 0x1B),
|
|
"Text event leaked an ESC byte for input {input:?}: {ev:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pathological inputs all return promptly with the parser in
|
|
/// a sane state (subsequent input parses normally).
|
|
#[test]
|
|
fn m6_3_parser_terminates_on_malformed_input() {
|
|
let pathological: &[Vec<u8>] = &[
|
|
// Bare ESC repeated.
|
|
std::iter::repeat_n(0x1B, 1024).collect(),
|
|
// Half-CSI followed by ESC repeatedly.
|
|
(0..512).flat_map(|_| b"\x1b[3".iter().copied()).collect(),
|
|
// OSC with no terminator and no ESC for 5 KiB.
|
|
{
|
|
let mut v = b"\x1b]0;".to_vec();
|
|
v.extend(std::iter::repeat_n(b'X', 5 * 1024));
|
|
v
|
|
},
|
|
];
|
|
for input in pathological {
|
|
let mut p = AnsiParser::new();
|
|
let _ = p.feed(input);
|
|
// Post-pathology, force the parser back to Ground via
|
|
// an explicit ST sequence (ESC \), since the pathology
|
|
// may have left it mid-escape (e.g., a trailing bare
|
|
// ESC waits for its final byte). Then assert ordinary
|
|
// text comes through cleanly --- the goal of this test
|
|
// is that the parser doesn't panic, hang, or corrupt
|
|
// future input, not that any one byte aligns with a
|
|
// particular state.
|
|
let evs = p.feed(b"\x1b\\recovered");
|
|
let text = collect_text(&evs);
|
|
assert!(
|
|
text.contains("recovered"),
|
|
"parser failed to recover after pathological input; got {evs:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Strikethrough advances state without corrupting running style
|
|
// -----------------------------------------------------------------
|
|
|
|
// ---- UTF-8 cross-feed handling (post-M6.10 audit fix) -----------------
|
|
|
|
/// Multi-byte UTF-8 in a single feed decodes to the correct
|
|
/// scalar, not U+FFFD. Catches the M6.3-stage-1 shortcut that
|
|
/// emitted U+FFFD for every non-ASCII byte regardless of whether
|
|
/// it was actually malformed.
|
|
#[test]
|
|
fn ansi_utf8_multibyte_in_single_feed_decodes_correctly() {
|
|
let mut p = AnsiParser::new();
|
|
// "café" --- the é is U+00E9, encoded as 0xC3 0xA9 in UTF-8.
|
|
let evs = p.feed("café".as_bytes());
|
|
assert_eq!(collect_text(&evs), "café");
|
|
}
|
|
|
|
/// Three-byte UTF-8 (e.g., CJK) decodes correctly. Same gate as
|
|
/// above but exercises the 1110xxxx start byte + two
|
|
/// continuations path.
|
|
#[test]
|
|
fn ansi_utf8_three_byte_sequence_decodes_correctly() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed("日本語".as_bytes());
|
|
assert_eq!(collect_text(&evs), "日本語");
|
|
}
|
|
|
|
/// Four-byte UTF-8 (e.g., emoji past U+FFFF) decodes correctly.
|
|
/// Exercises the 11110xxx start byte + three continuations path.
|
|
#[test]
|
|
fn ansi_utf8_four_byte_sequence_decodes_correctly() {
|
|
let mut p = AnsiParser::new();
|
|
// U+1F600 = 😀 = 0xF0 0x9F 0x98 0x80
|
|
let evs = p.feed("😀".as_bytes());
|
|
assert_eq!(collect_text(&evs), "😀");
|
|
}
|
|
|
|
/// A multi-byte sequence split across two feeds completes
|
|
/// correctly. This is the load-bearing cross-feed behavior:
|
|
/// pre-fix, the first feed would emit U+FFFD for the start byte
|
|
/// and the second feed would emit U+FFFDs for the continuation
|
|
/// bytes; post-fix, the partial bytes wait in `utf8_buf` and
|
|
/// flush as one correct char when the trailing byte arrives.
|
|
#[test]
|
|
fn ansi_utf8_split_across_two_feeds() {
|
|
let mut p = AnsiParser::new();
|
|
let bytes = "café".as_bytes();
|
|
// Split between the start byte (0xC3) and continuation
|
|
// (0xA9) of the é.
|
|
let split = bytes.len() - 1;
|
|
let evs1 = p.feed(&bytes[..split]);
|
|
let evs2 = p.feed(&bytes[split..]);
|
|
let combined: String = collect_text(&evs1) + &collect_text(&evs2);
|
|
assert_eq!(combined, "café");
|
|
}
|
|
|
|
/// A four-byte sequence split across three feeds completes
|
|
/// correctly. Exercises the cross-feed buffer holding 2 bytes
|
|
/// across one feed and 1 byte across the next.
|
|
#[test]
|
|
fn ansi_utf8_four_byte_split_across_three_feeds() {
|
|
let mut p = AnsiParser::new();
|
|
// U+1F600 = 0xF0 0x9F 0x98 0x80
|
|
let evs1 = p.feed(&[0xF0]);
|
|
let evs2 = p.feed(&[0x9F, 0x98]);
|
|
let evs3 = p.feed(&[0x80]);
|
|
let combined = collect_text(&evs1) + &collect_text(&evs2) + &collect_text(&evs3);
|
|
assert_eq!(combined, "😀");
|
|
}
|
|
|
|
/// Mixed ASCII and multi-byte text decodes correctly.
|
|
#[test]
|
|
fn ansi_utf8_mixed_ascii_and_multibyte() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed("hello, café 日本 😀!".as_bytes());
|
|
assert_eq!(collect_text(&evs), "hello, café 日本 😀!");
|
|
}
|
|
|
|
/// A lone continuation byte (no preceding start) emits U+FFFD
|
|
/// for that byte only; subsequent ASCII still decodes to ASCII.
|
|
#[test]
|
|
fn ansi_utf8_lone_continuation_byte_emits_replacement() {
|
|
let mut p = AnsiParser::new();
|
|
// 0x80 alone is a continuation byte with no start.
|
|
// ASCII follows; it should decode normally.
|
|
let evs = p.feed(&[b'a', 0x80, b'b']);
|
|
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
|
|
}
|
|
|
|
/// An invalid start byte (0xFF) emits U+FFFD; subsequent ASCII
|
|
/// decodes normally.
|
|
#[test]
|
|
fn ansi_utf8_invalid_start_byte_emits_replacement() {
|
|
let mut p = AnsiParser::new();
|
|
let evs = p.feed(&[b'a', 0xFF, b'b']);
|
|
assert_eq!(collect_text(&evs), "a\u{FFFD}b");
|
|
}
|
|
|
|
/// A start byte expecting two continuations followed by ASCII
|
|
/// (one continuation, then ASCII) emits U+FFFD for the
|
|
/// truncated sequence and decodes the ASCII normally.
|
|
#[test]
|
|
fn ansi_utf8_truncated_sequence_then_ascii() {
|
|
let mut p = AnsiParser::new();
|
|
// 0xE6 (start of 3-byte) + 0x97 (continuation) + 'X' (ASCII,
|
|
// not a continuation). The sequence is malformed: 0xE6 0x97
|
|
// followed by ASCII.
|
|
let evs = p.feed(&[0xE6, 0x97, b'X']);
|
|
// The 0xE6 0x97 prefix is malformed; std::str::from_utf8
|
|
// reports error_len() = 2, so we emit one U+FFFD covering
|
|
// both bytes, then ASCII.
|
|
let text = collect_text(&evs);
|
|
assert!(
|
|
text.contains('\u{FFFD}') && text.ends_with('X'),
|
|
"expected U+FFFD then 'X', got {text:?}"
|
|
);
|
|
}
|
|
|
|
/// A multi-byte sequence interrupted by a control byte (CSI
|
|
/// start) flushes pending bytes as U+FFFD, then resumes
|
|
/// processing the control byte normally.
|
|
#[test]
|
|
fn ansi_utf8_partial_interrupted_by_csi_emits_replacement() {
|
|
let mut p = AnsiParser::new();
|
|
// 0xC3 (start of 2-byte) then ESC [ 31 m (red SGR).
|
|
// The 0xC3 is interrupted by the ESC; should emit U+FFFD
|
|
// for the partial, then process the SGR.
|
|
let evs = p.feed(&[b'a', 0xC3, 0x1B, b'[', b'3', b'1', b'm', b'b']);
|
|
let text = collect_text(&evs);
|
|
assert!(
|
|
text.starts_with("a\u{FFFD}") && text.ends_with('b'),
|
|
"expected a + U+FFFD + b, got {text:?}"
|
|
);
|
|
// The SGR should still have produced a SetStyle event.
|
|
let styles = collect_styles(&evs);
|
|
assert_eq!(styles.len(), 1, "expected one SetStyle from the SGR");
|
|
assert_eq!(styles[0].fg, Color::Indexed(1));
|
|
}
|
|
|
|
/// Pending UTF-8 prefix that exceeds the 8-byte defensive cap
|
|
/// (pathological producer that never finishes a sequence)
|
|
/// flushes as U+FFFD and recovers. Ensures the buffer can't
|
|
/// grow unbounded.
|
|
#[test]
|
|
fn ansi_utf8_pathological_pending_caps_at_8_bytes() {
|
|
let mut p = AnsiParser::new();
|
|
// Feed 8 start bytes in a row. Each is a "start of 2-byte"
|
|
// marker; the next one arriving where a continuation is
|
|
// expected is malformed. The first one accumulates; each
|
|
// subsequent one emits U+FFFD for the malformed prefix.
|
|
// After 8 bytes accumulated without completing, the cap
|
|
// forces a flush.
|
|
let evs = p.feed(&[0xC3; 9]);
|
|
let text = collect_text(&evs);
|
|
// Every byte should have been replaced; no panic, no
|
|
// unbounded growth.
|
|
assert!(
|
|
text.chars().all(|c| c == '\u{FFFD}'),
|
|
"all 9 bytes should be replaced; got {text:?}"
|
|
);
|
|
}
|
|
|
|
/// `reset()` clears any pending UTF-8 prefix.
|
|
#[test]
|
|
fn ansi_utf8_reset_clears_pending_buffer() {
|
|
let mut p = AnsiParser::new();
|
|
let _ = p.feed(&[0xC3]); // partial é prefix pending
|
|
p.reset();
|
|
// Subsequent valid input must decode cleanly --- the stale
|
|
// prefix should not contaminate it.
|
|
let evs = p.feed("hello".as_bytes());
|
|
assert_eq!(collect_text(&evs), "hello");
|
|
}
|
|
|
|
/// SGR 9 (strikethrough) is a no-op for the running style ---
|
|
/// the cell `Style` has no strikethrough field, so the running
|
|
/// style must be *unchanged* after SGR 9. A future regression
|
|
/// that maps SGR 9 to another field (e.g., flips bold by
|
|
/// accident) is caught here.
|
|
#[test]
|
|
fn m6_3_strikethrough_sgr_9_does_not_corrupt_running_style() {
|
|
let mut p = AnsiParser::new();
|
|
// Set bold + italic + red, then SGR 9.
|
|
let _ = p.feed(b"\x1b[1;3;31m");
|
|
let evs_after_9 = p.feed(b"\x1b[9m");
|
|
let after = collect_styles(&evs_after_9);
|
|
assert_eq!(after.len(), 1, "SGR 9 should still emit a SetStyle");
|
|
let s = after[0];
|
|
assert!(
|
|
s.bold && s.italic && s.fg == Color::Indexed(1),
|
|
"SGR 9 must leave bold + italic + red intact; got {s:?}"
|
|
);
|
|
// Subsequent text uses the unchanged style.
|
|
let evs_text = p.feed(b"hi");
|
|
assert_eq!(collect_text(&evs_text), "hi");
|
|
}
|
|
}
|