1use std::sync::LazyLock;
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5
6use super::{Ansi, AnsiIter, Color, Error, Minifier};
7
8#[derive(Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
10#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
11pub struct StyledText {
12 pub text: String,
14 pub style: Option<TextStyle>,
16}
17
18#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
20#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
21pub struct TextStyle {
22 pub fg: Option<String>,
24 pub bg: Option<String>,
26 pub effects: TextEffects,
28}
29
30#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
32#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
33pub struct TextEffects {
34 pub bold: bool,
36 pub faint: bool,
38 pub italic: bool,
40 pub underline: bool,
42 pub strikethrough: bool,
44}
45
46#[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
150pub 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); Ok(minifier.result())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test() {
199 let text = "[2m1970-01-01T00:00:00.000000Z[0m [32m INFO[0m \
200 [2mgraphql_starter::ansi::text::tests[0m[2m:[0m 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}