Skip to content

Commit 988699b

Browse files
committed
ansi: strip OSC and DCS sequences
Treat OSC and DCS (including tmux passthrough wrappers) as non-printing when iterating/stripping ANSI codes. Adds regression tests for OSC 8 hyperlinks (ST/BEL) and tmux-wrapped OSC 8 hyperlinks.
1 parent 0bf645d commit 988699b

1 file changed

Lines changed: 190 additions & 34 deletions

File tree

src/ansi.rs

Lines changed: 190 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,142 @@ use core::{
66
str::CharIndices,
77
};
88

9+
#[derive(Clone, Copy, Debug)]
10+
struct OscSequence;
11+
12+
impl EscSequence for OscSequence {
13+
const START: char = ']';
14+
15+
fn on_escape(next: Option<char>) -> EscAction {
16+
if matches!(next, Some('\\')) {
17+
EscAction {
18+
consume_next: true,
19+
end: true,
20+
}
21+
} else {
22+
EscAction {
23+
consume_next: false,
24+
end: false,
25+
}
26+
}
27+
}
28+
29+
fn on_char(c: char) -> EscAction {
30+
EscAction {
31+
consume_next: false,
32+
end: c == '\u{07}',
33+
}
34+
}
35+
}
36+
37+
#[derive(Clone, Copy, Debug)]
38+
struct DcsSequence;
39+
40+
impl EscSequence for DcsSequence {
41+
const START: char = 'P';
42+
43+
fn on_escape(next: Option<char>) -> EscAction {
44+
match next {
45+
Some('\\') => EscAction {
46+
consume_next: true,
47+
end: true,
48+
},
49+
None => EscAction {
50+
consume_next: false,
51+
end: true,
52+
},
53+
Some('\u{1b}') => EscAction {
54+
consume_next: true,
55+
end: false,
56+
},
57+
_ => EscAction {
58+
consume_next: false,
59+
end: false,
60+
},
61+
}
62+
}
63+
64+
fn on_char(_c: char) -> EscAction {
65+
EscAction {
66+
consume_next: false,
67+
end: false,
68+
}
69+
}
70+
}
71+
72+
trait EscSequence {
73+
fn on_escape(next: Option<char>) -> EscAction;
74+
fn on_char(c: char) -> EscAction;
75+
const START: char;
76+
}
77+
78+
struct EscAction {
79+
consume_next: bool,
80+
end: bool,
81+
}
82+
83+
fn consume_end_exclusive<S: EscSequence>(
84+
it: &mut Peekable<CharIndices<'_>>,
85+
start: usize,
86+
) -> usize {
87+
let mut end = start + 1;
88+
let Some((idx, start_char)) = it.next() else {
89+
return end;
90+
};
91+
if start_char != S::START {
92+
return end;
93+
}
94+
end = idx + 1;
95+
96+
while let Some((idx, c)) = it.next() {
97+
end = idx + c.len_utf8();
98+
match c {
99+
'\u{9c}' => return end,
100+
'\u{1b}' => {
101+
let action = S::on_escape(it.peek().map(|(_, next)| *next));
102+
if action.consume_next {
103+
if let Some((next_idx, _)) = it.peek() {
104+
end = *next_idx + 1;
105+
it.next();
106+
}
107+
}
108+
if action.end {
109+
return end;
110+
}
111+
}
112+
_ => {}
113+
}
114+
if S::on_char(c).end {
115+
return end;
116+
}
117+
}
118+
119+
end
120+
}
121+
122+
fn find_dfa_end_exclusive_after_entry(it: &mut Peekable<CharIndices<'_>>) -> Option<usize> {
123+
let mut state = State::S1;
124+
let mut maybe_end = None;
125+
126+
loop {
127+
let item = it.peek();
128+
129+
if let Some((idx, c)) = item {
130+
state.transition(*c);
131+
132+
if state.is_final() {
133+
maybe_end = Some(*idx);
134+
}
135+
}
136+
137+
if state.is_trapped() || item.is_none() {
138+
return maybe_end.map(|end| end + 1);
139+
}
140+
141+
it.next();
142+
}
143+
}
144+
9145
#[derive(Debug, Clone, Copy, Default)]
10146
enum State {
11147
#[default]
@@ -143,42 +279,36 @@ impl FusedIterator for Matches<'_> {}
143279

144280
fn find_ansi_code_exclusive(it: &mut Peekable<CharIndices>) -> Option<(usize, usize)> {
145281
'outer: loop {
146-
if let (start, '\u{1b}') | (start, '\u{9b}') = it.peek()? {
147-
let start = *start;
148-
let mut state = State::default();
149-
let mut maybe_end = None;
150-
151-
loop {
152-
let item = it.peek();
153-
154-
if let Some((idx, c)) = item {
155-
state.transition(*c);
156-
157-
if state.is_final() {
158-
maybe_end = Some(*idx);
282+
let (start, entry) = *it.peek()?;
283+
match entry {
284+
'\u{1b}' => {
285+
it.next();
286+
match it.peek() {
287+
Some((_, OscSequence::START)) => {
288+
return Some((start, consume_end_exclusive::<OscSequence>(it, start)))
159289
}
160-
}
161-
162-
// The match is greedy so run till we hit the trap state no matter what. A valid
163-
// match is just one that was final at some point
164-
if state.is_trapped() || item.is_none() {
165-
match maybe_end {
166-
Some(end) => {
167-
// All possible final characters are a single byte so it's safe to make
168-
// the end exclusive by just adding one
169-
return Some((start, end + 1));
290+
Some((_, DcsSequence::START)) => {
291+
return Some((start, consume_end_exclusive::<DcsSequence>(it, start)))
292+
}
293+
_ => {
294+
if let Some(end) = find_dfa_end_exclusive_after_entry(it) {
295+
return Some((start, end));
170296
}
171-
// The character we are peeking right now might be the start of a match so
172-
// we want to continue the loop without popping off that char
173-
None => continue 'outer,
297+
continue 'outer;
174298
}
175299
}
176-
300+
}
301+
'\u{9b}' => {
302+
it.next();
303+
if let Some(end) = find_dfa_end_exclusive_after_entry(it) {
304+
return Some((start, end));
305+
}
306+
continue 'outer;
307+
}
308+
_ => {
177309
it.next();
178310
}
179311
}
180-
181-
it.next();
182312
}
183313
}
184314

@@ -296,12 +426,16 @@ mod tests {
296426
use proptest::prelude::*;
297427
use regex::Regex;
298428

299-
// The manual dfa `State` is a handwritten translation from the previously used regex. That
300-
// regex is kept here and used to ensure that the new matches are the same as the old
429+
// The manual dfa `State` is a handwritten translation of the following regex.
301430
static STRIP_ANSI_RE: Lazy<Regex> = Lazy::new(|| {
302-
Regex::new(
303-
r"[\x1b\x9b]([()][012AB]|[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><])",
304-
)
431+
Regex::new(concat!(
432+
r"(?s)(?:",
433+
r"\x1b\].*?(?:\x07|\x9c|\x1b\\|\z)|",
434+
r"\x1bP(?:[^\x1b\x9c]|\x1b\x1b|\x1b[^\x1b\\])*?(?:\x9c|\x1b\\|\x1b\z|\z)|",
435+
r"[\x1b\x9b]([()][012AB]|[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?",
436+
r"[0-9A-PRZcf-nqry=><])",
437+
r")",
438+
))
305439
.unwrap()
306440
});
307441

@@ -415,6 +549,28 @@ mod tests {
415549
}
416550
}
417551

552+
#[test]
553+
fn strip_osc8_hyperlink_st() {
554+
let s = "\x1b]8;;file:///tmp/test\x1b\\hello\x1b]8;;\x1b\\";
555+
assert_eq!(strip_ansi_codes(s).as_ref(), "hello");
556+
}
557+
558+
#[test]
559+
fn strip_osc8_hyperlink_bel() {
560+
let s = "\x1b]8;;file:///tmp/test\x07hello\x1b]8;;\x07";
561+
assert_eq!(strip_ansi_codes(s).as_ref(), "hello");
562+
}
563+
564+
#[test]
565+
fn strip_tmux_passthrough_dcs() {
566+
let open = "\x1bPtmux;\x1b\x1b\x1b]8;;file:///tmp/test\x1b\x1b\\\x1b\\";
567+
let close = "\x1bPtmux;\x1b\x1b\x1b]8;;\x1b\x1b\\\x1b\\";
568+
assert_eq!(
569+
strip_ansi_codes(&format!("{open}hello{close}")).as_ref(),
570+
"hello"
571+
);
572+
}
573+
418574
#[test]
419575
fn test_ansi_iter_re_vt100() {
420576
let s = "\x1b(0lpq\x1b)Benglish";

0 commit comments

Comments
 (0)