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::{AzaleaRead, AzaleaWrite, 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_string()),
104    ///     |text| text.to_string(),
105    ///     |style| {
106    ///         if !style.is_empty() {
107    ///             "\u{1b}[m".to_string()
108    ///         } else {
109    ///             "".to_string()
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_string(),
211            |style| if !style.is_empty() { "\u{1b}[m" } else { "" }.to_string(),
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_string(),
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_string(),
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_string();
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_string(),
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        // the first item in the array is the one that we're gonna return, the others
412        // are siblings
413        let mut component =
414            FormattedText::deserialize(&json_array[0]).map_err(de::Error::custom)?;
415        for i in 1..json_array.len() {
416            component.append(
417                FormattedText::deserialize(json_array.get(i).unwrap())
418                    .map_err(de::Error::custom)?,
419            );
420        }
421        Ok(component)
422    }
423}
424
425#[cfg(feature = "simdnbt")]
426impl simdnbt::Serialize for FormattedText {
427    fn to_compound(self) -> simdnbt::owned::NbtCompound {
428        match self {
429            FormattedText::Text(c) => c.to_compound(),
430            FormattedText::Translatable(c) => c.to_compound(),
431        }
432    }
433}
434
435#[cfg(feature = "simdnbt")]
436impl simdnbt::FromNbtTag for FormattedText {
437    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
438        // if it's a string, return a text component with that string
439        if let Some(string) = tag.string() {
440            Some(FormattedText::from_nbt_string(string))
441        }
442        // if it's a compound, make it do things with { text } and stuff
443        // simdnbt::borrow::NbtTag::Compound(compound) => {
444        else if let Some(compound) = tag.compound() {
445            FormattedText::from_nbt_compound(compound)
446        }
447        // ok so it's not a compound, if it's a list deserialize every item
448        else if let Some(list) = tag.list() {
449            FormattedText::from_nbt_list(list)
450        } else {
451            Some(FormattedText::Text(TextComponent::new("".to_owned())))
452        }
453    }
454}
455
456#[cfg(feature = "simdnbt")]
457impl FormattedText {
458    fn from_nbt_string(s: &simdnbt::Mutf8Str) -> Self {
459        FormattedText::from(s)
460    }
461    fn from_nbt_list(list: simdnbt::borrow::NbtList) -> Option<FormattedText> {
462        use tracing::debug;
463
464        let mut component;
465        if let Some(compounds) = list.compounds() {
466            component = FormattedText::from_nbt_compound(compounds.first()?)?;
467            for compound in compounds.into_iter().skip(1) {
468                component.append(FormattedText::from_nbt_compound(compound)?);
469            }
470        } else if let Some(strings) = list.strings() {
471            component = FormattedText::from(*(strings.first()?));
472            for &string in strings.iter().skip(1) {
473                component.append(FormattedText::from(string));
474            }
475        } else {
476            debug!("couldn't parse {list:?} as FormattedText");
477            return None;
478        }
479        Some(component)
480    }
481
482    pub fn from_nbt_compound(compound: simdnbt::borrow::NbtCompound) -> Option<Self> {
483        use simdnbt::{Deserialize, FromNbtTag};
484        use tracing::{trace, warn};
485
486        let mut component: FormattedText;
487
488        if let Some(text) = compound.get("text") {
489            let text = text.string().unwrap_or_default().to_string();
490            component = FormattedText::Text(TextComponent::new(text));
491        } else if let Some(translate) = compound.get("translate") {
492            let translate = translate.string()?.into();
493            if let Some(with) = compound.get("with") {
494                let mut with_array = Vec::new();
495                let with_list = with.list()?;
496                if with_list.empty() {
497                } else if let Some(with) = with_list.strings() {
498                    for item in with {
499                        with_array.push(PrimitiveOrComponent::String(item.to_string()));
500                    }
501                } else if let Some(with) = with_list.ints() {
502                    for item in with {
503                        with_array.push(PrimitiveOrComponent::Integer(item));
504                    }
505                } else if let Some(with) = with_list.compounds() {
506                    for item in with {
507                        // if it's a string component with no styling and no siblings,
508                        // just add a string to
509                        // with_array otherwise add the
510                        // component to the array
511                        if let Some(primitive) = item.get("") {
512                            // minecraft does this sometimes, for example
513                            // for the /give system messages
514                            if let Some(b) = primitive.byte() {
515                                // interpreted as boolean
516                                with_array.push(PrimitiveOrComponent::Boolean(b != 0));
517                            } else if let Some(s) = primitive.short() {
518                                with_array.push(PrimitiveOrComponent::Short(s));
519                            } else if let Some(i) = primitive.int() {
520                                with_array.push(PrimitiveOrComponent::Integer(i));
521                            } else if let Some(l) = primitive.long() {
522                                with_array.push(PrimitiveOrComponent::Long(l));
523                            } else if let Some(f) = primitive.float() {
524                                with_array.push(PrimitiveOrComponent::Float(f));
525                            } else if let Some(d) = primitive.double() {
526                                with_array.push(PrimitiveOrComponent::Double(d));
527                            } else if let Some(s) = primitive.string() {
528                                with_array.push(PrimitiveOrComponent::String(s.to_string()));
529                            } else {
530                                warn!(
531                                    "couldn't parse {item:?} as FormattedText because it has a disallowed primitive"
532                                );
533                                with_array.push(PrimitiveOrComponent::String("?".to_string()));
534                            }
535                        } else if let Some(c) = FormattedText::from_nbt_compound(item) {
536                            if let FormattedText::Text(text_component) = c
537                                && text_component.base.siblings.is_empty()
538                                && text_component.base.style.is_empty()
539                            {
540                                with_array.push(PrimitiveOrComponent::String(text_component.text));
541                                continue;
542                            }
543                            with_array.push(PrimitiveOrComponent::FormattedText(
544                                FormattedText::from_nbt_compound(item)?,
545                            ));
546                        } else {
547                            warn!("couldn't parse {item:?} as FormattedText");
548                            with_array.push(PrimitiveOrComponent::String("?".to_string()));
549                        }
550                    }
551                } else {
552                    warn!(
553                        "couldn't parse {with:?} as FormattedText because it's not a list of compounds"
554                    );
555                    return None;
556                }
557                component =
558                    FormattedText::Translatable(TranslatableComponent::new(translate, with_array));
559            } else {
560                // if it doesn't have a "with", just have the with_array be empty
561                component =
562                    FormattedText::Translatable(TranslatableComponent::new(translate, Vec::new()));
563            }
564        } else if let Some(score) = compound.compound("score") {
565            if score.get("name").is_none() || score.get("objective").is_none() {
566                trace!("A score component needs at least a name and an objective");
567                return None;
568            }
569            // TODO: implement these
570            return None;
571        } else if compound.get("selector").is_some() {
572            trace!("selector text components aren't supported");
573            return None;
574        } else if compound.get("keybind").is_some() {
575            trace!("keybind text components aren't supported");
576            return None;
577        } else if compound.get("object").is_some() {
578            trace!("object text components aren't supported");
579            return None;
580        } else if let Some(tag) = compound.get("") {
581            return FormattedText::from_nbt_tag(tag);
582        } else {
583            let _nbt = compound.get("nbt")?;
584            let _separator = FormattedText::parse_separator_nbt(&compound)?;
585
586            let _interpret = match compound.get("interpret") {
587                Some(v) => v.byte().unwrap_or_default() != 0,
588                None => false,
589            };
590            if let Some(_block) = compound.get("block") {}
591            trace!("nbt text components aren't yet supported");
592            return None;
593        }
594        if let Some(extra) = compound.get("extra") {
595            // if it's an array, deserialize every item
596            if let Some(items) = extra.list() {
597                if let Some(items) = items.compounds() {
598                    for item in items {
599                        component.append(FormattedText::from_nbt_compound(item)?);
600                    }
601                } else if let Some(items) = items.strings() {
602                    for item in items {
603                        component.append(FormattedText::from_nbt_string(item));
604                    }
605                } else if let Some(items) = items.lists() {
606                    for item in items {
607                        component.append(FormattedText::from_nbt_list(item)?);
608                    }
609                } else {
610                    warn!(
611                        "couldn't parse {items:?} as FormattedText because it's not a list of compounds or strings"
612                    );
613                }
614            } else {
615                component.append(FormattedText::from_nbt_tag(extra)?);
616            }
617        }
618
619        let base_style = Style::from_compound(compound).ok()?;
620        let new_style = &mut component.get_base_mut().style;
621        **new_style = new_style.merged_with(&base_style);
622
623        Some(component)
624    }
625}
626
627#[cfg(feature = "simdnbt")]
628impl From<&simdnbt::Mutf8Str> for FormattedText {
629    fn from(s: &simdnbt::Mutf8Str) -> Self {
630        FormattedText::Text(TextComponent::new(s.to_string()))
631    }
632}
633
634#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
635impl AzaleaRead for FormattedText {
636    fn azalea_read(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> {
637        use simdnbt::FromNbtTag;
638        use tracing::trace;
639
640        let nbt = simdnbt::borrow::read_optional_tag(buf)?;
641        trace!(
642            "Reading NBT for FormattedText: {:?}",
643            nbt.as_ref().map(|n| n.as_tag().to_owned())
644        );
645        match nbt {
646            Some(nbt) => FormattedText::from_nbt_tag(nbt.as_tag()).ok_or(BufReadError::Custom(
647                "couldn't convert nbt to chat message".to_owned(),
648            )),
649            _ => Ok(FormattedText::default()),
650        }
651    }
652}
653
654#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
655impl AzaleaWrite for FormattedText {
656    fn azalea_write(&self, buf: &mut impl Write) -> io::Result<()> {
657        use simdnbt::Serialize;
658
659        let mut out = Vec::new();
660        simdnbt::owned::BaseNbt::write_unnamed(&(self.clone().to_compound().into()), &mut out);
661        buf.write_all(&out)
662    }
663}
664
665impl From<String> for FormattedText {
666    fn from(s: String) -> Self {
667        FormattedText::Text(TextComponent {
668            text: s,
669            base: BaseComponent::default(),
670        })
671    }
672}
673impl From<&str> for FormattedText {
674    fn from(s: &str) -> Self {
675        Self::from(s.to_string())
676    }
677}
678impl From<TranslatableComponent> for FormattedText {
679    fn from(c: TranslatableComponent) -> Self {
680        FormattedText::Translatable(c)
681    }
682}
683impl From<TextComponent> for FormattedText {
684    fn from(c: TextComponent) -> Self {
685        FormattedText::Text(c)
686    }
687}
688
689impl Display for FormattedText {
690    /// Render the text in the component but without any formatting/styling.
691    ///
692    /// If you want the text to be styled, consider using [`Self::to_ansi`] or
693    /// [`Self::to_html`].
694    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
695        match self {
696            FormattedText::Text(c) => c.fmt(f),
697            FormattedText::Translatable(c) => c.fmt(f),
698        }
699    }
700}
701
702impl Default for FormattedText {
703    fn default() -> Self {
704        FormattedText::Text(TextComponent::default())
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use serde_json::Value;
711
712    use super::*;
713    use crate::style::TextColor;
714
715    #[test]
716    fn deserialize_translation() {
717        let j: Value =
718            serde_json::from_str(r#"{"translate": "translation.test.args", "with": ["a", "b"]}"#)
719                .unwrap();
720        let component = FormattedText::deserialize(&j).unwrap();
721        assert_eq!(
722            component,
723            FormattedText::Translatable(TranslatableComponent::new(
724                "translation.test.args".to_string(),
725                vec![
726                    PrimitiveOrComponent::String("a".to_string()),
727                    PrimitiveOrComponent::String("b".to_string())
728                ]
729            ))
730        );
731    }
732
733    #[test]
734    fn deserialize_translation_invalid_arguments() {
735        let j: Value =
736            serde_json::from_str(r#"{"translate": "translation.test.args", "with": {}}"#).unwrap();
737        assert!(FormattedText::deserialize(&j).is_err());
738    }
739
740    #[test]
741    fn deserialize_translation_fallback() {
742        let j: Value = serde_json::from_str(r#"{"translate": "translation.test.undefined", "fallback": "fallback: %s", "with": ["a"]}"#).unwrap();
743        let component = FormattedText::deserialize(&j).unwrap();
744        assert_eq!(
745            component,
746            FormattedText::Translatable(TranslatableComponent::with_fallback(
747                "translation.test.undefined".to_string(),
748                Some("fallback: %s".to_string()),
749                vec![PrimitiveOrComponent::String("a".to_string())]
750            ))
751        );
752    }
753
754    #[test]
755    fn deserialize_translation_invalid_fallback() {
756        let j: Value = serde_json::from_str(
757            r#"{"translate": "translation.test.undefined", "fallback": {"text": "invalid"}}"#,
758        )
759        .unwrap();
760        assert!(FormattedText::deserialize(&j).is_err());
761    }
762    #[test]
763    fn deserialize_translation_primitive_args() {
764        let j: Value = serde_json::from_str(
765            r#"{"translate":"commands.list.players", "with": [1, 65536, "<players>", {"text": "unused", "color": "red"}]}"#,
766        )
767        .unwrap();
768        assert_eq!(
769            FormattedText::deserialize(&j).unwrap(),
770            FormattedText::Translatable(TranslatableComponent::new(
771                "commands.list.players".to_string(),
772                vec![
773                    PrimitiveOrComponent::Short(1),
774                    PrimitiveOrComponent::Integer(65536),
775                    PrimitiveOrComponent::String("<players>".to_string()),
776                    PrimitiveOrComponent::FormattedText(FormattedText::Text(
777                        TextComponent::new("unused")
778                            .with_style(Style::new().color(Some(TextColor::parse("red").unwrap())))
779                    ))
780                ]
781            ))
782        );
783    }
784
785    #[test]
786    fn test_translatable_with_color_inheritance() {
787        let json = serde_json::json!({
788            "translate": "advancements.story.smelt_iron.title",
789            "color": "green",
790            "with": [{"text": "Acquire Hardware"}]
791        });
792        let component = FormattedText::deserialize(&json).unwrap();
793        let ansi = component.to_ansi();
794        assert!(ansi.contains("\u{1b}[38;2;85;255;85m"));
795    }
796}