graphql_starter/ansi/
text.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5
6use super::{Ansi, AnsiIter, Color, Error, Minifier};
7
8/// A text string that contains an optional style
9#[derive(Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
10#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
11pub struct StyledText {
12    /// The text
13    pub text: String,
14    /// The optional style
15    pub style: Option<TextStyle>,
16}
17
18/// The style of a text
19#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
20#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
21pub struct TextStyle {
22    /// The foreground hex color
23    pub fg: Option<String>,
24    /// The background hex color
25    pub bg: Option<String>,
26    /// The text effects
27    pub effects: TextEffects,
28}
29
30/// The effects of a text
31#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
32#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
33pub struct TextEffects {
34    /// Bold or increased intensity
35    pub bold: bool,
36    /// Faint, decreased intensity, or dim
37    pub faint: bool,
38    /// Italic
39    pub italic: bool,
40    /// Underlined
41    pub underline: bool,
42    /// Strike or crossed-out
43    pub strikethrough: bool,
44}
45
46// From here on, based on https://github.com/Aloso/to-html/blob/main/crates/ansi-to-html/src/html/mod.rs
47
48#[derive(Debug, Copy, Clone, Eq, PartialEq)]
49pub(super) enum AnsiStyle {
50    Bold,
51    Faint,
52    Italic,
53    Underline,
54    CrossedOut,
55    ForegroundColor(Color),
56    BackgroundColor(Color),
57}
58
59impl From<&Vec<AnsiStyle>> for TextStyle {
60    fn from(styles: &Vec<AnsiStyle>) -> Self {
61        let mut ret = Self::default();
62        for s in styles {
63            match s {
64                AnsiStyle::Bold => ret.effects.bold = true,
65                AnsiStyle::Faint => ret.effects.faint = true,
66                AnsiStyle::Italic => ret.effects.italic = true,
67                AnsiStyle::Underline => ret.effects.underline = true,
68                AnsiStyle::CrossedOut => ret.effects.strikethrough = true,
69                AnsiStyle::ForegroundColor(fg) => ret.fg = Some(fg.to_string()),
70                AnsiStyle::BackgroundColor(bg) => ret.bg = Some(bg.to_string()),
71            }
72        }
73        ret
74    }
75}
76
77#[derive(Debug, Default)]
78pub(super) struct AnsiConverter {
79    styles: Vec<AnsiStyle>,
80    current_text: String,
81    result: Vec<StyledText>,
82}
83
84impl AnsiConverter {
85    pub(super) fn new() -> Self {
86        Self::default()
87    }
88
89    pub(super) fn consume_ansi_code(&mut self, ansi: Ansi) {
90        match ansi {
91            Ansi::Noop => {}
92            Ansi::Reset => self.clear_style(|_| true),
93            Ansi::Bold => self.set_style(AnsiStyle::Bold),
94            Ansi::Faint => self.set_style(AnsiStyle::Faint),
95            Ansi::Italic => self.set_style(AnsiStyle::Italic),
96            Ansi::Underline => self.set_style(AnsiStyle::Underline),
97            Ansi::CrossedOut => self.set_style(AnsiStyle::CrossedOut),
98            Ansi::BoldOff => self.clear_style(|&s| s == AnsiStyle::Bold),
99            Ansi::BoldAndFaintOff => self.clear_style(|&s| s == AnsiStyle::Bold || s == AnsiStyle::Faint),
100            Ansi::ItalicOff => self.clear_style(|&s| s == AnsiStyle::Italic),
101            Ansi::UnderlineOff => self.clear_style(|&s| s == AnsiStyle::Underline),
102            Ansi::CrossedOutOff => self.clear_style(|&s| s == AnsiStyle::CrossedOut),
103            Ansi::ForgroundColor(c) => self.set_style(AnsiStyle::ForegroundColor(c)),
104            Ansi::DefaultForegroundColor => self.clear_style(|&s| matches!(s, AnsiStyle::ForegroundColor(_))),
105            Ansi::BackgroundColor(c) => self.set_style(AnsiStyle::BackgroundColor(c)),
106            Ansi::DefaultBackgroundColor => self.clear_style(|&s| matches!(s, AnsiStyle::BackgroundColor(_))),
107        }
108    }
109
110    pub(super) fn push_str(&mut self, s: &str) {
111        self.current_text.push_str(s);
112    }
113
114    fn set_style(&mut self, s: AnsiStyle) {
115        if !self.styles.contains(&s) {
116            self.checkpoint();
117            self.styles.push(s);
118        }
119    }
120
121    fn clear_style(&mut self, mut cond: impl Fn(&AnsiStyle) -> bool) {
122        if self.styles.iter().any(&mut cond) {
123            self.checkpoint();
124            self.styles.retain(|s| !cond(s));
125        }
126    }
127
128    fn checkpoint(&mut self) {
129        if !self.current_text.is_empty() {
130            let text = std::mem::take(&mut self.current_text);
131            self.result.push(StyledText {
132                text,
133                style: if self.styles.is_empty() {
134                    None
135                } else {
136                    Some((&self.styles).into())
137                },
138            })
139        }
140    }
141
142    pub(super) fn result(mut self) -> Vec<StyledText> {
143        self.checkpoint();
144        self.result
145    }
146}
147
148static ANSI_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new("\x1b(\\[[0-9;?]*[A-HJKSTfhilmnsu]|\\(B)").unwrap());
149
150/// Convert ANSI sequences to styled text.
151pub fn ansi_to_text(mut input: &str) -> Result<Vec<StyledText>, Error> {
152    let mut minifier = Minifier::new();
153
154    loop {
155        match ANSI_REGEX.find(input) {
156            Some(m) => {
157                if m.start() > 0 {
158                    let (before, after) = input.split_at(m.start());
159                    minifier.push_str(before);
160                    input = after;
161                }
162
163                let len = m.range().len();
164                input = &input[len..];
165
166                if !m.as_str().ends_with('m') {
167                    continue;
168                }
169
170                if len == 3 {
171                    minifier.clear_styles();
172                    continue;
173                }
174
175                let nums = &m.as_str()[2..len - 1];
176                let nums = nums.split(';').map(|n| n.parse::<u8>());
177
178                for ansi in AnsiIter::new(nums) {
179                    minifier.push_ansi_code(ansi?);
180                }
181            }
182            None => {
183                minifier.push_str(input);
184                break;
185            }
186        }
187    }
188    minifier.push_ansi_code(Ansi::Reset); // make sure all tags are closed
189
190    Ok(minifier.result())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test() {
199        let text = "1970-01-01T00:00:00.000000Z  INFO \
200                    graphql_starter::ansi::text::tests: test-event-#1";
201        let mut res = ansi_to_text(text).unwrap();
202
203        let date = res.remove(0);
204        assert_eq!(
205            date,
206            StyledText {
207                text: "1970-01-01T00:00:00.000000Z".into(),
208                style: Some(TextStyle {
209                    fg: None,
210                    bg: None,
211                    effects: TextEffects {
212                        bold: false,
213                        faint: true,
214                        italic: false,
215                        underline: false,
216                        strikethrough: false,
217                    },
218                },),
219            }
220        );
221
222        let whitespace = res.remove(0);
223        assert_eq!(
224            whitespace,
225            StyledText {
226                text: " ".into(),
227                style: None
228            }
229        );
230
231        let level = res.remove(0);
232        assert_eq!(
233            level,
234            StyledText {
235                text: " INFO".into(),
236                style: Some(TextStyle {
237                    fg: Some("#0a0".into(),),
238                    bg: None,
239                    effects: TextEffects {
240                        bold: false,
241                        faint: false,
242                        italic: false,
243                        underline: false,
244                        strikethrough: false,
245                    },
246                },),
247            }
248        );
249
250        let whitespace = res.remove(0);
251        assert_eq!(
252            whitespace,
253            StyledText {
254                text: " ".into(),
255                style: None
256            }
257        );
258
259        let location = res.remove(0);
260        assert_eq!(
261            location,
262            StyledText {
263                text: "graphql_starter::ansi::text::tests:".into(),
264                style: Some(TextStyle {
265                    fg: None,
266                    bg: None,
267                    effects: TextEffects {
268                        bold: false,
269                        faint: true,
270                        italic: false,
271                        underline: false,
272                        strikethrough: false,
273                    },
274                },),
275            }
276        );
277
278        let log = res.remove(0);
279        assert_eq!(
280            log,
281            StyledText {
282                text: " test-event-#1".into(),
283                style: None
284            }
285        );
286    }
287}