azalea_chat/
component.rs

1#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
2use std::io::{self, Cursor, Write};
3use std::{
4    fmt::{self, Display},
5    sync::LazyLock,
6};
7
8#[cfg(feature = "azalea-buf")]
9use azalea_buf::{AzaleaRead, AzaleaWrite, BufReadError};
10use serde::{Deserialize, Deserializer, Serialize, de};
11#[cfg(feature = "simdnbt")]
12use simdnbt::{Deserialize as _, FromNbtTag as _, Serialize as _};
13#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
14use tracing::{debug, trace, warn};
15
16use crate::{
17    base_component::BaseComponent,
18    style::{ChatFormatting, Style},
19    text_component::TextComponent,
20    translatable_component::{PrimitiveOrComponent, TranslatableComponent},
21};
22
23/// A chat component, basically anything you can see in chat.
24#[derive(Clone, Debug, PartialEq, Serialize)]
25#[serde(untagged)]
26pub enum FormattedText {
27    Text(TextComponent),
28    Translatable(TranslatableComponent),
29}
30
31pub static DEFAULT_STYLE: LazyLock<Style> = LazyLock::new(|| Style {
32    color: Some(ChatFormatting::White.try_into().unwrap()),
33    ..Style::default()
34});
35
36/// A chat component
37impl FormattedText {
38    pub fn get_base_mut(&mut self) -> &mut BaseComponent {
39        match self {
40            Self::Text(c) => &mut c.base,
41            Self::Translatable(c) => &mut c.base,
42        }
43    }
44
45    pub fn get_base(&self) -> &BaseComponent {
46        match self {
47            Self::Text(c) => &c.base,
48            Self::Translatable(c) => &c.base,
49        }
50    }
51
52    /// Add a component as a sibling of this one
53    fn append(&mut self, sibling: FormattedText) {
54        self.get_base_mut().siblings.push(sibling);
55    }
56
57    /// Get the "separator" component from the json
58    fn parse_separator(
59        json: &serde_json::Value,
60    ) -> Result<Option<FormattedText>, serde_json::Error> {
61        if let Some(separator) = json.get("separator") {
62            return Ok(Some(FormattedText::deserialize(separator)?));
63        }
64        Ok(None)
65    }
66
67    #[cfg(feature = "simdnbt")]
68    fn parse_separator_nbt(nbt: &simdnbt::borrow::NbtCompound) -> Option<FormattedText> {
69        if let Some(separator) = nbt.get("separator") {
70            FormattedText::from_nbt_tag(separator)
71        } else {
72            None
73        }
74    }
75
76    /// Render all components into a single `String`, using your custom
77    /// closures to drive styling, text transformation, and final cleanup.
78    ///
79    /// # Type parameters
80    ///
81    /// - `F`: `(running, component, default) -> (prefix, suffix)` for
82    ///   per-component styling
83    /// - `S`: `&str -> String` for text tweaks (escaping, mapping, etc.)
84    /// - `C`: `&final_running_style -> String` for any trailing cleanup
85    ///
86    /// # Arguments
87    ///
88    /// - `style_formatter`: how to open/close each component's style
89    /// - `text_formatter`: how to turn raw text into output text
90    /// - `cleanup_formatter`: emit after all components (e.g. reset codes)
91    /// - `default_style`: where to reset when a component's `reset` is true
92    ///
93    /// # Example
94    ///
95    /// ```rust
96    /// use azalea_chat::{FormattedText, DEFAULT_STYLE};
97    /// use serde::de::Deserialize;
98    ///
99    /// let component = FormattedText::deserialize(&serde_json::json!({
100    ///    "text": "Hello, world!",
101    ///    "color": "red",
102    /// })).unwrap();
103    ///
104    /// let ansi = component.to_custom_format(
105    ///     |running, new| (running.compare_ansi(new), "".to_string()),
106    ///     |text| text.to_string(),
107    ///     |style| {
108    ///         if !style.is_empty() {
109    ///             "\u{1b}[m".to_string()
110    ///         } else {
111    ///             "".to_string()
112    ///         }
113    ///     },
114    ///     &DEFAULT_STYLE,
115    /// );
116    /// println!("{ansi}");
117    /// ```
118    pub fn to_custom_format<F, S, C>(
119        &self,
120        mut style_formatter: F,
121        mut text_formatter: S,
122        mut cleanup_formatter: C,
123        default_style: &Style,
124    ) -> String
125    where
126        F: FnMut(&Style, &Style) -> (String, String),
127        S: FnMut(&str) -> String,
128        C: FnMut(&Style) -> String,
129    {
130        let mut output = String::new();
131
132        let mut running_style = Style::default();
133        self.to_custom_format_recursive(
134            &mut output,
135            &mut style_formatter,
136            &mut text_formatter,
137            &default_style.clone(),
138            &mut running_style,
139        );
140        output.push_str(&cleanup_formatter(&running_style));
141
142        output
143    }
144
145    fn to_custom_format_recursive<F, S>(
146        &self,
147        output: &mut String,
148        style_formatter: &mut F,
149        text_formatter: &mut S,
150        parent_style: &Style,
151        running_style: &mut Style,
152    ) where
153        F: FnMut(&Style, &Style) -> (String, String),
154        S: FnMut(&str) -> String,
155    {
156        let component_text = match &self {
157            Self::Text(c) => c.text.to_string(),
158            Self::Translatable(c) => match c.read() {
159                Ok(c) => c.to_string(),
160                Err(_) => c.key.to_string(),
161            },
162        };
163
164        let component_style = &self.get_base().style;
165        let new_style = parent_style.merged_with(component_style);
166
167        if !component_text.is_empty() {
168            let (formatted_style_prefix, formatted_style_suffix) =
169                style_formatter(running_style, &new_style);
170            let formatted_text = text_formatter(&component_text);
171
172            output.push_str(&formatted_style_prefix);
173            output.push_str(&formatted_text);
174            output.push_str(&formatted_style_suffix);
175
176            *running_style = new_style.clone();
177        }
178
179        for sibling in &self.get_base().siblings {
180            sibling.to_custom_format_recursive(
181                output,
182                style_formatter,
183                text_formatter,
184                &new_style,
185                running_style,
186            );
187        }
188    }
189
190    /// Convert this component into an
191    /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code).
192    ///
193    /// This is the same as [`FormattedText::to_ansi`], but you can specify a
194    /// default [`Style`] to use.
195    pub fn to_ansi_with_custom_style(&self, default_style: &Style) -> String {
196        self.to_custom_format(
197            |running, new| (running.compare_ansi(new), "".to_owned()),
198            |text| text.to_string(),
199            |style| if !style.is_empty() { "\u{1b}[m" } else { "" }.to_string(),
200            default_style,
201        )
202    }
203
204    /// Convert this component into an
205    /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code), so you
206    /// can print it to your terminal and get styling.
207    ///
208    /// This is technically a shortcut for
209    /// [`FormattedText::to_ansi_with_custom_style`] with a default [`Style`]
210    /// colored white.
211    ///
212    /// If you don't want the result to be styled at all, use
213    /// [`Self::to_string`](#method.fmt-1).
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use azalea_chat::FormattedText;
219    /// use serde::de::Deserialize;
220    ///
221    /// let component = FormattedText::deserialize(&serde_json::json!({
222    ///    "text": "Hello, world!",
223    ///    "color": "red",
224    /// })).unwrap();
225    ///
226    /// println!("{}", component.to_ansi());
227    /// ```
228    pub fn to_ansi(&self) -> String {
229        self.to_ansi_with_custom_style(&DEFAULT_STYLE)
230    }
231
232    /// Similar to [`Self::to_ansi`] but renders the result as HTML instead.
233    pub fn to_html(&self) -> String {
234        self.to_custom_format(
235            |running, new| {
236                (
237                    format!(
238                        "<span style=\"{}\">",
239                        running.merged_with(new).get_html_style()
240                    ),
241                    "</span>".to_owned(),
242                )
243            },
244            |text| {
245                text.replace("&", "&amp;")
246                    .replace("<", "&lt;")
247                    // usually unnecessary but good for compatibility
248                    .replace(">", "&gt;")
249                    .replace("\n", "<br>")
250            },
251            |_| "".to_string(),
252            &DEFAULT_STYLE,
253        )
254    }
255}
256
257impl IntoIterator for FormattedText {
258    type Item = FormattedText;
259    type IntoIter = std::vec::IntoIter<Self::Item>;
260
261    /// Recursively call the function for every component in this component
262    fn into_iter(self) -> Self::IntoIter {
263        let base = self.get_base();
264        let siblings = base.siblings.clone();
265        let mut v: Vec<FormattedText> = Vec::with_capacity(siblings.len() + 1);
266        v.push(self);
267        for sibling in siblings {
268            v.extend(sibling);
269        }
270
271        v.into_iter()
272    }
273}
274
275impl<'de> Deserialize<'de> for FormattedText {
276    fn deserialize<D>(de: D) -> Result<Self, D::Error>
277    where
278        D: Deserializer<'de>,
279    {
280        let json: serde_json::Value = serde::Deserialize::deserialize(de)?;
281
282        // we create a component that we might add siblings to
283        let mut component: FormattedText;
284
285        // if it's primitive, make it a text component
286        if !json.is_array() && !json.is_object() {
287            return Ok(FormattedText::Text(TextComponent::new(
288                json.as_str().unwrap_or("").to_string(),
289            )));
290        }
291        // if it's an object, do things with { text } and stuff
292        else if json.is_object() {
293            if let Some(text) = json.get("text") {
294                let text = text.as_str().unwrap_or("").to_string();
295                component = FormattedText::Text(TextComponent::new(text));
296            } else if let Some(translate) = json.get("translate") {
297                let translate = translate
298                    .as_str()
299                    .ok_or_else(|| de::Error::custom("\"translate\" must be a string"))?
300                    .into();
301                let fallback = if let Some(fallback) = json.get("fallback") {
302                    Some(
303                        fallback
304                            .as_str()
305                            .ok_or_else(|| de::Error::custom("\"fallback\" must be a string"))?
306                            .to_string(),
307                    )
308                } else {
309                    None
310                };
311                if let Some(with) = json.get("with") {
312                    let with_array = with
313                        .as_array()
314                        .ok_or_else(|| de::Error::custom("\"with\" must be an array"))?
315                        .iter()
316                        .map(|item| {
317                            PrimitiveOrComponent::deserialize(item).map_err(de::Error::custom)
318                        })
319                        .collect::<Result<Vec<PrimitiveOrComponent>, _>>()?;
320
321                    component = FormattedText::Translatable(TranslatableComponent::with_fallback(
322                        translate, fallback, with_array,
323                    ));
324                } else {
325                    // if it doesn't have a "with", just have the with_array be empty
326                    component = FormattedText::Translatable(TranslatableComponent::with_fallback(
327                        translate,
328                        fallback,
329                        Vec::new(),
330                    ));
331                }
332            } else if let Some(score) = json.get("score") {
333                // object = GsonHelper.getAsJsonObject(jsonObject, "score");
334                if score.get("name").is_none() || score.get("objective").is_none() {
335                    return Err(de::Error::missing_field(
336                        "A score component needs at least a name and an objective",
337                    ));
338                }
339                // TODO
340                return Err(de::Error::custom(
341                    "score text components aren't yet supported",
342                ));
343            } else if json.get("selector").is_some() {
344                return Err(de::Error::custom(
345                    "selector text components aren't yet supported",
346                ));
347            } else if json.get("keybind").is_some() {
348                return Err(de::Error::custom(
349                    "keybind text components aren't yet supported",
350                ));
351            } else if json.get("object").is_some() {
352                return Err(de::Error::custom(
353                    "object text components aren't yet supported",
354                ));
355            } else {
356                let Some(_nbt) = json.get("nbt") else {
357                    return Err(de::Error::custom(
358                        format!("Don't know how to turn {json} into a FormattedText").as_str(),
359                    ));
360                };
361                let _separator =
362                    FormattedText::parse_separator(&json).map_err(de::Error::custom)?;
363
364                let _interpret = match json.get("interpret") {
365                    Some(v) => v.as_bool().ok_or(Some(false)).unwrap(),
366                    None => false,
367                };
368                if let Some(_block) = json.get("block") {}
369                return Err(de::Error::custom(
370                    "nbt text components aren't yet supported",
371                ));
372            }
373            if let Some(extra) = json.get("extra") {
374                let Some(extra) = extra.as_array() else {
375                    return Err(de::Error::custom("Extra isn't an array"));
376                };
377                if extra.is_empty() {
378                    return Err(de::Error::custom("Unexpected empty array of components"));
379                }
380                for extra_component in extra {
381                    let sibling =
382                        FormattedText::deserialize(extra_component).map_err(de::Error::custom)?;
383                    component.append(sibling);
384                }
385            }
386
387            let style = Style::deserialize(&json);
388            *component.get_base_mut().style = style;
389
390            return Ok(component);
391        }
392        // ok so it's not an object, if it's an array deserialize every item
393        else if !json.is_array() {
394            return Err(de::Error::custom(
395                format!("Don't know how to turn {json} into a FormattedText").as_str(),
396            ));
397        }
398        let json_array = json.as_array().unwrap();
399        // the first item in the array is the one that we're gonna return, the others
400        // are siblings
401        let mut component =
402            FormattedText::deserialize(&json_array[0]).map_err(de::Error::custom)?;
403        for i in 1..json_array.len() {
404            component.append(
405                FormattedText::deserialize(json_array.get(i).unwrap())
406                    .map_err(de::Error::custom)?,
407            );
408        }
409        Ok(component)
410    }
411}
412
413#[cfg(feature = "simdnbt")]
414impl simdnbt::Serialize for FormattedText {
415    fn to_compound(self) -> simdnbt::owned::NbtCompound {
416        match self {
417            FormattedText::Text(c) => c.to_compound(),
418            FormattedText::Translatable(c) => c.to_compound(),
419        }
420    }
421}
422
423#[cfg(feature = "simdnbt")]
424impl simdnbt::FromNbtTag for FormattedText {
425    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
426        // if it's a string, return a text component with that string
427        if let Some(string) = tag.string() {
428            Some(FormattedText::from_nbt_string(string))
429        }
430        // if it's a compound, make it do things with { text } and stuff
431        // simdnbt::borrow::NbtTag::Compound(compound) => {
432        else if let Some(compound) = tag.compound() {
433            FormattedText::from_nbt_compound(compound)
434        }
435        // ok so it's not a compound, if it's a list deserialize every item
436        else if let Some(list) = tag.list() {
437            FormattedText::from_nbt_list(list)
438        } else {
439            Some(FormattedText::Text(TextComponent::new("".to_owned())))
440        }
441    }
442}
443
444#[cfg(feature = "simdnbt")]
445impl FormattedText {
446    fn from_nbt_string(s: &simdnbt::Mutf8Str) -> Self {
447        FormattedText::from(s)
448    }
449    fn from_nbt_list(list: simdnbt::borrow::NbtList) -> Option<FormattedText> {
450        let mut component;
451        if let Some(compounds) = list.compounds() {
452            component = FormattedText::from_nbt_compound(compounds.first()?)?;
453            for compound in compounds.into_iter().skip(1) {
454                component.append(FormattedText::from_nbt_compound(compound)?);
455            }
456        } else if let Some(strings) = list.strings() {
457            component = FormattedText::from(*(strings.first()?));
458            for &string in strings.iter().skip(1) {
459                component.append(FormattedText::from(string));
460            }
461        } else {
462            debug!("couldn't parse {list:?} as FormattedText");
463            return None;
464        }
465        Some(component)
466    }
467
468    pub fn from_nbt_compound(compound: simdnbt::borrow::NbtCompound) -> Option<Self> {
469        let mut component: FormattedText;
470
471        if let Some(text) = compound.get("text") {
472            let text = text.string().unwrap_or_default().to_string();
473            component = FormattedText::Text(TextComponent::new(text));
474        } else if let Some(translate) = compound.get("translate") {
475            let translate = translate.string()?.into();
476            if let Some(with) = compound.get("with") {
477                let mut with_array = Vec::new();
478                let with_list = with.list()?;
479                if with_list.empty() {
480                } else if let Some(with) = with_list.strings() {
481                    for item in with {
482                        with_array.push(PrimitiveOrComponent::String(item.to_string()));
483                    }
484                } else if let Some(with) = with_list.ints() {
485                    for item in with {
486                        with_array.push(PrimitiveOrComponent::Integer(item));
487                    }
488                } else if let Some(with) = with_list.compounds() {
489                    for item in with {
490                        // if it's a string component with no styling and no siblings,
491                        // just add a string to
492                        // with_array otherwise add the
493                        // component to the array
494                        if let Some(primitive) = item.get("") {
495                            // minecraft does this sometimes, for example
496                            // for the /give system messages
497                            if let Some(b) = primitive.byte() {
498                                // interpreted as boolean
499                                with_array.push(PrimitiveOrComponent::Boolean(b != 0));
500                            } else if let Some(s) = primitive.short() {
501                                with_array.push(PrimitiveOrComponent::Short(s));
502                            } else if let Some(i) = primitive.int() {
503                                with_array.push(PrimitiveOrComponent::Integer(i));
504                            } else if let Some(l) = primitive.long() {
505                                with_array.push(PrimitiveOrComponent::Long(l));
506                            } else if let Some(f) = primitive.float() {
507                                with_array.push(PrimitiveOrComponent::Float(f));
508                            } else if let Some(d) = primitive.double() {
509                                with_array.push(PrimitiveOrComponent::Double(d));
510                            } else if let Some(s) = primitive.string() {
511                                with_array.push(PrimitiveOrComponent::String(s.to_string()));
512                            } else {
513                                warn!(
514                                    "couldn't parse {item:?} as FormattedText because it has a disallowed primitive"
515                                );
516                                with_array.push(PrimitiveOrComponent::String("?".to_string()));
517                            }
518                        } else if let Some(c) = FormattedText::from_nbt_compound(item) {
519                            if let FormattedText::Text(text_component) = c
520                                && text_component.base.siblings.is_empty()
521                                && text_component.base.style.is_empty()
522                            {
523                                with_array.push(PrimitiveOrComponent::String(text_component.text));
524                                continue;
525                            }
526                            with_array.push(PrimitiveOrComponent::FormattedText(
527                                FormattedText::from_nbt_compound(item)?,
528                            ));
529                        } else {
530                            warn!("couldn't parse {item:?} as FormattedText");
531                            with_array.push(PrimitiveOrComponent::String("?".to_string()));
532                        }
533                    }
534                } else {
535                    warn!(
536                        "couldn't parse {with:?} as FormattedText because it's not a list of compounds"
537                    );
538                    return None;
539                }
540                component =
541                    FormattedText::Translatable(TranslatableComponent::new(translate, with_array));
542            } else {
543                // if it doesn't have a "with", just have the with_array be empty
544                component =
545                    FormattedText::Translatable(TranslatableComponent::new(translate, Vec::new()));
546            }
547        } else if let Some(score) = compound.compound("score") {
548            if score.get("name").is_none() || score.get("objective").is_none() {
549                trace!("A score component needs at least a name and an objective");
550                return None;
551            }
552            // TODO: implement these
553            return None;
554        } else if compound.get("selector").is_some() {
555            trace!("selector text components aren't supported");
556            return None;
557        } else if compound.get("keybind").is_some() {
558            trace!("keybind text components aren't supported");
559            return None;
560        } else if compound.get("object").is_some() {
561            trace!("object text components aren't supported");
562            return None;
563        } else if let Some(tag) = compound.get("") {
564            return FormattedText::from_nbt_tag(tag);
565        } else {
566            let _nbt = compound.get("nbt")?;
567            let _separator = FormattedText::parse_separator_nbt(&compound)?;
568
569            let _interpret = match compound.get("interpret") {
570                Some(v) => v.byte().unwrap_or_default() != 0,
571                None => false,
572            };
573            if let Some(_block) = compound.get("block") {}
574            trace!("nbt text components aren't yet supported");
575            return None;
576        }
577        if let Some(extra) = compound.get("extra") {
578            // if it's an array, deserialize every item
579            if let Some(items) = extra.list() {
580                if let Some(items) = items.compounds() {
581                    for item in items {
582                        component.append(FormattedText::from_nbt_compound(item)?);
583                    }
584                } else if let Some(items) = items.strings() {
585                    for item in items {
586                        component.append(FormattedText::from_nbt_string(item));
587                    }
588                } else if let Some(items) = items.lists() {
589                    for item in items {
590                        component.append(FormattedText::from_nbt_list(item)?);
591                    }
592                } else {
593                    warn!(
594                        "couldn't parse {items:?} as FormattedText because it's not a list of compounds or strings"
595                    );
596                }
597            } else {
598                component.append(FormattedText::from_nbt_tag(extra)?);
599            }
600        }
601
602        let base_style = Style::from_compound(compound).ok()?;
603        let new_style = &mut component.get_base_mut().style;
604        **new_style = new_style.merged_with(&base_style);
605
606        Some(component)
607    }
608}
609
610#[cfg(feature = "simdnbt")]
611impl From<&simdnbt::Mutf8Str> for FormattedText {
612    fn from(s: &simdnbt::Mutf8Str) -> Self {
613        FormattedText::Text(TextComponent::new(s.to_string()))
614    }
615}
616
617#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
618impl AzaleaRead for FormattedText {
619    fn azalea_read(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> {
620        let nbt = simdnbt::borrow::read_optional_tag(buf)?;
621        trace!(
622            "Reading NBT for FormattedText: {:?}",
623            nbt.as_ref().map(|n| n.as_tag().to_owned())
624        );
625        match nbt {
626            Some(nbt) => FormattedText::from_nbt_tag(nbt.as_tag()).ok_or(BufReadError::Custom(
627                "couldn't convert nbt to chat message".to_owned(),
628            )),
629            _ => Ok(FormattedText::default()),
630        }
631    }
632}
633
634#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
635impl AzaleaWrite for FormattedText {
636    fn azalea_write(&self, buf: &mut impl Write) -> io::Result<()> {
637        let mut out = Vec::new();
638        simdnbt::owned::BaseNbt::write_unnamed(&(self.clone().to_compound().into()), &mut out);
639        buf.write_all(&out)
640    }
641}
642
643impl From<String> for FormattedText {
644    fn from(s: String) -> Self {
645        FormattedText::Text(TextComponent {
646            text: s,
647            base: BaseComponent::default(),
648        })
649    }
650}
651impl From<&str> for FormattedText {
652    fn from(s: &str) -> Self {
653        Self::from(s.to_string())
654    }
655}
656impl From<TranslatableComponent> for FormattedText {
657    fn from(c: TranslatableComponent) -> Self {
658        FormattedText::Translatable(c)
659    }
660}
661impl From<TextComponent> for FormattedText {
662    fn from(c: TextComponent) -> Self {
663        FormattedText::Text(c)
664    }
665}
666
667impl Display for FormattedText {
668    /// Render the text in the component but without any formatting/styling.
669    ///
670    /// If you want the text to be styled, consider using [`Self::to_ansi`] or
671    /// [`Self::to_html`].
672    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
673        match self {
674            FormattedText::Text(c) => c.fmt(f),
675            FormattedText::Translatable(c) => c.fmt(f),
676        }
677    }
678}
679
680impl Default for FormattedText {
681    fn default() -> Self {
682        FormattedText::Text(TextComponent::default())
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use serde_json::Value;
689
690    use super::*;
691    use crate::style::TextColor;
692
693    #[test]
694    fn deserialize_translation() {
695        let j: Value =
696            serde_json::from_str(r#"{"translate": "translation.test.args", "with": ["a", "b"]}"#)
697                .unwrap();
698        let component = FormattedText::deserialize(&j).unwrap();
699        assert_eq!(
700            component,
701            FormattedText::Translatable(TranslatableComponent::new(
702                "translation.test.args".to_string(),
703                vec![
704                    PrimitiveOrComponent::String("a".to_string()),
705                    PrimitiveOrComponent::String("b".to_string())
706                ]
707            ))
708        );
709    }
710
711    #[test]
712    fn deserialize_translation_invalid_arguments() {
713        let j: Value =
714            serde_json::from_str(r#"{"translate": "translation.test.args", "with": {}}"#).unwrap();
715        assert!(FormattedText::deserialize(&j).is_err());
716    }
717
718    #[test]
719    fn deserialize_translation_fallback() {
720        let j: Value = serde_json::from_str(r#"{"translate": "translation.test.undefined", "fallback": "fallback: %s", "with": ["a"]}"#).unwrap();
721        let component = FormattedText::deserialize(&j).unwrap();
722        assert_eq!(
723            component,
724            FormattedText::Translatable(TranslatableComponent::with_fallback(
725                "translation.test.undefined".to_string(),
726                Some("fallback: %s".to_string()),
727                vec![PrimitiveOrComponent::String("a".to_string())]
728            ))
729        );
730    }
731
732    #[test]
733    fn deserialize_translation_invalid_fallback() {
734        let j: Value = serde_json::from_str(
735            r#"{"translate": "translation.test.undefined", "fallback": {"text": "invalid"}}"#,
736        )
737        .unwrap();
738        assert!(FormattedText::deserialize(&j).is_err());
739    }
740    #[test]
741    fn deserialize_translation_primitive_args() {
742        let j: Value = serde_json::from_str(
743            r#"{"translate":"commands.list.players", "with": [1, 65536, "<players>", {"text": "unused", "color": "red"}]}"#,
744        )
745        .unwrap();
746        assert_eq!(
747            FormattedText::deserialize(&j).unwrap(),
748            FormattedText::Translatable(TranslatableComponent::new(
749                "commands.list.players".to_string(),
750                vec![
751                    PrimitiveOrComponent::Short(1),
752                    PrimitiveOrComponent::Integer(65536),
753                    PrimitiveOrComponent::String("<players>".to_string()),
754                    PrimitiveOrComponent::FormattedText(FormattedText::Text(
755                        TextComponent::new("unused")
756                            .with_style(Style::new().color(Some(TextColor::parse("red").unwrap())))
757                    ))
758                ]
759            ))
760        );
761    }
762}