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