Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ repository = "https://github.com/console-rs/console"
documentation = "https://docs.rs/console"
readme = "README.md"
rust-version = "1.71"
include = ["CHANGELOG.md", "Cargo.toml", "LICENSE", "README.md", "src/**/*.rs"]
include = [
"CHANGELOG.md",
"Cargo.toml",
"LICENSE",
"README.md",
"src/**/*.rs",
"tests/data/sample_zellij_session.log",
]

[features]
default = ["unicode-width", "ansi-parsing", "std"]
Expand Down
222 changes: 188 additions & 34 deletions src/ansi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,136 @@ use core::{
str::CharIndices,
};

#[derive(Clone, Copy, Debug)]
struct OscSequence;

impl EscSequence for OscSequence {
const START: char = ']';

fn on_escape(next: Option<char>) -> EscAction {
let end = matches!(next, Some('\\'));
EscAction {
consume_next: end,
end,
}
}

fn on_char(c: char) -> EscAction {
EscAction {
consume_next: false,
end: c == '\u{07}',
}
}
}

#[derive(Clone, Copy, Debug)]
struct DcsSequence;

impl EscSequence for DcsSequence {
const START: char = 'P';

fn on_escape(next: Option<char>) -> EscAction {
match next {
Some('\\') => EscAction {
consume_next: true,
end: true,
},
None => EscAction {
consume_next: false,
end: true,
},
Some('\u{1b}') => EscAction {
consume_next: true,
end: false,
},
_ => EscAction {
consume_next: false,
end: false,
},
}
}

fn on_char(_c: char) -> EscAction {
EscAction {
consume_next: false,
end: false,
}
}
}

trait EscSequence {
fn on_escape(next: Option<char>) -> EscAction;
fn on_char(c: char) -> EscAction;
const START: char;
}

struct EscAction {
consume_next: bool,
end: bool,
}

fn consume_end_exclusive<S: EscSequence>(
Comment thread
djc marked this conversation as resolved.
it: &mut Peekable<CharIndices<'_>>,
start: usize,
) -> usize {
let mut end = start + 1;
let Some((idx, start_char)) = it.next() else {
return end;
};
if start_char != S::START {
return end;
}
end = idx + start_char.len_utf8();

while let Some((idx, c)) = it.next() {
end = idx + c.len_utf8();

if c == '\u{1b}' {
let action = S::on_escape(it.peek().map(|(_, next)| *next));
if action.consume_next {
if let Some((next_idx, next)) = it.next() {
end = next_idx + next.len_utf8();
}
}
if action.end {
return end;
}
continue;
}

match c {
'\u{9c}' => return end,
_ if S::on_char(c).end => return end,
_ => {}
}
}

end
}

fn find_dfa_end_exclusive_after_entry(it: &mut Peekable<CharIndices<'_>>) -> Option<usize> {
let mut state = State::S1;
let mut maybe_end = None;

loop {
let item = it.peek();

if let Some((idx, c)) = item {
state.transition(*c);

if state.is_final() {
maybe_end = Some(*idx);
}
}

if state.is_trapped() || item.is_none() {
return maybe_end.map(|end| end + 1);
}

it.next();
}
}

#[derive(Debug, Clone, Copy, Default)]
enum State {
#[default]
Expand Down Expand Up @@ -143,42 +273,36 @@ impl FusedIterator for Matches<'_> {}

fn find_ansi_code_exclusive(it: &mut Peekable<CharIndices>) -> Option<(usize, usize)> {
'outer: loop {
if let (start, '\u{1b}') | (start, '\u{9b}') = it.peek()? {
let start = *start;
let mut state = State::default();
let mut maybe_end = None;

loop {
let item = it.peek();

if let Some((idx, c)) = item {
state.transition(*c);

if state.is_final() {
maybe_end = Some(*idx);
let (start, entry) = *it.peek()?;
match entry {
'\u{1b}' => {
it.next();
match it.peek() {
Some((_, OscSequence::START)) => {
return Some((start, consume_end_exclusive::<OscSequence>(it, start)))
}
}

// The match is greedy so run till we hit the trap state no matter what. A valid
// match is just one that was final at some point
if state.is_trapped() || item.is_none() {
match maybe_end {
Some(end) => {
// All possible final characters are a single byte so it's safe to make
// the end exclusive by just adding one
return Some((start, end + 1));
Some((_, DcsSequence::START)) => {
return Some((start, consume_end_exclusive::<DcsSequence>(it, start)))
}
_ => {
if let Some(end) = find_dfa_end_exclusive_after_entry(it) {
return Some((start, end));
}
// The character we are peeking right now might be the start of a match so
// we want to continue the loop without popping off that char
None => continue 'outer,
continue 'outer;
}
}

}
'\u{9b}' => {
it.next();
if let Some(end) = find_dfa_end_exclusive_after_entry(it) {
return Some((start, end));
}
continue 'outer;
}
_ => {
it.next();
}
}

it.next();
}
}

Expand Down Expand Up @@ -296,14 +420,22 @@ mod tests {
use regex::Regex;
use std::sync::OnceLock;

// The manual dfa `State` is a handwritten translation from the previously used regex. That
// regex is kept here and used to ensure that the new matches are the same as the old
// Keep the handwritten parser aligned with this reference regex, including OSC sequences and
// DCS passthrough sequences used by tmux.
fn strip_ansi_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();

RE.get_or_init(|| Regex::new(
r"[\x1b\x9b]([()][012AB]|[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><])",
).unwrap())
RE.get_or_init(|| {
Regex::new(concat!(
r"(?s)(?:",
r"\x1b\].*?(?:\x07|\x9c|\x1b\\|\z)|",
r"\x1bP(?:[^\x1b\x9c]|\x1b\x1b|\x1b[^\x1b\\])*?(?:\x9c|\x1b\\|\x1b\z|\z)|",
r"[\x1b\x9b]([()][012AB]|[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?",
r"[0-9A-PRZcf-nqry=><])",
r")",
))
.unwrap()
})
}

impl<'a> PartialEq<Match<'a>> for regex::Match<'_> {
Expand Down Expand Up @@ -416,6 +548,28 @@ mod tests {
}
}

#[test]
fn strip_osc8_hyperlink_st() {
let s = "\x1b]8;;file:///tmp/test\x1b\\hello\x1b]8;;\x1b\\";
assert_eq!(strip_ansi_codes(s).as_ref(), "hello");
}

#[test]
fn strip_osc8_hyperlink_bel() {
let s = "\x1b]8;;file:///tmp/test\x07hello\x1b]8;;\x07";
assert_eq!(strip_ansi_codes(s).as_ref(), "hello");
}

#[test]
fn strip_tmux_passthrough_dcs() {
let open = "\x1bPtmux;\x1b\x1b\x1b]8;;file:///tmp/test\x1b\x1b\\\x1b\\";
let close = "\x1bPtmux;\x1b\x1b\x1b]8;;\x1b\x1b\\\x1b\\";
assert_eq!(
strip_ansi_codes(&format!("{open}hello{close}")).as_ref(),
"hello"
);
}

#[test]
fn test_ansi_iter_re_vt100() {
let s = "\x1b(0lpq\x1b)Benglish";
Expand Down
Loading