irc_async/proto/
colors.rs

1//! An extension trait that provides the ability to strip IRC colors from a string
2use std::borrow::Cow;
3
4enum ParserState {
5    Text,
6    ColorCode,
7    Foreground1(char),
8    Foreground2,
9    Comma,
10    Background1(char),
11}
12struct Parser {
13    state: ParserState,
14}
15
16/// An extension trait giving strings a function to strip IRC colors
17pub trait FormattedStringExt<'a> {
18    /// Returns true if the string contains color, bold, underline or italics
19    fn is_formatted(&self) -> bool;
20
21    /// Returns the string with all color, bold, underline and italics stripped
22    fn strip_formatting(self) -> Cow<'a, str>;
23}
24
25const FORMAT_CHARACTERS: &[char] = &[
26    '\x02', // bold
27    '\x1F', // underline
28    '\x16', // reverse
29    '\x0F', // normal
30    '\x03', // color
31];
32
33impl<'a> FormattedStringExt<'a> for &'a str {
34    fn is_formatted(&self) -> bool {
35        self.contains(FORMAT_CHARACTERS)
36    }
37
38    fn strip_formatting(self) -> Cow<'a, str> {
39        if !self.is_formatted() {
40            return Cow::Borrowed(self);
41        }
42        let mut s = String::from(self);
43        strip_formatting(&mut s);
44        Cow::Owned(s)
45    }
46}
47
48fn strip_formatting(buf: &mut String) {
49    let mut parser = Parser::new();
50    buf.retain(|cur| parser.next(cur));
51}
52
53impl Parser {
54    fn new() -> Self {
55        Parser {
56            state: ParserState::Text,
57        }
58    }
59
60    fn next(&mut self, cur: char) -> bool {
61        use self::ParserState::*;
62        match self.state {
63            Text | Foreground1(_) | Foreground2 if cur == '\x03' => {
64                self.state = ColorCode;
65                false
66            }
67            Text => !FORMAT_CHARACTERS.contains(&cur),
68            ColorCode if cur.is_digit(10) => {
69                self.state = Foreground1(cur);
70                false
71            }
72            Foreground1('1') if cur.is_digit(6) => {
73                // can only consume another digit if previous char was 1.
74                self.state = Foreground2;
75                false
76            }
77            Foreground1(_) if cur.is_digit(6) => {
78                self.state = Text;
79                true
80            }
81            Foreground1(_) if cur == ',' => {
82                self.state = Comma;
83                false
84            }
85            Foreground2 if cur == ',' => {
86                self.state = Comma;
87                false
88            }
89            Comma if (cur.is_digit(10)) => {
90                self.state = Background1(cur);
91                false
92            }
93            Background1(prev) if cur.is_digit(6) => {
94                // can only consume another digit if previous char was 1.
95                self.state = Text;
96                prev != '1'
97            }
98            _ => {
99                self.state = Text;
100                true
101            }
102        }
103    }
104}
105
106impl FormattedStringExt<'static> for String {
107    fn is_formatted(&self) -> bool {
108        self.as_str().is_formatted()
109    }
110    fn strip_formatting(mut self) -> Cow<'static, str> {
111        if !self.is_formatted() {
112            return Cow::Owned(self);
113        }
114        strip_formatting(&mut self);
115        Cow::Owned(self)
116    }
117}
118
119#[cfg(test)]
120mod test {
121    use crate::proto::colors::FormattedStringExt;
122    use std::borrow::Cow;
123
124    macro_rules! test_formatted_string_ext {
125        { $( $name:ident ( $($line:tt)* ), )* } => {
126            $(
127            mod $name {
128                use super::*;
129                test_formatted_string_ext!(@ $($line)*);
130            }
131            )*
132        };
133        (@ $text:expr, should stripped into $expected:expr) => {
134            #[test]
135            fn test_formatted() {
136                assert!($text.is_formatted());
137            }
138            #[test]
139            fn test_strip() {
140                assert_eq!($text.strip_formatting(), $expected);
141            }
142        };
143        (@ $text:expr, is not formatted) => {
144            #[test]
145            fn test_formatted() {
146                assert!(!$text.is_formatted());
147            }
148            #[test]
149            fn test_strip() {
150                assert_eq!($text.strip_formatting(), $text);
151            }
152        }
153    }
154
155    test_formatted_string_ext! {
156        blank("", is not formatted),
157        blank2("    ", is not formatted),
158        blank3("\t\r\n", is not formatted),
159        bold("l\x02ol", should stripped into "lol"),
160        bold_from_string(String::from("l\x02ol"), should stripped into "lol"),
161        bold_hangul("우왕\x02굳", should stripped into "우왕굳"),
162        fg_color("l\x033ol", should stripped into "lol"),
163        fg_color2("l\x0312ol", should stripped into "lol"),
164        fg_bg_11("l\x031,2ol", should stripped into "lol"),
165        fg_bg_21("l\x0312,3ol", should stripped into "lol"),
166        fg_bg_12("l\x031,12ol", should stripped into "lol"),
167        fg_bg_22("l\x0312,13ol", should stripped into "lol"),
168        string_with_multiple_colors("hoo\x034r\x033a\x0312y", should stripped into "hooray"),
169        string_with_digit_after_color("\x0344\x0355\x0366", should stripped into "456"),
170        string_with_multiple_2digit_colors("hoo\x0310r\x0311a\x0312y", should stripped into "hooray"),
171        string_with_digit_after_2digit_color("\x031212\x031111\x031010", should stripped into "121110"),
172        thinking("🤔...", is not formatted),
173        unformatted("a plain text", is not formatted),
174    }
175
176    #[test]
177    fn test_strip_no_allocation_for_unformatted_text() {
178        if let Cow::Borrowed(formatted) = "plain text".strip_formatting() {
179            assert_eq!(formatted, "plain text");
180        } else {
181            panic!("allocation detected");
182        }
183    }
184}