mc_legacy_formatting/
lib.rs

1//! A parser for Minecraft's [legacy formatting system][legacy_fmt], created
2//! with careful attention to the quirks of the vanilla client's implementation.
3//!
4//! # Features
5//!
6//! * Iterator-based, non-allocating parser
7//! * Supports `#![no_std]` usage (with `default-features` set to `false`)
8//! * Implements the entire spec as well as vanilla client quirks (such as handling
9//!   of whitespace with the `STRIKETHROUGH` style)
10//! * Helpers for pretty-printing the parsed [`Span`]s to the terminal
11//! * Support for parsing any start character for the formatting codes (vanilla
12//!   uses `§` while many community tools use `&`)
13//!
14//! # Examples
15//!
16//! Using [`SpanIter`]:
17//!
18//! ```
19//! use mc_legacy_formatting::{SpanExt, Span, Color, Styles};
20//!
21//! let s = "§4This will be dark red §oand italic";
22//! let mut span_iter = s.span_iter();
23//!
24//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("This will be dark red ", Color::DarkRed, Styles::empty()));
25//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("and italic", Color::DarkRed, Styles::ITALIC));
26//! assert!(span_iter.next().is_none());
27//! ```
28//!
29//! With a custom start character:
30//!
31//! ```
32//! use mc_legacy_formatting::{SpanExt, Span, Color, Styles};
33//!
34//! let s = "&6It's a lot easier to type &b& &6than &b§";
35//! let mut span_iter = s.span_iter().with_start_char('&');
36//!
37//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("It's a lot easier to type ", Color::Gold, Styles::empty()));
38//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("& ", Color::Aqua, Styles::empty()));
39//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("than ", Color::Gold, Styles::empty()));
40//! assert_eq!(span_iter.next().unwrap(), Span::new_styled("§", Color::Aqua, Styles::empty()));
41//! assert!(span_iter.next().is_none());
42//! ```
43//!
44//! [legacy_fmt]: https://wiki.vg/Chat#Colors
45
46#![no_std]
47#![deny(missing_docs)]
48#![deny(unused_must_use)]
49
50/// Bring `std` in for testing
51#[cfg(test)]
52extern crate std;
53
54use core::str::CharIndices;
55
56use bitflags::bitflags;
57
58#[cfg(feature = "color-print")]
59mod color_print;
60
61#[cfg(feature = "color-print")]
62pub use color_print::PrintSpanColored;
63
64/// An extension trait that adds a method for creating a [`SpanIter`]
65pub trait SpanExt {
66    /// Produces a [`SpanIter`] from `&self`
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use mc_legacy_formatting::{SpanExt, Span, Color, Styles};
72    ///
73    /// let s = "§4This will be dark red §oand italic";
74    /// let mut span_iter = s.span_iter();
75    ///
76    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("This will be dark red ", Color::DarkRed, Styles::empty()));
77    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("and italic", Color::DarkRed, Styles::ITALIC));
78    /// assert!(span_iter.next().is_none());
79    /// ```
80    fn span_iter(&self) -> SpanIter;
81}
82
83impl<T: AsRef<str>> SpanExt for T {
84    fn span_iter(&self) -> SpanIter {
85        SpanIter::new(self.as_ref())
86    }
87}
88
89/// An iterator that yields [`Span`]s from an input string.
90///
91/// # Examples
92///
93/// ```
94/// use mc_legacy_formatting::{SpanIter, Span, Color, Styles};
95///
96/// let s = "§4This will be dark red §oand italic";
97/// let mut span_iter = SpanIter::new(s);
98///
99/// assert_eq!(span_iter.next().unwrap(), Span::new_styled("This will be dark red ", Color::DarkRed, Styles::empty()));
100/// assert_eq!(span_iter.next().unwrap(), Span::new_styled("and italic", Color::DarkRed, Styles::ITALIC));
101/// assert!(span_iter.next().is_none());
102/// ```
103#[derive(Debug, Clone)]
104pub struct SpanIter<'a> {
105    buf: &'a str,
106    chars: CharIndices<'a>,
107    /// The character that indicates the beginning of a fmt code
108    ///
109    /// The vanilla client uses `§` for this, but community tooling often uses
110    /// `&`, so we allow it to be configured
111    start_char: char,
112    color: Color,
113    styles: Styles,
114    finished: bool,
115}
116
117impl<'a> SpanIter<'a> {
118    /// Create a new [`SpanIter`] to parse the given string
119    pub fn new(s: &'a str) -> Self {
120        Self {
121            buf: s,
122            chars: s.char_indices(),
123            start_char: '§',
124            color: Color::White,
125            styles: Styles::default(),
126            finished: false,
127        }
128    }
129
130    /// Set the start character used while parsing
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use mc_legacy_formatting::{SpanIter, Span, Color, Styles};
136    ///
137    /// let s = "&6It's a lot easier to type &b& &6than &b§";
138    /// let mut span_iter = SpanIter::new(s).with_start_char('&');
139    ///
140    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("It's a lot easier to type ", Color::Gold, Styles::empty()));
141    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("& ", Color::Aqua, Styles::empty()));
142    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("than ", Color::Gold, Styles::empty()));
143    /// assert_eq!(span_iter.next().unwrap(), Span::new_styled("§", Color::Aqua, Styles::empty()));
144    /// assert!(span_iter.next().is_none());
145    /// ```
146    pub fn with_start_char(mut self, c: char) -> Self {
147        self.start_char = c;
148        self
149    }
150
151    /// Set the start character used while parsing
152    pub fn set_start_char(&mut self, c: char) {
153        self.start_char = c;
154    }
155
156    /// Update the currently stored color
157    fn update_color(&mut self, color: Color) {
158        self.color = color;
159        // According to https://wiki.vg/Chat, using a color code resets the current
160        // style
161        self.styles = Styles::empty();
162    }
163
164    /// Insert `styles` into the currently stored styles
165    fn update_styles(&mut self, styles: Styles) {
166        self.styles.insert(styles);
167    }
168
169    /// Should be called when encountering the `RESET` fmt code
170    fn reset_styles(&mut self) {
171        self.color = Color::White;
172        self.styles = Styles::empty();
173    }
174
175    /// Make a [`Span`] based off the current state of the iterator
176    ///
177    /// The span will be from `start..end`
178    fn make_span(&self, start: usize, end: usize) -> Span<'a> {
179        if self.color == Color::White && self.styles.is_empty() {
180            Span::Plain(&self.buf[start..end])
181        } else {
182            let text = &self.buf[start..end];
183
184            // The vanilla client renders whitespace with `Styles::STRIKETHROUGH`
185            // as a solid line. This replicates that behavior
186            //
187            // (Technically it does this by drawing a line over any text slice
188            // with the `STRIKETHROUGH` style.)
189            if text.chars().all(|c| c.is_ascii_whitespace())
190                && self.styles.contains(Styles::STRIKETHROUGH)
191            {
192                Span::StrikethroughWhitespace {
193                    text,
194                    color: self.color,
195                    styles: self.styles,
196                }
197            } else {
198                Span::Styled {
199                    text,
200                    color: self.color,
201                    styles: self.styles,
202                }
203            }
204        }
205    }
206}
207
208/// Keeps track of the state for each iteration
209#[derive(Debug, Copy, Clone)]
210enum SpanIterState {
211    GatheringStyles(GatheringStylesState),
212    GatheringText(GatheringTextState),
213}
214
215/// In this state we are at the beginning of an iteration and we are looking to
216/// handle any initial formatting codes
217#[derive(Debug, Copy, Clone)]
218enum GatheringStylesState {
219    /// We're looking for our start char
220    ExpectingStartChar,
221    /// We've found our start char and are expecting a fmt code after it
222    ExpectingFmtCode,
223}
224
225/// In this state we've encountered text unrelated to formatting, which means
226/// the next valid fmt code we encounter ends this iteration
227#[derive(Debug, Copy, Clone)]
228enum GatheringTextState {
229    /// We're waiting to find our start char
230    WaitingForStartChar,
231    /// We've found our start char and are expecting a fmt code after it
232    ///
233    /// If we find a valid fmt code in this state, we need to make a span, apply
234    /// this last fmt code to our state, and end this iteration.
235    ExpectingEndChar,
236}
237
238impl<'a> Iterator for SpanIter<'a> {
239    type Item = Span<'a>;
240
241    fn next(&mut self) -> Option<Self::Item> {
242        use GatheringStylesState::*;
243        use GatheringTextState::*;
244        use SpanIterState::*;
245
246        if self.finished {
247            return None;
248        }
249        let mut state = GatheringStyles(ExpectingStartChar);
250        let mut span_start = None;
251        let mut span_end = None;
252
253        while let Some((idx, c)) = self.chars.next() {
254            state = match state {
255                GatheringStyles(style_state) => match style_state {
256                    ExpectingStartChar => {
257                        span_start = Some(idx);
258                        match c {
259                            c if c == self.start_char => GatheringStyles(ExpectingFmtCode),
260                            _ => GatheringText(WaitingForStartChar),
261                        }
262                    }
263                    ExpectingFmtCode => {
264                        if let Some(color) = Color::from_char(c) {
265                            self.update_color(color);
266                            span_start = None;
267                            GatheringStyles(ExpectingStartChar)
268                        } else if let Some(style) = Styles::from_char(c) {
269                            self.update_styles(style);
270                            span_start = None;
271                            GatheringStyles(ExpectingStartChar)
272                        } else if c == 'r' || c == 'R' {
273                            // Handle the `RESET` fmt code
274
275                            self.reset_styles();
276                            span_start = None;
277                            GatheringStyles(ExpectingStartChar)
278                        } else {
279                            GatheringText(WaitingForStartChar)
280                        }
281                    }
282                },
283                GatheringText(text_state) => match text_state {
284                    WaitingForStartChar => match c {
285                        c if c == self.start_char => {
286                            span_end = Some(idx);
287                            GatheringText(ExpectingEndChar)
288                        }
289                        _ => state,
290                    },
291                    ExpectingEndChar => {
292                        // Note that we only end this iteration if we find a valid fmt code
293                        //
294                        // If we do, we make sure to apply it to our state so that we can
295                        // pick up where we left off when the next iteration begins
296
297                        if let Some(color) = Color::from_char(c) {
298                            let span = self.make_span(span_start.unwrap(), span_end.unwrap());
299                            self.update_color(color);
300                            return Some(span);
301                        } else if let Some(style) = Styles::from_char(c) {
302                            let span = self.make_span(span_start.unwrap(), span_end.unwrap());
303                            self.update_styles(style);
304                            return Some(span);
305                        } else if c == 'r' || c == 'R' {
306                            // Handle the `RESET` fmt code
307
308                            let span = self.make_span(span_start.unwrap(), span_end.unwrap());
309                            self.reset_styles();
310                            return Some(span);
311                        } else {
312                            span_end = None;
313                            GatheringText(WaitingForStartChar)
314                        }
315                    }
316                },
317            }
318        }
319
320        self.finished = true;
321        span_start.map(|start| self.make_span(start, self.buf.len()))
322    }
323}
324
325/// Text with an associated color and associated styles.
326///
327/// [`Span`] implements [`Display`](core::fmt::Display) and can be neatly printed.
328#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
329pub enum Span<'a> {
330    /// A styled slice of text
331    Styled {
332        /// The styled text slice
333        text: &'a str,
334        /// The color of the text
335        color: Color,
336        /// Styles that should be applied to the text
337        styles: Styles,
338    },
339    /// An unbroken sequence of whitespace that was given the
340    /// [`STRIKETHROUGH`](Styles::STRIKETHROUGH) style.
341    ///
342    /// The vanilla client renders whitespace with the `STRIKETHROUGH` style
343    /// as a solid line; this variant allows for replicating that behavior.
344    StrikethroughWhitespace {
345        /// The styled whitespace slice
346        text: &'a str,
347        /// The color of the whitespace (and therefore the line over it)
348        color: Color,
349        /// Styles applied to the whitespace (will contain at least
350        /// [`STRIKETHROUGH`](Styles::STRIKETHROUGH))
351        styles: Styles,
352    },
353    /// An unstyled slice of text
354    ///
355    /// This should be given a default style. The vanilla client
356    /// would use [`Color::White`] and [`Styles::empty()`].
357    Plain(&'a str),
358}
359
360impl<'a> core::fmt::Display for Span<'a> {
361    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
362        match self {
363            // TODO: handle random style
364            Span::Styled { text, .. } => f.write_str(text),
365            Span::StrikethroughWhitespace { text, .. } => {
366                (0..text.len()).try_for_each(|_| f.write_str("-"))
367            }
368            Span::Plain(text) => f.write_str(text),
369        }
370    }
371}
372
373impl<'a> Span<'a> {
374    /// Create a new [`Span::Plain`]
375    pub fn new_plain(s: &'a str) -> Self {
376        Span::Plain(s)
377    }
378
379    /// Create a new [`Span::StrikethroughWhitespace`]
380    pub fn new_strikethrough_whitespace(s: &'a str, color: Color, styles: Styles) -> Self {
381        Span::StrikethroughWhitespace {
382            text: s,
383            color,
384            styles,
385        }
386    }
387
388    /// Create a new [`Span::Styled`]
389    pub fn new_styled(s: &'a str, color: Color, styles: Styles) -> Self {
390        Span::Styled {
391            text: s,
392            color,
393            styles,
394        }
395    }
396
397    /// Wraps this [`Span`] in a type that enables colored printing
398    #[cfg(feature = "color-print")]
399    pub fn wrap_colored(self) -> PrintSpanColored<'a> {
400        PrintSpanColored::from(self)
401    }
402}
403
404/// Various colors that a [`Span`] can have.
405///
406/// See [the wiki.vg docs][colors] for specific information.
407///
408/// [colors]: https://wiki.vg/Chat#Colors
409#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)]
410#[allow(missing_docs)]
411pub enum Color {
412    Black,
413    DarkBlue,
414    DarkGreen,
415    DarkAqua,
416    DarkRed,
417    DarkPurple,
418    Gold,
419    Gray,
420    DarkGray,
421    Blue,
422    Green,
423    Aqua,
424    Red,
425    LightPurple,
426    Yellow,
427    White,
428}
429
430impl Default for Color {
431    fn default() -> Self {
432        Color::White
433    }
434}
435
436impl Color {
437    /// Map a `char` to a [`Color`].
438    ///
439    /// Returns [`None`] if `c` didn't map to a [`Color`].
440    pub fn from_char(c: char) -> Option<Self> {
441        Some(match c {
442            '0' => Color::Black,
443            '1' => Color::DarkBlue,
444            '2' => Color::DarkGreen,
445            '3' => Color::DarkAqua,
446            '4' => Color::DarkRed,
447            '5' => Color::DarkPurple,
448            '6' => Color::Gold,
449            '7' => Color::Gray,
450            '8' => Color::DarkGray,
451            '9' => Color::DarkBlue,
452            // The vanilla client accepts lower or uppercase interchangeably
453            'a' | 'A' => Color::Green,
454            'b' | 'B' => Color::Aqua,
455            'c' | 'C' => Color::Red,
456            'd' | 'D' => Color::LightPurple,
457            'e' | 'E' => Color::Yellow,
458            'f' | 'F' => Color::White,
459            _ => return None,
460        })
461    }
462
463    /// Get the correct foreground hex color string for a given color
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// use mc_legacy_formatting::Color;
469    /// assert_eq!(Color::Aqua.foreground_hex_str(), "#55ffff");
470    /// ```
471    pub const fn foreground_hex_str(&self) -> &'static str {
472        match self {
473            Color::Black => "#000000",
474            Color::DarkBlue => "#0000aa",
475            Color::DarkGreen => "#00aa00",
476            Color::DarkAqua => "#00aaaa",
477            Color::DarkRed => "#aa0000",
478            Color::DarkPurple => "#aa00aa",
479            Color::Gold => "#ffaa00",
480            Color::Gray => "#aaaaaa",
481            Color::DarkGray => "#555555",
482            Color::Blue => "#5555ff",
483            Color::Green => "#55ff55",
484            Color::Aqua => "#55ffff",
485            Color::Red => "#ff5555",
486            Color::LightPurple => "#ff55ff",
487            Color::Yellow => "#ffff55",
488            Color::White => "#ffffff",
489        }
490    }
491
492    /// Get the correct background hex color string for a given color
493    ///
494    /// # Examples
495    ///
496    /// ```
497    /// use mc_legacy_formatting::Color;
498    /// assert_eq!(Color::Aqua.background_hex_str(), "#153f3f");
499    /// ```
500    pub const fn background_hex_str(&self) -> &'static str {
501        match self {
502            Color::Black => "#000000",
503            Color::DarkBlue => "#00002a",
504            Color::DarkGreen => "#002a00",
505            Color::DarkAqua => "#002a2a",
506            Color::DarkRed => "#2a0000",
507            Color::DarkPurple => "#2a002a",
508            Color::Gold => "#2a2a00",
509            Color::Gray => "#2a2a2a",
510            Color::DarkGray => "#151515",
511            Color::Blue => "#15153f",
512            Color::Green => "#153f15",
513            Color::Aqua => "#153f3f",
514            Color::Red => "#3f1515",
515            Color::LightPurple => "#3f153f",
516            Color::Yellow => "#3f3f15",
517            Color::White => "#3f3f3f",
518        }
519    }
520
521    /// Get the correct foreground RGB color values for a given color
522    ///
523    /// Returns (red, green, blue)
524    ///
525    /// # Examples
526    ///
527    /// ```
528    /// use mc_legacy_formatting::Color;
529    /// assert_eq!(Color::Aqua.foreground_rgb(), (85, 255, 255));
530    /// ```
531    pub const fn foreground_rgb(&self) -> (u8, u8, u8) {
532        match self {
533            Color::Black => (0, 0, 0),
534            Color::DarkBlue => (0, 0, 170),
535            Color::DarkGreen => (0, 170, 0),
536            Color::DarkAqua => (0, 170, 170),
537            Color::DarkRed => (170, 0, 0),
538            Color::DarkPurple => (170, 0, 170),
539            Color::Gold => (255, 170, 0),
540            Color::Gray => (170, 170, 170),
541            Color::DarkGray => (85, 85, 85),
542            Color::Blue => (85, 85, 255),
543            Color::Green => (85, 255, 85),
544            Color::Aqua => (85, 255, 255),
545            Color::Red => (255, 85, 85),
546            Color::LightPurple => (255, 85, 255),
547            Color::Yellow => (255, 255, 85),
548            Color::White => (255, 255, 255),
549        }
550    }
551
552    /// Get the correct background RGB color values for a given color
553    ///
554    /// Returns (red, green, blue)
555    ///
556    /// # Examples
557    ///
558    /// ```
559    /// use mc_legacy_formatting::Color;
560    /// assert_eq!(Color::Aqua.background_rgb(), (21, 63, 63));
561    /// ```
562    pub const fn background_rgb(&self) -> (u8, u8, u8) {
563        match self {
564            Color::Black => (0, 0, 0),
565            Color::DarkBlue => (0, 0, 42),
566            Color::DarkGreen => (0, 42, 0),
567            Color::DarkAqua => (0, 42, 42),
568            Color::DarkRed => (42, 0, 0),
569            Color::DarkPurple => (42, 0, 42),
570            Color::Gold => (42, 42, 0),
571            Color::Gray => (42, 42, 42),
572            Color::DarkGray => (21, 21, 21),
573            Color::Blue => (21, 21, 63),
574            Color::Green => (21, 63, 21),
575            Color::Aqua => (21, 63, 63),
576            Color::Red => (63, 21, 21),
577            Color::LightPurple => (63, 21, 63),
578            Color::Yellow => (63, 63, 21),
579            Color::White => (63, 63, 63),
580        }
581    }
582}
583
584bitflags! {
585    /// Styles that can be combined and applied to a [`Span`].
586    ///
587    /// The `RESET` flag is missing because the parser implemented in [`SpanIter`]
588    /// takes care of it for you.
589    ///
590    /// See [wiki.vg's docs][styles] for detailed info about each style.
591    ///
592    /// # Examples
593    ///
594    /// ```
595    /// use mc_legacy_formatting::Styles;
596    /// let styles = Styles::BOLD | Styles::ITALIC | Styles::UNDERLINED;
597    ///
598    /// assert!(styles.contains(Styles::BOLD));
599    /// assert!(!styles.contains(Styles::RANDOM));
600    /// ```
601    ///
602    /// [styles]: https://wiki.vg/Chat#Styles
603    #[derive(Default)]
604    pub struct Styles: u32 {
605        /// Signals that the `Span`'s text should be replaced with randomized
606        /// characters at a constant interval
607        const RANDOM        = 0b00000001;
608        /// Signals that the `Span`'s text should be bold
609        const BOLD          = 0b00000010;
610        /// Signals that the `Span`'s text should be strikethrough
611        const STRIKETHROUGH = 0b00000100;
612        /// Signals that the `Span`'s text should be underlined
613        const UNDERLINED    = 0b00001000;
614        /// Signals that the `Span`'s text should be italic
615        const ITALIC        = 0b00010000;
616    }
617}
618
619impl Styles {
620    /// Map a `char` to a [`Styles`] object.
621    ///
622    /// Returns [`None`] if `c` didn't map to a [`Styles`] object.
623    pub fn from_char(c: char) -> Option<Self> {
624        Some(match c {
625            // The vanilla client accepts lower or uppercase interchangeably
626            'k' | 'K' => Styles::RANDOM,
627            'l' | 'L' => Styles::BOLD,
628            'm' | 'M' => Styles::STRIKETHROUGH,
629            'n' | 'N' => Styles::UNDERLINED,
630            'o' | 'O' => Styles::ITALIC,
631            _ => return None,
632        })
633    }
634}