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 {
90 static RE: OnceLock<Regex> = OnceLock::new();
91 RE.get_or_init(|| {
92 Regex::new(r"(?:\x1b\](.*?)\x1b\\)|(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
93 .expect("valid ansi regex")
94 })
95}
96
97enum Token {
100 Plain(String),
101 Sgr(String),
103 Osc(String),
105}
106
107fn tokenize(line: &str) -> Vec<Token> {
110 let mut tokens = Vec::new();
111 let mut position = 0;
112 for caps in re_ansi().captures_iter(line).flatten() {
113 let whole = caps.get(0).expect("group 0 always present");
114 let (start, end) = (whole.start(), whole.end());
115 if start > position {
116 tokens.push(Token::Plain(line[position..start].to_string()));
117 }
118 match caps.get(2) {
119 Some(sgr) => {
120 let sgr = sgr.as_str();
121 if sgr == "(" {
122 position = (end + 1).min(line.len());
124 continue;
125 }
126 if let Some(params) = sgr.strip_prefix('[').and_then(|s| s.strip_suffix('m')) {
127 tokens.push(Token::Sgr(params.to_string()));
128 }
129 }
131 None => {
132 if let Some(osc) = caps.get(1) {
135 tokens.push(Token::Osc(osc.as_str().to_string()));
136 }
137 }
138 }
139 position = end;
140 }
141 if position < line.len() {
142 tokens.push(Token::Plain(line[position..].to_string()));
143 }
144 tokens
145}
146
147#[derive(Default)]
152pub struct AnsiDecoder {
153 style: Style,
154}
155
156impl AnsiDecoder {
157 pub fn new() -> Self {
158 AnsiDecoder {
159 style: Style::new(),
160 }
161 }
162
163 pub fn decode(&mut self, terminal_text: &str) -> Vec<Text> {
165 terminal_text.lines().map(|l| self.decode_line(l)).collect()
167 }
168
169 pub fn decode_line(&mut self, line: &str) -> Text {
171 let line = line.rsplit('\r').next().unwrap_or(line);
173 let mut text = Text::new("");
174 for token in tokenize(line) {
175 match token {
176 Token::Plain(plain) => {
177 let style = if self.style.is_null() {
178 None
179 } else {
180 Some(self.style.clone().into())
181 };
182 text.append(&plain, style);
183 }
184 Token::Sgr(params) => self.apply_sgr(¶ms),
185 Token::Osc(osc) => self.apply_osc(&osc),
186 }
187 }
188 text
189 }
190
191 fn apply_osc(&mut self, osc: &str) {
196 if let Some(rest) = osc.strip_prefix("8;") {
197 if let Some(idx) = rest.find(';') {
199 let link = &rest[idx + 1..];
200 let link = (!link.is_empty()).then(|| link.to_string());
201 self.style = self.style.update_link(link);
202 }
203 }
204 }
205
206 fn apply_sgr(&mut self, params: &str) {
208 let codes: Vec<u16> = params
210 .split(';')
211 .filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
212 .map(|c| c.parse::<u32>().unwrap_or(0).min(255) as u16)
213 .collect();
214
215 let mut iter = codes.into_iter();
216 while let Some(code) = iter.next() {
217 if code == 0 {
218 self.style = Style::new();
219 } else if let Some(spec) = sgr_style(code) {
220 if let Ok(parsed) = Style::parse(spec) {
221 self.style = self.style.combine(&parsed);
222 }
223 } else if code == 38 {
224 if let Some(color) = read_extended_color(&mut iter) {
225 self.style = self.style.combine(&Style::from_color(Some(color), None));
226 }
227 } else if code == 48 {
228 if let Some(color) = read_extended_color(&mut iter) {
229 self.style = self.style.combine(&Style::from_color(None, Some(color)));
230 }
231 }
232 }
233 }
234}
235
236fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
240 match iter.next()? {
241 5 => Some(Color::from_ansi(iter.next()? as u8)),
242 2 => {
243 let r = iter.next()? as u8;
244 let g = iter.next()? as u8;
245 let b = iter.next()? as u8;
246 Some(Color::from_rgb(r, g, b))
247 }
248 _ => None,
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::color::ColorSystem;
256 use crate::console::Console;
257
258 fn round_trip(input: &str) -> String {
259 let mut decoder = AnsiDecoder::new();
260 let console = Console::builder()
261 .force_terminal(true)
262 .color_system(Some(ColorSystem::Truecolor))
263 .width(80)
264 .build();
265 decoder
266 .decode(input)
267 .iter()
268 .map(|t| console.render_to_string(t))
269 .collect::<Vec<_>>()
270 .join("\n")
271 }
272
273 #[test]
274 fn plain_text_has_no_style() {
275 assert_eq!(round_trip("hello"), "hello");
276 }
277
278 #[test]
279 fn bold_red_round_trips() {
280 assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
282 }
283
284 #[test]
285 fn eight_bit_and_truecolor() {
286 assert_eq!(
287 round_trip("\x1b[38;5;214mx\x1b[0m"),
288 "\x1b[38;5;214mx\x1b[0m"
289 );
290 assert_eq!(
291 round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
292 "\x1b[38;2;255;136;0mx\x1b[0m"
293 );
294 }
295
296 #[test]
297 fn style_persists_until_reset() {
298 assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
300 }
301
302 #[test]
303 fn non_sgr_csi_is_dropped() {
304 assert_eq!(round_trip("\x1b[2Jhi"), "hi");
305 }
306
307 #[test]
308 fn osc8_hyperlink_round_trips() {
309 assert_eq!(
313 round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
314 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
315 );
316 }
317
318 #[test]
319 fn osc8_link_without_style_and_clear() {
320 assert_eq!(
323 round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
324 "before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
325 );
326 }
327
328 #[test]
329 fn osc8_id_param_is_ignored() {
330 assert_eq!(
333 round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
334 "\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
335 );
336 }
337}