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::{StringOrComponent, 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 = with
310                        .as_array()
311                        .ok_or_else(|| de::Error::custom("\"with\" must be an array"))?;
312                    let mut with_array = Vec::with_capacity(with.len());
313                    for item in with {
314                        // if it's a string component with no styling and no siblings, just add a
315                        // string to with_array otherwise add the component
316                        // to the array
317                        let c = FormattedText::deserialize(item).map_err(de::Error::custom)?;
318                        if let FormattedText::Text(text_component) = c
319                            && text_component.base.siblings.is_empty()
320                            && text_component.base.style.is_empty()
321                        {
322                            with_array.push(StringOrComponent::String(text_component.text));
323                            continue;
324                        }
325                        with_array.push(StringOrComponent::FormattedText(
326                            FormattedText::deserialize(item).map_err(de::Error::custom)?,
327                        ));
328                    }
329                    component = FormattedText::Translatable(TranslatableComponent::with_fallback(
330                        translate, fallback, with_array,
331                    ));
332                } else {
333                    // if it doesn't have a "with", just have the with_array be empty
334                    component = FormattedText::Translatable(TranslatableComponent::with_fallback(
335                        translate,
336                        fallback,
337                        Vec::new(),
338                    ));
339                }
340            } else if let Some(score) = json.get("score") {
341                // object = GsonHelper.getAsJsonObject(jsonObject, "score");
342                if score.get("name").is_none() || score.get("objective").is_none() {
343                    return Err(de::Error::missing_field(
344                        "A score component needs at least a name and an objective",
345                    ));
346                }
347                // TODO
348                return Err(de::Error::custom(
349                    "score text components aren't yet supported",
350                ));
351            } else if json.get("selector").is_some() {
352                return Err(de::Error::custom(
353                    "selector text components aren't yet supported",
354                ));
355            } else if json.get("keybind").is_some() {
356                return Err(de::Error::custom(
357                    "keybind text components aren't yet supported",
358                ));
359            } else {
360                let Some(_nbt) = json.get("nbt") else {
361                    return Err(de::Error::custom(
362                        format!("Don't know how to turn {json} into a FormattedText").as_str(),
363                    ));
364                };
365                let _separator =
366                    FormattedText::parse_separator(&json).map_err(de::Error::custom)?;
367
368                let _interpret = match json.get("interpret") {
369                    Some(v) => v.as_bool().ok_or(Some(false)).unwrap(),
370                    None => false,
371                };
372                if let Some(_block) = json.get("block") {}
373                return Err(de::Error::custom(
374                    "nbt text components aren't yet supported",
375                ));
376            }
377            if let Some(extra) = json.get("extra") {
378                let Some(extra) = extra.as_array() else {
379                    return Err(de::Error::custom("Extra isn't an array"));
380                };
381                if extra.is_empty() {
382                    return Err(de::Error::custom("Unexpected empty array of components"));
383                }
384                for extra_component in extra {
385                    let sibling =
386                        FormattedText::deserialize(extra_component).map_err(de::Error::custom)?;
387                    component.append(sibling);
388                }
389            }
390
391            let style = Style::deserialize(&json);
392            component.get_base_mut().style = Box::new(style);
393
394            return Ok(component);
395        }
396        // ok so it's not an object, if it's an array deserialize every item
397        else if !json.is_array() {
398            return Err(de::Error::custom(
399                format!("Don't know how to turn {json} into a FormattedText").as_str(),
400            ));
401        }
402        let json_array = json.as_array().unwrap();
403        // the first item in the array is the one that we're gonna return, the others
404        // are siblings
405        let mut component =
406            FormattedText::deserialize(&json_array[0]).map_err(de::Error::custom)?;
407        for i in 1..json_array.len() {
408            component.append(
409                FormattedText::deserialize(json_array.get(i).unwrap())
410                    .map_err(de::Error::custom)?,
411            );
412        }
413        Ok(component)
414    }
415}
416
417#[cfg(feature = "simdnbt")]
418impl simdnbt::Serialize for FormattedText {
419    fn to_compound(self) -> simdnbt::owned::NbtCompound {
420        match self {
421            FormattedText::Text(c) => c.to_compound(),
422            FormattedText::Translatable(c) => c.to_compound(),
423        }
424    }
425}
426
427#[cfg(feature = "simdnbt")]
428impl simdnbt::FromNbtTag for FormattedText {
429    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
430        // if it's a string, return a text component with that string
431        if let Some(string) = tag.string() {
432            Some(FormattedText::from_nbt_string(string))
433        }
434        // if it's a compound, make it do things with { text } and stuff
435        // simdnbt::borrow::NbtTag::Compound(compound) => {
436        else if let Some(compound) = tag.compound() {
437            FormattedText::from_nbt_compound(compound)
438        }
439        // ok so it's not a compound, if it's a list deserialize every item
440        else if let Some(list) = tag.list() {
441            FormattedText::from_nbt_list(list)
442        } else {
443            Some(FormattedText::Text(TextComponent::new("".to_owned())))
444        }
445    }
446}
447
448#[cfg(feature = "simdnbt")]
449impl FormattedText {
450    fn from_nbt_string(s: &simdnbt::Mutf8Str) -> Self {
451        FormattedText::from(s)
452    }
453    fn from_nbt_list(list: simdnbt::borrow::NbtList) -> Option<FormattedText> {
454        let mut component;
455        if let Some(compounds) = list.compounds() {
456            component = FormattedText::from_nbt_compound(compounds.first()?)?;
457            for compound in compounds.into_iter().skip(1) {
458                component.append(FormattedText::from_nbt_compound(compound)?);
459            }
460        } else if let Some(strings) = list.strings() {
461            component = FormattedText::from(*(strings.first()?));
462            for &string in strings.iter().skip(1) {
463                component.append(FormattedText::from(string));
464            }
465        } else {
466            debug!("couldn't parse {list:?} as FormattedText");
467            return None;
468        }
469        Some(component)
470    }
471
472    pub fn from_nbt_compound(compound: simdnbt::borrow::NbtCompound) -> Option<Self> {
473        let mut component: FormattedText;
474
475        if let Some(text) = compound.get("text") {
476            let text = text.string().unwrap_or_default().to_string();
477            component = FormattedText::Text(TextComponent::new(text));
478        } else if let Some(translate) = compound.get("translate") {
479            let translate = translate.string()?.into();
480            if let Some(with) = compound.get("with") {
481                let mut with_array = Vec::new();
482                let with_list = with.list()?;
483                if with_list.empty() {
484                } else if let Some(with) = with_list.strings() {
485                    for item in with {
486                        with_array.push(StringOrComponent::String(item.to_string()));
487                    }
488                } else if let Some(with) = with_list.ints() {
489                    for item in with {
490                        with_array.push(StringOrComponent::String(item.to_string()));
491                    }
492                } else if let Some(with) = with_list.compounds() {
493                    for item in with {
494                        // if it's a string component with no styling and no siblings,
495                        // just add a string to
496                        // with_array otherwise add the
497                        // component to the array
498                        if let Some(primitive) = item.get("") {
499                            // minecraft does this sometimes, for example
500                            // for the /give system messages
501                            if let Some(b) = primitive.byte() {
502                                // interpreted as boolean
503                                with_array.push(StringOrComponent::String(
504                                    if b != 0 { "true" } else { "false" }.to_string(),
505                                ));
506                            } else if let Some(s) = primitive.short() {
507                                with_array.push(StringOrComponent::String(s.to_string()));
508                            } else if let Some(i) = primitive.int() {
509                                with_array.push(StringOrComponent::String(i.to_string()));
510                            } else if let Some(l) = primitive.long() {
511                                with_array.push(StringOrComponent::String(l.to_string()));
512                            } else if let Some(f) = primitive.float() {
513                                with_array.push(StringOrComponent::String(f.to_string()));
514                            } else if let Some(d) = primitive.double() {
515                                with_array.push(StringOrComponent::String(d.to_string()));
516                            } else if let Some(s) = primitive.string() {
517                                with_array.push(StringOrComponent::String(s.to_string()));
518                            } else {
519                                warn!(
520                                    "couldn't parse {item:?} as FormattedText because it has a disallowed primitive"
521                                );
522                                with_array.push(StringOrComponent::String("?".to_string()));
523                            }
524                        } else if let Some(c) = FormattedText::from_nbt_compound(item) {
525                            if let FormattedText::Text(text_component) = c
526                                && text_component.base.siblings.is_empty()
527                                && text_component.base.style.is_empty()
528                            {
529                                with_array.push(StringOrComponent::String(text_component.text));
530                                continue;
531                            }
532                            with_array.push(StringOrComponent::FormattedText(
533                                FormattedText::from_nbt_compound(item)?,
534                            ));
535                        } else {
536                            warn!("couldn't parse {item:?} as FormattedText");
537                            with_array.push(StringOrComponent::String("?".to_string()));
538                        }
539                    }
540                } else {
541                    warn!(
542                        "couldn't parse {with:?} as FormattedText because it's not a list of compounds"
543                    );
544                    return None;
545                }
546                component =
547                    FormattedText::Translatable(TranslatableComponent::new(translate, with_array));
548            } else {
549                // if it doesn't have a "with", just have the with_array be empty
550                component =
551                    FormattedText::Translatable(TranslatableComponent::new(translate, Vec::new()));
552            }
553        } else if let Some(score) = compound.compound("score") {
554            // object = GsonHelper.getAsJsonObject(jsonObject, "score");
555            if score.get("name").is_none() || score.get("objective").is_none() {
556                // A score component needs at least a name and an objective
557                trace!("A score component needs at least a name and an objective");
558                return None;
559            }
560            // TODO, score text components aren't yet supported
561            return None;
562        } else if compound.get("selector").is_some() {
563            // selector text components aren't yet supported
564            trace!("selector text components aren't yet supported");
565            return None;
566        } else if compound.get("keybind").is_some() {
567            // keybind text components aren't yet supported
568            trace!("keybind text components aren't yet supported");
569            return None;
570        } else if let Some(tag) = compound.get("") {
571            return FormattedText::from_nbt_tag(tag);
572        } else {
573            let _nbt = compound.get("nbt")?;
574            let _separator = FormattedText::parse_separator_nbt(&compound)?;
575
576            let _interpret = match compound.get("interpret") {
577                Some(v) => v.byte().unwrap_or_default() != 0,
578                None => false,
579            };
580            if let Some(_block) = compound.get("block") {}
581            trace!("nbt text components aren't yet supported");
582            return None;
583        }
584        if let Some(extra) = compound.get("extra") {
585            // if it's an array, deserialize every item
586            if let Some(items) = extra.list() {
587                if let Some(items) = items.compounds() {
588                    for item in items {
589                        component.append(FormattedText::from_nbt_compound(item)?);
590                    }
591                } else if let Some(items) = items.strings() {
592                    for item in items {
593                        component.append(FormattedText::from_nbt_string(item));
594                    }
595                } else if let Some(items) = items.lists() {
596                    for item in items {
597                        component.append(FormattedText::from_nbt_list(item)?);
598                    }
599                } else {
600                    warn!(
601                        "couldn't parse {items:?} as FormattedText because it's not a list of compounds or strings"
602                    );
603                }
604            } else {
605                component.append(FormattedText::from_nbt_tag(extra)?);
606            }
607        }
608
609        let base_style = Style::from_compound(compound).ok()?;
610        let new_style = &mut component.get_base_mut().style;
611        *new_style = Box::new(new_style.merged_with(&base_style));
612
613        Some(component)
614    }
615}
616
617#[cfg(feature = "simdnbt")]
618impl From<&simdnbt::Mutf8Str> for FormattedText {
619    fn from(s: &simdnbt::Mutf8Str) -> Self {
620        FormattedText::Text(TextComponent::new(s.to_string()))
621    }
622}
623
624#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
625impl AzaleaRead for FormattedText {
626    fn azalea_read(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> {
627        let nbt = simdnbt::borrow::read_optional_tag(buf)?;
628        trace!(
629            "Reading NBT for FormattedText: {:?}",
630            nbt.as_ref().map(|n| n.as_tag().to_owned())
631        );
632        match nbt {
633            Some(nbt) => FormattedText::from_nbt_tag(nbt.as_tag()).ok_or(BufReadError::Custom(
634                "couldn't convert nbt to chat message".to_owned(),
635            )),
636            _ => Ok(FormattedText::default()),
637        }
638    }
639}
640
641#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
642impl AzaleaWrite for FormattedText {
643    fn azalea_write(&self, buf: &mut impl Write) -> io::Result<()> {
644        let mut out = Vec::new();
645        simdnbt::owned::BaseNbt::write_unnamed(&(self.clone().to_compound().into()), &mut out);
646        buf.write_all(&out)
647    }
648}
649
650impl From<String> for FormattedText {
651    fn from(s: String) -> Self {
652        FormattedText::Text(TextComponent {
653            text: s,
654            base: BaseComponent::default(),
655        })
656    }
657}
658impl From<&str> for FormattedText {
659    fn from(s: &str) -> Self {
660        Self::from(s.to_string())
661    }
662}
663impl From<TranslatableComponent> for FormattedText {
664    fn from(c: TranslatableComponent) -> Self {
665        FormattedText::Translatable(c)
666    }
667}
668impl From<TextComponent> for FormattedText {
669    fn from(c: TextComponent) -> Self {
670        FormattedText::Text(c)
671    }
672}
673
674impl Display for FormattedText {
675    /// Render the text in the component but without any formatting/styling.
676    ///
677    /// If you want the text to be styled, consider using [`Self::to_ansi`] or
678    /// [`Self::to_html`].
679    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
680        match self {
681            FormattedText::Text(c) => c.fmt(f),
682            FormattedText::Translatable(c) => c.fmt(f),
683        }
684    }
685}
686
687impl Default for FormattedText {
688    fn default() -> Self {
689        FormattedText::Text(TextComponent::default())
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use serde_json::Value;
696
697    use super::*;
698
699    #[test]
700    fn deserialize_translation() {
701        let j: Value =
702            serde_json::from_str(r#"{"translate": "translation.test.args", "with": ["a", "b"]}"#)
703                .unwrap();
704        let component = FormattedText::deserialize(&j).unwrap();
705        assert_eq!(
706            component,
707            FormattedText::Translatable(TranslatableComponent::new(
708                "translation.test.args".to_string(),
709                vec![
710                    StringOrComponent::String("a".to_string()),
711                    StringOrComponent::String("b".to_string())
712                ]
713            ))
714        );
715    }
716
717    #[test]
718    fn deserialize_translation_invalid_arguments() {
719        let j: Value =
720            serde_json::from_str(r#"{"translate": "translation.test.args", "with": {}}"#).unwrap();
721        assert!(FormattedText::deserialize(&j).is_err());
722    }
723
724    #[test]
725    fn deserialize_translation_fallback() {
726        let j: Value = serde_json::from_str(r#"{"translate": "translation.test.undefined", "fallback": "fallback: %s", "with": ["a"]}"#).unwrap();
727        let component = FormattedText::deserialize(&j).unwrap();
728        assert_eq!(
729            component,
730            FormattedText::Translatable(TranslatableComponent::with_fallback(
731                "translation.test.undefined".to_string(),
732                Some("fallback: %s".to_string()),
733                vec![StringOrComponent::String("a".to_string())]
734            ))
735        );
736    }
737
738    #[test]
739    fn deserialize_translation_invalid_fallback() {
740        let j: Value = serde_json::from_str(
741            r#"{"translate": "translation.test.undefined", "fallback": {"text": "invalid"}}"#,
742        )
743        .unwrap();
744        assert!(FormattedText::deserialize(&j).is_err());
745    }
746}