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