1use alloc::string::String;
9use alloc::vec::Vec;
10use core::iter::Peekable;
11use core::mem;
12use core::str::Chars;
13
14const BOLD: char = '\u{02}';
15const ITALIC: char = '\u{1D}';
16const UNDERLINE: char = '\u{1F}';
17const STRIKETHROUGH: char = '\u{1E}';
18const MONOSPACE: char = '\u{11}';
19const REVERSE: char = '\u{16}';
20const RESET: char = '\u{0F}';
21const COLOUR: char = '\u{03}';
22const HEX_COLOUR: char = '\u{04}';
23const CTCP_DELIM: char = '\u{01}';
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
32pub enum Colour {
33 Numbered(u8),
35 Hex(u8, u8, u8),
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
46pub struct Emphasis(u8);
47
48impl Emphasis {
49 const BOLD: u8 = 0b0000_0001;
50 const ITALIC: u8 = 0b0000_0010;
51 const UNDERLINE: u8 = 0b0000_0100;
52 const STRIKETHROUGH: u8 = 0b0000_1000;
53 const MONOSPACE: u8 = 0b0001_0000;
54 const REVERSE: u8 = 0b0010_0000;
55
56 pub fn bold(self) -> bool {
58 self.0 & Self::BOLD != 0
59 }
60
61 pub fn italic(self) -> bool {
63 self.0 & Self::ITALIC != 0
64 }
65
66 pub fn underline(self) -> bool {
68 self.0 & Self::UNDERLINE != 0
69 }
70
71 pub fn strikethrough(self) -> bool {
73 self.0 & Self::STRIKETHROUGH != 0
74 }
75
76 pub fn monospace(self) -> bool {
78 self.0 & Self::MONOSPACE != 0
79 }
80
81 pub fn reverse(self) -> bool {
84 self.0 & Self::REVERSE != 0
85 }
86
87 fn toggle(&mut self, bit: u8) {
88 self.0 ^= bit;
89 }
90}
91
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
96pub struct Style {
97 pub emphasis: Emphasis,
99 pub foreground: Option<Colour>,
101 pub background: Option<Colour>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
109pub struct Span {
110 pub text: String,
112 pub style: Style,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
120pub struct Ctcp {
121 pub command: String,
123 pub params: String,
125}
126
127pub fn parse_ctcp(body: &str) -> Option<Ctcp> {
136 let inner = body.strip_prefix(CTCP_DELIM)?;
137 let inner = inner.strip_suffix(CTCP_DELIM).unwrap_or(inner);
138 let (command, params) = inner.split_once(' ').unwrap_or((inner, ""));
139 Some(Ctcp {
140 command: command.into(),
141 params: params.into(),
142 })
143}
144
145pub fn parse_spans(body: &str) -> Vec<Span> {
150 let mut spans = Vec::new();
151 let mut style = Style::default();
152 let mut current = String::new();
153 let mut chars = body.chars().peekable();
154
155 while let Some(c) = chars.next() {
156 match c {
157 BOLD => {
158 flush(&mut spans, &mut current, &style);
159 style.emphasis.toggle(Emphasis::BOLD);
160 }
161 ITALIC => {
162 flush(&mut spans, &mut current, &style);
163 style.emphasis.toggle(Emphasis::ITALIC);
164 }
165 UNDERLINE => {
166 flush(&mut spans, &mut current, &style);
167 style.emphasis.toggle(Emphasis::UNDERLINE);
168 }
169 STRIKETHROUGH => {
170 flush(&mut spans, &mut current, &style);
171 style.emphasis.toggle(Emphasis::STRIKETHROUGH);
172 }
173 MONOSPACE => {
174 flush(&mut spans, &mut current, &style);
175 style.emphasis.toggle(Emphasis::MONOSPACE);
176 }
177 REVERSE => {
178 flush(&mut spans, &mut current, &style);
179 style.emphasis.toggle(Emphasis::REVERSE);
180 }
181 RESET => {
182 flush(&mut spans, &mut current, &style);
183 style = Style::default();
184 }
185 COLOUR => {
186 flush(&mut spans, &mut current, &style);
187 apply_colour(&mut style, parse_numbered_colour(&mut chars));
188 }
189 HEX_COLOUR => {
190 flush(&mut spans, &mut current, &style);
191 apply_colour(&mut style, parse_hex_colour(&mut chars));
192 }
193 _ => current.push(c),
194 }
195 }
196 flush(&mut spans, &mut current, &style);
197 spans
198}
199
200pub fn strip_formatting(body: &str) -> String {
202 parse_spans(body)
203 .into_iter()
204 .map(|span| span.text)
205 .collect()
206}
207
208fn flush(spans: &mut Vec<Span>, current: &mut String, style: &Style) {
209 if current.is_empty() {
210 return;
211 }
212 spans.push(Span {
213 text: mem::take(current),
214 style: *style,
215 });
216}
217
218fn apply_colour(style: &mut Style, parsed: Option<(Colour, Option<Colour>)>) {
219 if let Some((foreground, background)) = parsed {
221 style.foreground = Some(foreground);
222 if let Some(background) = background {
223 style.background = Some(background);
224 }
225 } else {
226 style.foreground = None;
227 style.background = None;
228 }
229}
230
231fn parse_numbered_colour(chars: &mut Peekable<Chars<'_>>) -> Option<(Colour, Option<Colour>)> {
235 let foreground = take_digits(chars, 2)?;
236 let background = take_comma_digits(chars);
237 Some((
238 Colour::Numbered(foreground),
239 background.map(Colour::Numbered),
240 ))
241}
242
243fn parse_hex_colour(chars: &mut Peekable<Chars<'_>>) -> Option<(Colour, Option<Colour>)> {
246 let foreground = take_hex_triple(chars)?;
247 let background = take_comma_hex_triple(chars);
248 Some((
249 Colour::Hex(foreground.0, foreground.1, foreground.2),
250 background,
251 ))
252}
253
254fn take_digits(chars: &mut Peekable<Chars<'_>>, max: u8) -> Option<u8> {
258 let mut probe = chars.clone();
259 let mut value: u8 = 0;
260 let mut count = 0u8;
261 while count < max {
262 let Some(digit) = probe.peek().and_then(|c| c.to_digit(10)) else {
263 break;
264 };
265 value = value * 10 + u8::try_from(digit).unwrap_or_default();
266 probe.next();
267 count += 1;
268 }
269 if count == 0 {
270 return None;
271 }
272 *chars = probe;
273 Some(value)
274}
275
276fn take_comma_digits(chars: &mut Peekable<Chars<'_>>) -> Option<u8> {
277 let mut probe = chars.clone();
278 if probe.next() != Some(',') {
279 return None;
280 }
281 let value = take_digits(&mut probe, 2)?;
282 *chars = probe;
283 Some(value)
284}
285
286fn hex_byte(chars: &mut Peekable<Chars<'_>>) -> Option<u8> {
287 let hi = u8::try_from(chars.next()?.to_digit(16)?).unwrap_or_default();
288 let lo = u8::try_from(chars.next()?.to_digit(16)?).unwrap_or_default();
289 Some((hi << 4) | lo)
290}
291
292fn take_hex_triple(chars: &mut Peekable<Chars<'_>>) -> Option<(u8, u8, u8)> {
293 let mut probe = chars.clone();
294 let triple = (
295 hex_byte(&mut probe)?,
296 hex_byte(&mut probe)?,
297 hex_byte(&mut probe)?,
298 );
299 *chars = probe;
300 Some(triple)
301}
302
303fn take_comma_hex_triple(chars: &mut Peekable<Chars<'_>>) -> Option<Colour> {
304 let mut probe = chars.clone();
305 if probe.next() != Some(',') {
306 return None;
307 }
308 let (r, g, b) = take_hex_triple(&mut probe)?;
309 *chars = probe;
310 Some(Colour::Hex(r, g, b))
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use alloc::vec;
317
318 fn plain(text: &str) -> Span {
319 Span {
320 text: text.into(),
321 style: Style::default(),
322 }
323 }
324
325 #[test]
326 fn plain_text_passes_through_untouched() {
327 assert_eq!(parse_spans("hello world"), vec![plain("hello world")]);
328 assert_eq!(strip_formatting("hello world"), "hello world");
329 }
330
331 #[test]
332 fn bold_wraps_the_text_between_toggles() {
333 let spans = parse_spans("a\u{02}b\u{02}c");
334 assert_eq!(spans[0], plain("a"));
335 assert!(spans[1].style.emphasis.bold());
336 assert_eq!(spans[1].text, "b");
337 assert!(!spans[2].style.emphasis.bold());
338 assert_eq!(spans[2].text, "c");
339 }
340
341 #[test]
342 fn italic_underline_strikethrough_monospace_and_reverse_each_toggle_their_own_flag() {
343 let spans = parse_spans("\u{1D}i\u{1F}u\u{1E}s\u{11}m\u{16}r");
344 assert!(spans[0].style.emphasis.italic());
345 assert!(spans[1].style.emphasis.italic() && spans[1].style.emphasis.underline());
346 assert!(spans[2].style.emphasis.strikethrough());
347 assert!(spans[3].style.emphasis.monospace());
348 assert!(spans[4].style.emphasis.reverse());
349 }
350
351 #[test]
352 fn reverse_is_kept_not_dropped() {
353 let spans = parse_spans("\u{16}flipped");
354 assert!(
355 spans[0].style.emphasis.reverse(),
356 "reverse must survive into the span"
357 );
358 }
359
360 #[test]
361 fn a_colour_code_with_one_digit_sets_only_the_foreground() {
362 let spans = parse_spans("\u{03}4red");
363 assert_eq!(spans[0].style.foreground, Some(Colour::Numbered(4)));
364 assert_eq!(spans[0].style.background, None);
365 }
366
367 #[test]
368 fn a_colour_code_with_a_background_sets_both() {
369 let spans = parse_spans("\u{03}4,8text");
370 assert_eq!(spans[0].style.foreground, Some(Colour::Numbered(4)));
371 assert_eq!(spans[0].style.background, Some(Colour::Numbered(8)));
372 }
373
374 #[test]
375 fn a_colour_code_stops_at_two_digits() {
376 let spans = parse_spans("\u{03}123abc");
377 assert_eq!(spans[0].style.foreground, Some(Colour::Numbered(12)));
378 assert_eq!(spans[0].text, "3abc");
379 }
380
381 #[test]
382 fn a_comma_not_followed_by_a_digit_is_left_as_text() {
383 let spans = parse_spans("\u{03}4,hi");
384 assert_eq!(spans[0].style.foreground, Some(Colour::Numbered(4)));
385 assert_eq!(spans[0].style.background, None);
386 assert_eq!(spans[0].text, ",hi");
387 }
388
389 #[test]
390 fn a_bare_colour_code_resets_colour() {
391 let spans = parse_spans("\u{03}4,8a\u{03}b");
392 assert_eq!(spans[1].style.foreground, None);
393 assert_eq!(spans[1].style.background, None);
394 assert_eq!(spans[1].text, "b");
395 }
396
397 #[test]
398 fn hex_colour_reads_six_digits_per_half() {
399 let spans = parse_spans("\u{04}FF00AAtext");
400 assert_eq!(
401 spans[0].style.foreground,
402 Some(Colour::Hex(0xFF, 0x00, 0xAA))
403 );
404 assert_eq!(spans[0].text, "text");
405 }
406
407 #[test]
408 fn hex_colour_with_a_background() {
409 let spans = parse_spans("\u{04}FF00AA,00FF00text");
410 assert_eq!(
411 spans[0].style.foreground,
412 Some(Colour::Hex(0xFF, 0x00, 0xAA))
413 );
414 assert_eq!(
415 spans[0].style.background,
416 Some(Colour::Hex(0x00, 0xFF, 0x00))
417 );
418 }
419
420 #[test]
421 fn a_short_hex_run_is_not_a_colour() {
422 let spans = parse_spans("\u{04}FF0text");
423 assert_eq!(spans[0].style.foreground, None);
424 assert_eq!(spans[0].text, "FF0text");
425 }
426
427 #[test]
428 fn reset_clears_every_flag_and_both_colours() {
429 let spans = parse_spans("\u{02}\u{03}4,8bold-red\u{0F}plain");
430 assert!(spans[0].style.emphasis.bold());
431 assert_eq!(spans[0].style.foreground, Some(Colour::Numbered(4)));
432 assert_eq!(spans[1].style, Style::default());
433 assert_eq!(spans[1].text, "plain");
434 }
435
436 #[test]
437 fn styles_nest_and_unwind_independently() {
438 let spans = parse_spans("\u{02}bold\u{1D}bold-italic\u{02}italic-only");
439 assert!(spans[0].style.emphasis.bold() && !spans[0].style.emphasis.italic());
440 assert!(spans[1].style.emphasis.bold() && spans[1].style.emphasis.italic());
441 assert!(!spans[2].style.emphasis.bold() && spans[2].style.emphasis.italic());
442 }
443
444 #[test]
445 fn an_unterminated_colour_code_does_not_panic_or_lose_text() {
446 assert_eq!(strip_formatting("text\u{03}"), "text");
447 assert_eq!(strip_formatting("text\u{03}5"), "text");
448 assert_eq!(parse_spans("text\u{03}5"), vec![plain("text")]);
449 }
450
451 #[test]
452 fn a_ctcp_action_keeps_its_formatting_parseable() {
453 let ctcp = parse_ctcp("\u{01}ACTION waves \u{02}hi\u{02}\u{01}").expect("ctcp body");
454 assert_eq!(ctcp.command, "ACTION");
455 assert_eq!(ctcp.params, "waves \u{02}hi\u{02}");
456 let spans = parse_spans(&ctcp.params);
457 assert_eq!(spans[0], plain("waves "));
458 assert!(spans[1].style.emphasis.bold());
459 assert_eq!(spans[1].text, "hi");
460 }
461
462 #[test]
463 fn a_ctcp_body_without_a_closing_delimiter_still_parses() {
464 let ctcp = parse_ctcp("\u{01}VERSION").expect("ctcp body");
465 assert_eq!(ctcp.command, "VERSION");
466 assert_eq!(ctcp.params, "");
467 }
468
469 #[test]
470 fn a_non_ctcp_body_is_not_recognised() {
471 assert_eq!(parse_ctcp("hello"), None);
472 assert_eq!(parse_ctcp(""), None);
473 }
474}