irc_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_ascii_digit() => {
69                self.state = Foreground1(cur);
70                false
71            }
72            Foreground1('0') if cur.is_ascii_digit() => {
73                // can consume another digit if previous char was 0.
74                self.state = Foreground2;
75                false
76            }
77            Foreground1('1') if cur.is_digit(6) => {
78                // can consume another digit if previous char was 1.
79                self.state = Foreground2;
80                false
81            }
82            Foreground1(_) if cur.is_digit(6) => {
83                self.state = Text;
84                true
85            }
86            Foreground1(_) if cur == ',' => {
87                self.state = Comma;
88                false
89            }
90            Foreground2 if cur == ',' => {
91                self.state = Comma;
92                false
93            }
94            Comma if (cur.is_ascii_digit()) => {
95                self.state = Background1(cur);
96                false
97            }
98            Background1(prev) if cur.is_digit(6) => {
99                // can only consume another digit if previous char was 1.
100                self.state = Text;
101                prev != '1'
102            }
103            _ => {
104                self.state = Text;
105                !FORMAT_CHARACTERS.contains(&cur)
106            }
107        }
108    }
109}
110
111impl FormattedStringExt<'static> for String {
112    fn is_formatted(&self) -> bool {
113        self.as_str().is_formatted()
114    }
115    fn strip_formatting(mut self) -> Cow<'static, str> {
116        if !self.is_formatted() {
117            return Cow::Owned(self);
118        }
119        strip_formatting(&mut self);
120        Cow::Owned(self)
121    }
122}
123
124#[cfg(test)]
125mod test {
126    use crate::colors::FormattedStringExt;
127    use std::borrow::Cow;
128
129    macro_rules! test_formatted_string_ext {
130        { $( $name:ident ( $($line:tt)* ), )* } => {
131            $(
132            mod $name {
133                use super::*;
134                test_formatted_string_ext!(@ $($line)*);
135            }
136            )*
137        };
138        (@ $text:expr, should stripped into $expected:expr) => {
139            #[test]
140            fn test_formatted() {
141                assert!($text.is_formatted());
142            }
143            #[test]
144            fn test_strip() {
145                assert_eq!($text.strip_formatting(), $expected);
146            }
147        };
148        (@ $text:expr, is not formatted) => {
149            #[test]
150            fn test_formatted() {
151                assert!(!$text.is_formatted());
152            }
153            #[test]
154            fn test_strip() {
155                assert_eq!($text.strip_formatting(), $text);
156            }
157        }
158    }
159
160    test_formatted_string_ext! {
161        blank("", is not formatted),
162        blank2("    ", is not formatted),
163        blank3("\t\r\n", is not formatted),
164        bold("l\x02ol", should stripped into "lol"),
165        bold_from_string(String::from("l\x02ol"), should stripped into "lol"),
166        bold_hangul("우왕\x02굳", should stripped into "우왕굳"),
167        fg_color("l\x033ol", should stripped into "lol"),
168        fg_color2("l\x0312ol", should stripped into "lol"),
169        fg_color3("l\x0302ol", should stripped into "lol"),
170        fg_color4("l\x030ol", should stripped into "lol"),
171        fg_color5("l\x0309ol", should stripped into "lol"),
172        fg_bg_11("l\x031,2ol", should stripped into "lol"),
173        fg_bg_21("l\x0312,3ol", should stripped into "lol"),
174        fg_bg_12("l\x031,12ol", should stripped into "lol"),
175        fg_bg_22("l\x0312,13ol", should stripped into "lol"),
176        fg_bold("l\x0309\x02ol", should stripped into "lol"),
177        string_with_multiple_colors("hoo\x034r\x033a\x0312y", should stripped into "hooray"),
178        string_with_digit_after_color("\x0344\x0355\x0366", should stripped into "456"),
179        string_with_multiple_2digit_colors("hoo\x0310r\x0311a\x0312y", should stripped into "hooray"),
180        string_with_digit_after_2digit_color("\x031212\x031111\x031010", should stripped into "121110"),
181        thinking("🤔...", is not formatted),
182        unformatted("a plain text", is not formatted),
183    }
184
185    #[test]
186    fn test_strip_no_allocation_for_unformatted_text() {
187        if let Cow::Borrowed(formatted) = "plain text".strip_formatting() {
188            assert_eq!(formatted, "plain text");
189        } else {
190            panic!("allocation detected");
191        }
192    }
193}