Skip to main content

obby_proto/
format.rs

1//! mIRC formatting codes and CTCP framing inside a message body.
2//!
3//! `PRIVMSG`/`NOTICE` bodies are plain bytes with a handful of control characters mixed in: toggles
4//! for bold and friends, `\x03`/`\x04` colour introducers, and the `\x01`-delimited CTCP wrapper. None
5//! of it is escaped, so a colour code's digits are only ambiguous, never invalid, and a parser has to
6//! pick one reading rather than reject the line.
7
8use 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/// A colour carried by `\x03` or `\x04`.
26///
27/// Both introducers are kept as one type because either can appear as a foreground or a background,
28/// and a consumer rendering a [`Style`] does not care which byte put it there.
29#[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    /// One of mIRC's 99 numbered colours (`\x03`), `0` through `99`.
34    Numbered(u8),
35    /// A 24-bit colour (`\x04`), as `(red, green, blue)`.
36    Hex(u8, u8, u8),
37}
38
39/// The six independent text-decoration toggles, packed into one byte.
40///
41/// mIRC turns each on and off independently of the others, so this is a bitset rather than the six
42/// separate `bool` fields that would trip `clippy::struct_excessive_bools`.
43#[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    /// True when bold (`\x02`) is active.
57    pub fn bold(self) -> bool {
58        self.0 & Self::BOLD != 0
59    }
60
61    /// True when italic (`\x1D`) is active.
62    pub fn italic(self) -> bool {
63        self.0 & Self::ITALIC != 0
64    }
65
66    /// True when underline (`\x1F`) is active.
67    pub fn underline(self) -> bool {
68        self.0 & Self::UNDERLINE != 0
69    }
70
71    /// True when strikethrough (`\x1E`) is active.
72    pub fn strikethrough(self) -> bool {
73        self.0 & Self::STRIKETHROUGH != 0
74    }
75
76    /// True when monospace (`\x11`) is active.
77    pub fn monospace(self) -> bool {
78        self.0 & Self::MONOSPACE != 0
79    }
80
81    /// True when reverse video (`\x16`) is active. Carried through rather than dropped: the reference
82    /// client emits this byte but never draws it.
83    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/// The formatting active over a run of text.
93#[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    /// The active bold/italic/underline/strikethrough/monospace/reverse toggles.
98    pub emphasis: Emphasis,
99    /// The active foreground, if a colour code set one.
100    pub foreground: Option<Colour>,
101    /// The active background, if a colour code set one.
102    pub background: Option<Colour>,
103}
104
105/// A run of text plus the [`Style`] active over it.
106#[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    /// The text, with every control code removed.
111    pub text: String,
112    /// The formatting active while this text was written.
113    pub style: Style,
114}
115
116/// A CTCP request or reply extracted from a message body, such as `ACTION waves`.
117#[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    /// The command word, such as `ACTION` or `VERSION`.
122    pub command: String,
123    /// Everything after the command, still carrying any formatting codes of its own.
124    pub params: String,
125}
126
127/// Recognise a CTCP-wrapped body: `\x01COMMAND params\x01`.
128///
129/// The reference client slices `ACTION` out with a hand-rolled prefix check and renders the result
130/// without ever running it back through formatting. Returning `params` untouched here means a caller
131/// can hand it straight to [`parse_spans`] instead.
132///
133/// The closing `\x01` is optional: some clients drop it, and a body missing one still names a real
134/// command.
135pub 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
145/// Parse a message body into styled spans.
146///
147/// A malformed or unterminated colour sequence consumes only what it can validly read and leaves the
148/// rest as text, so no byte of the original body is ever dropped.
149pub 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
200/// The plain text of a message body, with every formatting and colour code removed.
201pub 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    // a colour code with no digits after it resets colour, same as a bare `\x0F` for style
220    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
231/// `None` means the code had no digits at all, which mIRC treats as a colour reset. Otherwise the
232/// foreground is always present; the background is only set when a comma is immediately followed by
233/// a digit, so a bare trailing comma is left as ordinary text.
234fn 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
243/// Same shape as [`parse_numbered_colour`], but each half needs exactly six hex digits: a short or
244/// broken run is not a partial colour, it is text that happens to start with hex digits.
245fn 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
254/// Take up to `max` ASCII digits, greedily. A colour code followed by three digits always claims the
255/// first two: mIRC has no way to say "this one-digit colour is followed by a literal digit," so every
256/// implementation reads the maximum it can.
257fn 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}