kyori_component_json/
minimessage.rs

1//! MiniMessage format parser and serializer for Minecraft components.
2//!
3//! Implements the [`ComponentParser`] and [`ComponentSerializer`] traits
4//! for the MiniMessage text format.
5
6use crate::parsing::{ComponentParser, ComponentSerializer};
7use crate::{
8    ClickEvent, Color, Component, ComponentObject, HoverEvent, NamedColor, Style, TextDecoration,
9};
10use std::collections::HashMap;
11use std::error::Error;
12use std::fmt;
13
14/// Represents errors that can occur during MiniMessage parsing/serialization.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct MiniMessageError(String);
17
18impl fmt::Display for MiniMessageError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "MiniMessage error: {}", self.0)
21    }
22}
23
24impl Error for MiniMessageError {}
25
26/// Configuration for MiniMessage parsing/serialization.
27#[derive(Debug, Clone, Default, PartialEq, Eq, Copy, Hash)]
28pub struct MiniMessageConfig {
29    /// Whether to use strict parsing (requires proper tag closing)
30    pub strict: bool,
31    /// Whether to parse legacy color codes (e.g., &6 for gold)
32    pub parse_legacy_colors: bool,
33}
34
35/// MiniMessage parser and serializer implementation.
36#[derive(Debug, Clone, Copy)]
37pub struct MiniMessage {
38    config: MiniMessageConfig,
39}
40
41impl MiniMessage {
42    /// Creates a new MiniMessage instance with default configuration.
43    pub fn new() -> Self {
44        Self::with_config(Default::default())
45    }
46
47    /// Creates a new MiniMessage instance with custom configuration.
48    pub fn with_config(config: MiniMessageConfig) -> Self {
49        MiniMessage { config }
50    }
51
52    /// Parse input using instance configuration
53    pub fn parse(&self, input: impl AsRef<str>) -> Result<Component, MiniMessageError> {
54        let mut parser = Parser::new(input.as_ref(), &self.config);
55        parser.parse()
56    }
57}
58
59impl Default for MiniMessage {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl ComponentParser for MiniMessage {
66    type Err = MiniMessageError;
67
68    /// Parse input from MiniMessage string to Component using default configuration.
69    fn from_string(input: impl AsRef<str>) -> Result<Component, Self::Err> {
70        // FIXME: use default config since we can't access instance data in trait method as that
71        // isn't idiomatic Rust
72        let config = MiniMessageConfig::default();
73        let mut parser = Parser::new(input.as_ref(), &config);
74        parser.parse()
75    }
76}
77
78impl ComponentSerializer for MiniMessage {
79    type Err = MiniMessageError;
80
81    fn to_string(component: &Component) -> Result<String, Self::Err> {
82        Serializer::new().serialize(component)
83    }
84}
85
86/// Internal parser state
87struct Parser<'a> {
88    input: &'a str,
89    position: usize,
90    config: &'a MiniMessageConfig,
91    style_stack: Vec<Style>,
92    component_parts: Vec<Component>,
93}
94
95impl<'a> Parser<'a> {
96    fn new(input: &'a str, config: &'a MiniMessageConfig) -> Self {
97        Self {
98            input,
99            position: 0,
100            config,
101            style_stack: vec![Style::default()],
102            component_parts: Vec::new(),
103        }
104    }
105
106    fn parse(&mut self) -> Result<Component, MiniMessageError> {
107        while self.position < self.input.len() {
108            if self.starts_with('<') {
109                self.parse_tag()?;
110            } else {
111                self.parse_text()?;
112            }
113        }
114
115        let parts = std::mem::take(&mut self.component_parts);
116        if parts.len() == 1 {
117            // SAFETY: This is safe because we always have at least one style
118            Ok(parts.into_iter().next().unwrap())
119        } else {
120            Ok(Component::Array(parts))
121        }
122    }
123
124    fn parse_text(&mut self) -> Result<(), MiniMessageError> {
125        let start = self.position;
126        while self.position < self.input.len() {
127            if self.starts_with('<') || (self.config.parse_legacy_colors && self.starts_with('&')) {
128                break;
129            }
130            self.position += 1;
131        }
132
133        if start < self.position {
134            let text = &self.input[start..self.position];
135            let current_style = self.current_style();
136            let mut comp = Component::text(text);
137            comp = comp.color(current_style.color.clone());
138            comp = comp.decorations(&self.collect_decorations());
139            self.component_parts.push(comp);
140        }
141        Ok(())
142    }
143
144    fn parse_tag(&mut self) -> Result<(), MiniMessageError> {
145        // Skip '<'
146        self.position += 1;
147
148        if self.starts_with('/') {
149            // Closing tag
150            self.position += 1;
151            let tag_name = self.read_tag_name()?;
152            self.handle_close_tag(&tag_name)?;
153            self.expect('>')?;
154        } else {
155            // Opening tag
156            let tag_name = self.read_tag_name()?;
157            let mut args = Vec::new();
158            let mut self_closing = false;
159
160            while self.position < self.input.len() {
161                // skip whitespace
162                self.skip_whitespace();
163                // skip colon separators
164                while self.starts_with(':') {
165                    self.position += 1;
166                    self.skip_whitespace();
167                }
168
169                // if we’ve hit the end of the tag, stop
170                if self.starts_with('>') || self.starts_with('/') {
171                    break;
172                }
173
174                // read an argument
175                let arg = self.read_argument()?;
176                args.push(arg);
177            }
178
179            // Check for self-closing tag
180            if self.starts_with('/') {
181                self.position += 1;
182                self_closing = true;
183            }
184            self.expect('>')?;
185
186            self.handle_open_tag(&tag_name, args, self_closing)?;
187        }
188
189        Ok(())
190    }
191
192    fn read_tag_name(&mut self) -> Result<String, MiniMessageError> {
193        let start = self.position;
194        while self.position < self.input.len() {
195            let c = self.current_char();
196            if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
197                break;
198            }
199            self.position += 1;
200        }
201        if start == self.position {
202            return Err(MiniMessageError("Expected tag name".to_string()));
203        }
204        Ok(self.input[start..self.position].to_lowercase())
205    }
206
207    fn read_argument(&mut self) -> Result<String, MiniMessageError> {
208        if self.starts_with('\'') || self.starts_with('"') {
209            self.read_quoted_string()
210        } else {
211            self.read_unquoted_string()
212        }
213    }
214
215    fn read_quoted_string(&mut self) -> Result<String, MiniMessageError> {
216        let quote_char = self.current_char();
217        self.position += 1;
218
219        let mut escaped = false;
220        let mut result = String::new();
221
222        while self.position < self.input.len() {
223            let c = self.current_char();
224            if escaped {
225                result.push(c);
226                escaped = false;
227            } else if c == '\\' {
228                escaped = true;
229            } else if c == quote_char {
230                self.position += 1;
231                return Ok(result);
232            } else {
233                result.push(c);
234            }
235            self.position += 1;
236        }
237
238        Err(MiniMessageError("Unterminated quoted string".to_string()))
239    }
240
241    fn read_unquoted_string(&mut self) -> Result<String, MiniMessageError> {
242        let start = self.position;
243        while self.position < self.input.len() {
244            let c = self.current_char();
245            if c == ':' || c == '>' || c == '/' || c.is_whitespace() {
246                break;
247            }
248            self.position += 1;
249        }
250        // what?
251        // if start == self.position {
252        //     return Err(MiniMessageError("Expected argument".to_string()));
253        // }
254        Ok(self.input[start..self.position].to_string())
255    }
256
257    fn handle_open_tag(
258        &mut self,
259        tag: &str,
260        args: Vec<String>,
261        self_closing: bool,
262    ) -> Result<(), MiniMessageError> {
263        match tag {
264            // Colors
265            "black" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Black)))?,
266            "dark_blue" => {
267                self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkBlue)))?
268            }
269            "dark_green" => {
270                self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkGreen)))?
271            }
272            "dark_aqua" => {
273                self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkAqua)))?
274            }
275            "dark_red" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkRed)))?,
276            "dark_purple" => {
277                self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkPurple)))?
278            }
279            "gold" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Gold)))?,
280            "gray" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Gray)))?,
281            "dark_gray" => {
282                self.push_style(|s| s.color = Some(Color::Named(NamedColor::DarkGray)))?
283            }
284            "blue" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Blue)))?,
285            "green" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Green)))?,
286            "aqua" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Aqua)))?,
287            "red" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Red)))?,
288            "light_purple" => {
289                self.push_style(|s| s.color = Some(Color::Named(NamedColor::LightPurple)))?
290            }
291            "yellow" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::Yellow)))?,
292            "white" => self.push_style(|s| s.color = Some(Color::Named(NamedColor::White)))?,
293            "color" | "colour" | "c" if !args.is_empty() => {
294                if let Some(color) = args[0].parse::<Color>().ok() {
295                    self.push_style(|s| s.color = Some(color))?
296                }
297            }
298
299            // Decorations
300            "bold" | "b" => self.push_style(|s| s.bold = Some(true))?,
301            "italic" | "i" | "em" => self.push_style(|s| s.italic = Some(true))?,
302            "underlined" | "u" => self.push_style(|s| s.underlined = Some(true))?,
303            "strikethrough" | "st" => self.push_style(|s| s.strikethrough = Some(true))?,
304            "obfuscated" | "obf" => self.push_style(|s| s.obfuscated = Some(true))?,
305
306            // Reset tag
307            "reset" => self.reset_style()?,
308
309            // Click events
310            "click" if args.len() >= 2 => {
311                let action = args[0].as_str();
312                let value = args[1].as_str();
313                match action {
314                    "run_command" => self.push_style(|s| {
315                        s.click_event = Some(ClickEvent::RunCommand {
316                            command: value.to_string(),
317                        })
318                    })?,
319                    "suggest_command" => self.push_style(|s| {
320                        s.click_event = Some(ClickEvent::SuggestCommand {
321                            command: value.to_string(),
322                        })
323                    })?,
324                    "open_url" => self.push_style(|s| {
325                        s.click_event = Some(ClickEvent::OpenUrl {
326                            url: value.to_string(),
327                        })
328                    })?,
329                    "copy_to_clipboard" => self.push_style(|s| {
330                        s.click_event = Some(ClickEvent::CopyToClipboard {
331                            value: value.to_string(),
332                        })
333                    })?,
334                    _ => {}
335                }
336            }
337
338            // Hover events
339            "hover" if !args.is_empty() => {
340                let action = args[0].as_str();
341                if action == "show_text" && args.len() >= 2 {
342                    let mut nested_parser = Parser::new(&args[1], self.config);
343                    let nested = nested_parser.parse()?;
344                    self.push_style(|s| {
345                        s.hover_event = Some(HoverEvent::ShowText { value: nested })
346                    })?;
347                }
348            }
349
350            // Newline
351            "newline" | "br" => {
352                self.component_parts.push(Component::text("\n"));
353            }
354
355            // Insertion
356            "insert" | "insertion" if !args.is_empty() => {
357                self.push_style(|s| s.insertion = Some(args[0].clone()))?
358            }
359
360            // Handle self-closing tags
361            _ if self_closing => {
362                // For self-closing tags, create an empty component with the style
363                let current_style = self.current_style();
364                let mut comp = Component::text("");
365                comp = comp.color(current_style.color.clone());
366                comp = comp.decorations(&self.collect_decorations());
367                self.component_parts.push(comp);
368            }
369
370            // Unknown tags are treated as text
371            _ => {
372                let mut tag_text = format!("<{tag}");
373                for arg in args {
374                    tag_text.push(':');
375                    tag_text.push_str(&arg);
376                }
377                if self_closing {
378                    tag_text.push('/');
379                }
380                tag_text.push('>');
381                self.component_parts
382                    .push(Component::text(tag_text).apply_fallback_style(self.current_style()));
383            }
384        }
385
386        Ok(())
387    }
388
389    fn handle_close_tag(&mut self, tag: &str) -> Result<(), MiniMessageError> {
390        match tag {
391            "bold" | "b" | "italic" | "i" | "em" | "underlined" | "u" | "strikethrough" | "st"
392            | "obfuscated" | "obf" | "color" | "colour" | "c" | "click" | "hover" | "insert"
393            | "insertion" => {
394                self.pop_style()?;
395            }
396            _ => {
397                // For unknown tags, just pop the style anyway
398                if self.style_stack.len() > 1 {
399                    self.style_stack.pop();
400                }
401            }
402        }
403        Ok(())
404    }
405
406    fn push_style<F>(&mut self, modifier: F) -> Result<(), MiniMessageError>
407    where
408        F: FnOnce(&mut Style),
409    {
410        let mut new_style = self.current_style().clone();
411        modifier(&mut new_style);
412        self.style_stack.push(new_style);
413        Ok(())
414    }
415
416    fn pop_style(&mut self) -> Result<(), MiniMessageError> {
417        if self.style_stack.len() > 1 {
418            self.style_stack.pop();
419            Ok(())
420        } else {
421            Err(MiniMessageError("Unbalanced closing tag".to_string()))
422        }
423    }
424
425    fn reset_style(&mut self) -> Result<(), MiniMessageError> {
426        while self.style_stack.len() > 1 {
427            self.style_stack.pop();
428        }
429        Ok(())
430    }
431
432    fn current_style(&self) -> &Style {
433        // SAFETY: This is safe because we always have at least one style
434        self.style_stack.last().unwrap()
435    }
436
437    fn collect_decorations(&self) -> HashMap<TextDecoration, Option<bool>> {
438        let style = self.current_style();
439        let mut decorations = HashMap::new();
440        if let Some(bold) = style.bold {
441            decorations.insert(TextDecoration::Bold, Some(bold));
442        }
443        if let Some(italic) = style.italic {
444            decorations.insert(TextDecoration::Italic, Some(italic));
445        }
446        if let Some(underlined) = style.underlined {
447            decorations.insert(TextDecoration::Underlined, Some(underlined));
448        }
449        if let Some(strikethrough) = style.strikethrough {
450            decorations.insert(TextDecoration::Strikethrough, Some(strikethrough));
451        }
452        if let Some(obfuscated) = style.obfuscated {
453            decorations.insert(TextDecoration::Obfuscated, Some(obfuscated));
454        }
455        decorations
456    }
457
458    fn starts_with(&self, c: char) -> bool {
459        self.input[self.position..].starts_with(c)
460    }
461
462    fn current_char(&self) -> char {
463        self.input[self.position..].chars().next().unwrap_or('\0')
464    }
465
466    fn skip_whitespace(&mut self) {
467        while self.position < self.input.len() {
468            if !self.input[self.position..].starts_with(char::is_whitespace) {
469                break;
470            }
471            self.position += 1;
472        }
473    }
474
475    fn expect(&mut self, c: char) -> Result<(), MiniMessageError> {
476        if self.position < self.input.len() && self.current_char() == c {
477            self.position += 1;
478            Ok(())
479        } else {
480            Err(MiniMessageError(format!("Expected '{c}'")))
481        }
482    }
483}
484
485/// Serializes components to MiniMessage format
486struct Serializer {
487    output: String,
488    current_style: Style,
489}
490
491impl Serializer {
492    fn new() -> Self {
493        Self {
494            output: String::new(),
495            current_style: Style::default(),
496        }
497    }
498
499    fn serialize(&mut self, component: &Component) -> Result<String, MiniMessageError> {
500        self.serialize_component(component)?;
501        Ok(self.output.clone())
502    }
503
504    fn serialize_component(&mut self, component: &Component) -> Result<(), MiniMessageError> {
505        match component {
506            Component::String(s) => self.serialize_text(s),
507            Component::Array(components) => {
508                let base_style = self.current_style.clone();
509                for comp in components {
510                    // Reset to base style before each component
511                    self.current_style = base_style.clone();
512                    self.serialize_component(comp)?;
513                }
514                Ok(())
515            }
516            Component::Object(obj) => self.serialize_object(obj),
517        }
518    }
519
520    fn serialize_object(&mut self, obj: &ComponentObject) -> Result<(), MiniMessageError> {
521        // Save current style to compare changes
522        let prev_style = self.current_style.clone();
523
524        // Apply style changes
525        let mut style_changes = Vec::new();
526
527        if let Some(color) = &obj.color
528            && Some(color) != prev_style.color.as_ref()
529        {
530            if let Some(named) = color.to_named() {
531                style_changes.push(named.to_string());
532            } else if let Color::Hex(hex) = color {
533                style_changes.push(format!("color:{hex}"));
534            }
535        }
536
537        if obj.bold != prev_style.bold && obj.bold == Some(true) {
538            style_changes.push("bold".to_string());
539        }
540
541        if obj.italic != prev_style.italic && obj.italic == Some(true) {
542            style_changes.push("italic".to_string());
543        }
544
545        if obj.underlined != prev_style.underlined && obj.underlined == Some(true) {
546            style_changes.push("underlined".to_string());
547        }
548
549        if obj.strikethrough != prev_style.strikethrough && obj.strikethrough == Some(true) {
550            style_changes.push("strikethrough".to_string());
551        }
552
553        if obj.obfuscated != prev_style.obfuscated && obj.obfuscated == Some(true) {
554            style_changes.push("obfuscated".to_string());
555        }
556
557        // Apply style changes
558        for change in &style_changes {
559            self.output.push_str(&format!("<{change}>"));
560        }
561
562        // Update current style
563        self.current_style = Style {
564            color: obj.color.clone(),
565            bold: obj.bold,
566            italic: obj.italic,
567            underlined: obj.underlined,
568            strikethrough: obj.strikethrough,
569            obfuscated: obj.obfuscated,
570            ..self.current_style.clone()
571        };
572
573        // Serialize text content
574        if let Some(text) = &obj.text {
575            self.serialize_text(text)?;
576        }
577
578        // Serialize children
579        if let Some(extra) = &obj.extra {
580            for comp in extra {
581                self.serialize_component(comp)?;
582            }
583        }
584
585        // Close style changes
586        for change in style_changes.iter().rev() {
587            self.output.push_str(&format!("</{change}>"));
588        }
589
590        // Restore previous style
591        self.current_style = prev_style;
592
593        Ok(())
594    }
595
596    fn serialize_text(&mut self, text: &str) -> Result<(), MiniMessageError> {
597        // Escape special characters
598        for c in text.chars() {
599            match c {
600                '<' => self.output.push_str("&lt;"),
601                '>' => self.output.push_str("&gt;"),
602                '&' => self.output.push_str("&amp;"),
603                _ => self.output.push(c),
604            }
605        }
606        Ok(())
607    }
608}
609
610
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::{Component, NamedColor};
616
617    #[test]
618    fn test_parse_simple() {
619        let mm = MiniMessage::new();
620        let comp = mm.parse("Hello <red>world</red>!").unwrap();
621
622        if let Component::Array(parts) = comp {
623            assert_eq!(parts.len(), 3);
624            assert_eq!(parts[0].get_plain_text().unwrap(), "Hello ");
625            assert_eq!(parts[1].get_plain_text().unwrap(), "world");
626            assert_eq!(parts[2].get_plain_text().unwrap(), "!");
627        } else {
628            panic!("Expected array component");
629        }
630    }
631
632    #[test]
633    fn test_parse_nested() {
634        let mm = MiniMessage::new();
635        let comp = mm
636            .parse("Click <hover:show_text:'<red>Action!'>here</hover>")
637            .expect("Failed to parse component");
638
639        // Verify hover event exists and contains a red text component
640        if let Component::Object(obj) = &comp
641            && let Some(children) = &obj.extra
642        {
643            if let Component::Object(hover_obj) = &children[1]
644                && let Some(hover_event) = &hover_obj.hover_event
645            {
646                match hover_event {
647                    HoverEvent::ShowText { value } => {
648                        assert_eq!(value.get_plain_text().unwrap(), "Action!");
649                    }
650                    _ => panic!("Expected show_text hover event"),
651                }
652            }
653        }
654    }
655
656    #[test]
657    fn test_serialize_simple() {
658        let comp = Component::text("Hello ")
659            .color(Some(Color::Named(NamedColor::Yellow)))
660            .append(Component::text("world").color(Some(Color::Named(NamedColor::Red))));
661
662        let result = MiniMessage::to_string(&comp).unwrap();
663        // TODO: is <yellow>Hello </yellow><red>world</red> technically correct?
664        assert_eq!(result, "<yellow>Hello <red>world</red></yellow>");
665    }
666}