fancy_table/
ansi.rs

1use std::{borrow::Cow, cmp::min, fmt, ops::Deref};
2
3#[derive(PartialEq)]
4enum AnsiToken {
5    Escape,
6    Opening,
7    Code,
8}
9
10#[derive(Debug)]
11pub struct AnsiSlice<'a> {
12    pub slice: Cow<'a, str>,
13    pub len: usize,
14    pub needs_rst: bool,
15}
16
17impl<'a> Deref for AnsiSlice<'a> {
18    type Target = str;
19
20    fn deref(&self) -> &Self::Target {
21        self.slice.as_ref()
22    }
23}
24
25impl<'a> PartialEq<&str> for AnsiSlice<'a> {
26    fn eq(&self, other: &&str) -> bool {
27        self.slice.as_ref() == *other
28    }
29}
30
31impl<'a> PartialEq<String> for AnsiSlice<'a> {
32    fn eq(&self, other: &String) -> bool {
33        self.slice.as_ref() == other.as_str()
34    }
35}
36
37impl<'a> AnsiSlice<'a> {
38    pub fn tupled(&'a self) -> (&'a str, usize) {
39        (self.slice.as_ref(), self.len)
40    }
41
42    pub fn owned(self) -> String {
43        match self.slice {
44            Cow::Owned(text) => text,
45            Cow::Borrowed(text) => text.to_string(),
46        }
47    }
48}
49
50#[derive(Debug)]
51/// Representation of a single segment of a String with ANSI codes: an optional opening code (SGR) and a reset code.
52/// Each segment contains an optional code only at the starting position and a reset code at the end.
53///
54/// Example: "\x1b[38;2;255;105;180mHot Pink\x1b[0m"
55pub struct AnsiSegment<'a> {
56    pub sgr_code: Option<Cow<'a, str>>,
57    pub rst_code: Option<Cow<'a, str>>,
58    pub text: &'a str,
59
60    /// is this initial segment of the string?
61    is_initial: bool,
62}
63
64#[derive(Debug, Default)]
65/// Represents a string containing ANSI escape codes. The string is internally divided into segments,
66/// where each segment represents a portion of the string with its associated opening SGR (Select Graphic Rendition)
67/// code and corresponding reset code.
68pub struct AnsiString<'a> {
69    len: usize,
70    segments: Vec<AnsiSegment<'a>>,
71}
72
73impl<'a> fmt::Display for AnsiSegment<'a> {
74    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
75        let sgr = self.sgr_code.as_deref().unwrap_or("");
76        let rst = self.rst_code.as_deref().unwrap_or("");
77        write!(f, "{}{}{}", sgr, self.text, rst)
78    }
79}
80
81impl<'a> AnsiSegment<'a> {
82    pub fn len(&self) -> usize {
83        self.text.chars().count() + self.is_initial as usize
84    }
85
86    /// Returns the byte size of a segment. The size is calculated based on the following components:
87    ///
88    ///  - The size of the text (in bytes)
89    ///  - The combined size of both SGR (Select Graphic Rendition) and reset codes
90    ///  - An initial flag that, if true, adds +1 to the size to account for a separating space
91    fn size(&self) -> usize {
92        self.text.len() + self.sgr_size() + self.rst_size() + self.is_initial as usize
93    }
94    fn has_sgr_code(&self) -> bool {
95        self.sgr_code.is_some()
96    }
97    fn has_rst_code(&self) -> bool {
98        self.rst_code.is_some()
99    }
100    /// Returns SGR code size in case of Cow::Borrowed variant.
101    /// For Cow::Owned returns 0 as the code cannot be the part of text slice.
102    fn sgr_size(&self) -> usize {
103        if self.is_sgr_owned() {
104            return 0;
105        }
106        self.sgr_code.as_ref().map(|c| c.len()).unwrap_or(0)
107    }
108    fn rst_size(&self) -> usize {
109        self.rst_code.as_ref().map(|c| c.len()).unwrap_or(0)
110    }
111    fn is_sgr_owned(&self) -> bool {
112        matches!(self.sgr_code, Some(Cow::Owned(_)))
113    }
114}
115
116impl<'a> AnsiString<'a> {
117    pub fn new(input: &'a str) -> Self {
118        build_ansi_string(input)
119    }
120    pub fn with_sgr(mut self, codes: Option<String>) -> Self {
121        if let Some(codes) = codes
122            && !codes.is_empty()
123            && let Some(seg) = self.segments.first_mut()
124        {
125            seg.sgr_code = Some(Cow::Owned(codes))
126        }
127        self
128    }
129    pub fn segments(&self) -> &[AnsiSegment<'a>] {
130        &self.segments
131    }
132    pub fn is_empty(&self) -> bool {
133        self.len == 0
134    }
135    pub fn append(&mut self, mut str: AnsiString<'a>) {
136        if !self.segments.is_empty()
137            && let Some(seg) = str.segments.first_mut()
138        {
139            // appended string cannot start with Cow::Owned SGR.
140            assert!(!seg.is_sgr_owned());
141
142            seg.is_initial = true;
143            self.len += 1;
144        }
145        self.len += str.len();
146        self.segments.extend(str.segments);
147    }
148
149    /// Returns the number of visible characters in the string.
150    /// ANSI codes are not counted.
151    pub fn len(&self) -> usize {
152        self.len
153    }
154
155    /// Returns an SGR code (or multiple joined codes) that should be applied
156    /// when continuing text formatting on a text break after the last segment
157    /// of an AnsiString.
158    pub fn codes_to_continue(&self) -> String {
159        let codes = self
160            .segments
161            .iter()
162            .rev()
163            .take_while(|seg| !seg.has_rst_code())
164            .filter_map(|seg| seg.sgr_code.as_ref().map(|c| c.as_ref()))
165            .collect::<Vec<_>>();
166
167        codes.join("")
168    }
169
170    /// Returns a substring of the specified length while preserving ANSI codes intact - no ANSI code
171    /// will be corrupted. For performance reasons, returns a Cow<str> since in most cases the result
172    /// will simply be a sub-slice of the AnsiString. The only case when an owned variant is returned
173    /// is when the initial SGR code (stored in the first segment) was explicitly set using the
174    /// `with_sgr` function during AnsiString creation.
175    pub fn get(&self, len: usize) -> AnsiSlice<'a> {
176        let (text_len, bytes, is_terminating_rst) =
177            self.segments
178                .iter()
179                .fold((0, 0, true), |(text_len, byte_size, is_reset), segment| {
180                    if text_len >= len {
181                        (text_len, byte_size, is_reset)
182                    } else {
183                        let slen = text_len + segment.len();
184                        let diff = slen.saturating_sub(len);
185                        (
186                            min(slen, len),
187                            byte_size
188                                + if diff == 0 {
189                                    segment.size()
190                                } else {
191                                    segment.sgr_size()
192                                        + segment
193                                            .text
194                                            .char_indices()
195                                            .nth(segment.len() - diff)
196                                            .map_or(segment.text.len(), |(byte_idx, _)| byte_idx)
197                                },
198                            (segment.has_rst_code() && diff == 0)
199                                || (is_reset && !segment.has_sgr_code()),
200                        )
201                    }
202                });
203
204        if let Some((ptr, seg)) = self.slice_ptr() {
205            let str =
206                unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, bytes)) };
207
208            return AnsiSlice {
209                slice: if let Some(sgr) = seg.sgr_code.as_ref()
210                    && seg.is_sgr_owned()
211                {
212                    Cow::Owned(format!("{sgr}{str}"))
213                } else {
214                    Cow::Borrowed(str)
215                },
216                len: text_len,
217                needs_rst: !is_terminating_rst,
218            };
219        }
220        AnsiSlice {
221            slice: Cow::Borrowed(""),
222            len: 0,
223            needs_rst: false,
224        }
225    }
226
227    fn push_segment(&mut self, segment: AnsiSegment<'a>) {
228        self.len += segment.len() + !self.is_empty() as usize;
229        self.segments.push(segment);
230    }
231
232    fn slice_ptr(&self) -> Option<(*const u8, &AnsiSegment<'a>)> {
233        if let Some(seg) = self.segments.first() {
234            return if !seg.has_sgr_code() || seg.is_sgr_owned() {
235                Some((seg.text.as_ptr(), seg))
236            } else {
237                seg.sgr_code.as_ref().map(|c| (c.as_ptr(), seg))
238            };
239        }
240        None
241    }
242}
243
244pub fn build_ansi_string<'a>(input: &'a str) -> AnsiString<'a> {
245    let mut result = AnsiString::default();
246    let mut expected = AnsiToken::Escape;
247    let mut last_code = (0, 0, false); // start, end, is_reset
248    let mut sequence;
249
250    let mut current_code_start = 0;
251    let mut text_byte_size: usize = 0;
252
253    for (pos, ch) in input.char_indices() {
254        text_byte_size += ch.len_utf8();
255        match ch {
256            '\x1b' if expected == AnsiToken::Escape => {
257                expected = AnsiToken::Opening;
258                current_code_start = pos;
259            }
260            '[' if expected == AnsiToken::Opening => expected = AnsiToken::Code,
261            'm' if expected == AnsiToken::Code => {
262                // Valid SGR sequence terminator
263                sequence = &input[current_code_start..pos + 1];
264                text_byte_size = text_byte_size.saturating_sub(sequence.len());
265
266                let is_reset = sequence == "\x1b[0m";
267                let is_text = text_byte_size > 0;
268                let (last_code_start, last_code_end, was_reset) = last_code;
269
270                // Chunk of text found
271                if is_text {
272                    result.push_segment(AnsiSegment {
273                        text: &input[last_code_end..last_code_end + text_byte_size],
274                        sgr_code: if !was_reset && last_code_end > last_code_start {
275                            Some(Cow::Borrowed(&input[last_code_start..last_code_end]))
276                        } else {
277                            None
278                        },
279                        rst_code: if is_reset {
280                            Some(Cow::Borrowed(sequence))
281                        } else {
282                            None
283                        },
284                        is_initial: false,
285                    });
286                }
287                last_code = (
288                    if is_text || was_reset {
289                        current_code_start
290                    } else {
291                        last_code_start
292                    },
293                    pos + 1,
294                    is_reset,
295                );
296                text_byte_size = 0;
297                expected = AnsiToken::Escape
298            }
299            '0'..='9' | ';' | ':' if expected == AnsiToken::Code => {
300                continue;
301            }
302            _ => {
303                // Invalid character - this is not a valid SGR sequence
304            }
305        }
306    }
307
308    // Final text block not ended with a code.
309    // Note, input might be just an empty string. This is to handle this case too.
310    if text_byte_size > 0 || input.is_empty() {
311        let seg = AnsiSegment {
312            sgr_code: if !last_code.2 && last_code.0 != last_code.1 {
313                Some(Cow::Borrowed(&input[last_code.0..last_code.1]))
314            } else {
315                None
316            },
317            rst_code: None,
318            text: &input[last_code.1..],
319            is_initial: false,
320        };
321        result.push_segment(seg)
322    }
323    result
324}
325
326#[macro_export]
327macro_rules! assert_segments {
328        ($string:expr, [$($segment:tt),+]) => {
329            {
330                let str = format!($string);
331                let ansi = $crate::AnsiString::new(&str);
332                let segments = ansi.segments();
333                let mut segment_index = 0;
334
335                $(
336                    assert_segments!(@verify_segment segments[segment_index], $segment);
337                    segment_index += 1;
338                )+
339                    assert_eq!(segments.len(), segment_index, "Expected {} segments, found {}", segment_index, segments.len());
340            }
341        };
342        (@verify_segment $seg:expr, { $($field:ident => $value:tt),* }) => {
343            let seg = &$seg;
344            $(
345                assert_segments!(@check_field seg, $field, $value);
346            )*
347        };
348        (@check_field $seg:expr, len, $expected:expr) => {
349            assert_eq!($seg.len(), $expected);
350        };
351        (@check_field $seg:expr, txt, $expected:expr) => {
352            assert_eq!($seg.text, $expected);
353        };
354        (@check_field $seg:expr, sgr, $expected:literal) => {
355            let formatted = format!($expected);
356            assert_eq!($seg.sgr_code, Some(std::borrow::Cow::Borrowed(formatted.as_str())));
357        };
358        (@check_field $seg:expr, sgr, None) => {
359            assert_eq!($seg.sgr_code, None)
360        };
361        (@check_field $seg:expr, rst, $expected:literal) => {
362            let formatted = format!($expected);
363            assert_eq!($seg.rst_code, Some(std::borrow::Cow::Borrowed(formatted.as_str())));
364        };
365        (@check_field $seg:expr, rst, None) => {
366            assert_eq!($seg.rst_code, None)
367        };
368    }