1use fancy_regex::Regex;
16use std::sync::OnceLock;
17
18use crate::color::Color;
19use crate::style::Style;
20use crate::text::Text;
21
22fn sgr_style(code: u16) -> Option<&'static str> {
24 let spec = match code {
25 1 => "bold",
26 2 => "dim",
27 3 => "italic",
28 4 => "underline",
29 5 => "blink",
30 6 => "blink2",
31 7 => "reverse",
32 8 => "conceal",
33 9 => "strike",
34 21 => "underline2",
35 22 => "not dim not bold",
36 23 => "not italic",
37 24 => "not underline",
38 25 => "not blink",
39 26 => "not blink2",
40 27 => "not reverse",
41 28 => "not conceal",
42 29 => "not strike",
43 30 => "color(0)",
44 31 => "color(1)",
45 32 => "color(2)",
46 33 => "color(3)",
47 34 => "color(4)",
48 35 => "color(5)",
49 36 => "color(6)",
50 37 => "color(7)",
51 39 => "default",
52 40 => "on color(0)",
53 41 => "on color(1)",
54 42 => "on color(2)",
55 43 => "on color(3)",
56 44 => "on color(4)",
57 45 => "on color(5)",
58 46 => "on color(6)",
59 47 => "on color(7)",
60 49 => "on default",
61 51 => "frame",
62 52 => "encircle",
63 53 => "overline",
64 54 => "not frame not encircle",
65 55 => "not overline",
66 90 => "color(8)",
67 91 => "color(9)",
68 92 => "color(10)",
69 93 => "color(11)",
70 94 => "color(12)",
71 95 => "color(13)",
72 96 => "color(14)",
73 97 => "color(15)",
74 100 => "on color(8)",
75 101 => "on color(9)",
76 102 => "on color(10)",
77 103 => "on color(11)",
78 104 => "on color(12)",
79 105 => "on color(13)",
80 106 => "on color(14)",
81 107 => "on color(15)",
82 _ => return None,
83 };
84 Some(spec)
85}
86
87fn re_ansi() -> &'static Regex {
91 static RE: OnceLock<Regex> = OnceLock::new();
92 RE.get_or_init(|| {
93 Regex::new(r"(?:\x1b[0-?])|(?:\x1b\](.*?)\x1b\\)|(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
94 .expect("valid ansi regex")
95 })
96}
97
98enum Token {
101 Plain(String),
102 Sgr(String),
104 Osc(String),
106}
107
108fn tokenize(line: &str) -> Vec<Token> {
111 let mut tokens = Vec::new();
112 let mut position = 0;
113 for caps in re_ansi().captures_iter(line).flatten() {
114 let whole = caps.get(0).expect("group 0 always present");
115 let (start, end) = (whole.start(), whole.end());
116 if start > position {
117 tokens.push(Token::Plain(line[position..start].to_string()));
118 }
119 match caps.get(2) {
120 Some(sgr) => {
121 let sgr = sgr.as_str();
122 if sgr == "(" {
123 position = end + line[end..].chars().next().map_or(0, char::len_utf8);
128 continue;
129 }
130 if let Some(params) = sgr.strip_prefix('[').and_then(|s| s.strip_suffix('m')) {
131 tokens.push(Token::Sgr(params.to_string()));
132 }
133 }
135 None => {
136 if let Some(osc) = caps.get(1) {
140 tokens.push(Token::Osc(osc.as_str().to_string()));
141 }
142 }
143 }
144 position = end;
145 }
146 if position < line.len() {
147 tokens.push(Token::Plain(line[position..].to_string()));
148 }
149 tokens
150}
151
152#[derive(Default)]
157pub struct AnsiDecoder {
158 style: Style,
159}
160
161impl AnsiDecoder {
162 pub fn new() -> Self {
163 AnsiDecoder {
164 style: Style::new(),
165 }
166 }
167
168 pub fn decode(&mut self, terminal_text: &str) -> Vec<Text> {
177 self.decode_split_newlines(terminal_text)
178 }
179
180 pub fn decode_split_newlines(&mut self, terminal_text: &str) -> Vec<Text> {
183 terminal_text
184 .split_inclusive('\n')
185 .chain((terminal_text.is_empty() || terminal_text.ends_with('\n')).then_some(""))
186 .map(|line| self.decode_line(line.trim_end_matches('\n')))
187 .collect()
188 }
189
190 pub fn decode_line(&mut self, line: &str) -> Text {
192 let line = line.rsplit('\r').next().unwrap_or(line);
194 let mut text = Text::new("");
195 for token in tokenize(line) {
196 match token {
197 Token::Plain(plain) => {
198 let style = if self.style.is_null() {
199 None
200 } else {
201 Some(self.style.clone().into())
202 };
203 text.append(&plain, style);
204 }
205 Token::Sgr(params) => self.apply_sgr(¶ms),
206 Token::Osc(osc) => self.apply_osc(&osc),
207 }
208 }
209 text
210 }
211
212 fn apply_osc(&mut self, osc: &str) {
217 if let Some(rest) = osc.strip_prefix("8;") {
218 if let Some(idx) = rest.find(';') {
220 let link = &rest[idx + 1..];
221 let link = (!link.is_empty()).then(|| link.to_string());
222 self.style = self.style.update_link(link);
223 }
224 }
225 }
226
227 fn apply_sgr(&mut self, params: &str) {
229 let codes: Vec<u16> = params
231 .split(';')
232 .filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
233 .map(|c| {
235 if c.is_empty() {
236 0
237 } else {
238 c.parse::<u32>().map_or(255, |code| code.min(255)) as u16
239 }
240 })
241 .collect();
242
243 let mut iter = codes.into_iter();
244 while let Some(code) = iter.next() {
245 if code == 0 {
246 self.style = Style::new();
247 } else if let Some(spec) = sgr_style(code) {
248 if let Ok(parsed) = Style::parse(spec) {
249 self.style = self.style.combine(&parsed);
250 }
251 } else if code == 38 {
252 if let Some(color) = read_extended_color(&mut iter) {
253 self.style = self.style.combine(&Style::from_color(Some(color), None));
254 }
255 } else if code == 48 {
256 if let Some(color) = read_extended_color(&mut iter) {
257 self.style = self.style.combine(&Style::from_color(None, Some(color)));
258 }
259 }
260 }
261 }
262}
263
264fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
268 match iter.next()? {
269 5 => Some(Color::from_ansi(iter.next()? as u8)),
270 2 => {
271 let r = iter.next()? as u8;
272 let g = iter.next()? as u8;
273 let b = iter.next()? as u8;
274 Some(Color::from_rgb(r, g, b))
275 }
276 _ => None,
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::color::ColorSystem;
284 use crate::console::Console;
285
286 #[test]
287 fn decode_splits_as_rich_15() {
288 let cases: [(&str, &[&str]); 5] = [
290 ("a\r\nb\n", &["", "b", ""]),
291 ("", &[""]),
292 ("x", &["x"]),
293 ("a\n\nb", &["a", "", "b"]),
294 (
295 "\x1b[1mz\nw\x1b[0m\n",
296 &["[bold]z[/bold]", "[bold]w[/bold]", ""],
297 ),
298 ];
299 for (input, expected) in cases {
300 let got: Vec<String> = AnsiDecoder::new()
301 .decode(input)
302 .iter()
303 .map(Text::markup)
304 .collect();
305 assert_eq!(got, expected, "{input:?}");
306 }
307 }
308
309 fn round_trip(input: &str) -> String {
310 let mut decoder = AnsiDecoder::new();
311 let console = Console::builder()
312 .force_terminal(true)
313 .color_system(Some(ColorSystem::Truecolor))
314 .width(80)
315 .build();
316 decoder
317 .decode(input)
318 .iter()
319 .map(|t| console.render_to_string(t))
320 .collect::<Vec<_>>()
321 .join("\n")
322 }
323
324 #[test]
325 fn charset_select_skips_one_character_as_rich_15() {
326 let cases: [(&str, &[&str]); 4] = [
330 ("a\x1b(\u{e9}b", &["ab"]),
331 ("\x1b(\u{4e2d}\u{6587}", &["\u{6587}"]),
332 ("x\x1b(", &["x"]),
333 ("\x1b(\x1b[1mz", &["[bold]z[/bold]"]),
334 ];
335 for (input, expected) in cases {
336 let got: Vec<String> = AnsiDecoder::new()
337 .decode(input)
338 .iter()
339 .map(Text::markup)
340 .collect();
341 assert_eq!(got, expected, "{input:?}");
342 }
343 }
344
345 #[test]
346 fn plain_text_has_no_style() {
347 assert_eq!(round_trip("hello"), "hello");
348 }
349
350 #[test]
351 fn bold_red_round_trips() {
352 assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
354 }
355
356 #[test]
357 fn eight_bit_and_truecolor() {
358 assert_eq!(
359 round_trip("\x1b[38;5;214mx\x1b[0m"),
360 "\x1b[38;5;214mx\x1b[0m"
361 );
362 assert_eq!(
363 round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
364 "\x1b[38;2;255;136;0mx\x1b[0m"
365 );
366 }
367
368 #[test]
369 fn style_persists_until_reset() {
370 assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
372 }
373
374 #[test]
375 fn non_sgr_csi_is_dropped() {
376 assert_eq!(round_trip("\x1b[2Jhi"), "hi");
377 }
378
379 #[test]
380 fn osc8_hyperlink_round_trips() {
381 assert_eq!(
385 round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
386 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
387 );
388 }
389
390 #[test]
391 fn osc8_link_without_style_and_clear() {
392 assert_eq!(
395 round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
396 "before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
397 );
398 }
399
400 #[test]
401 fn osc8_id_param_is_ignored() {
402 assert_eq!(
405 round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
406 "\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
407 );
408 }
409}