Skip to main content

steel_core/command/execution/
text.rs

1//! Resolution of command-context text components.
2
3use simdnbt::owned::NbtTag;
4use steel_utils::{Identifier, nbt::parse_nbt_path, text::command_nbt_component, translations};
5use text_components::{
6    TextComponent,
7    content::{Content, NbtSource, Object, Resolvable},
8    custom::CustomData,
9    interactivity::HoverEvent,
10    resolving::TryTextResolutor,
11};
12
13use super::{CommandSource, coordinates::parse_block_pos, selector::parse_entity_selector_text};
14use crate::{
15    command::brigadier::{CommandSyntaxError, StringReader},
16    entity::Entity,
17    scoreboard::ScoreHolder,
18};
19
20pub(crate) trait CommandTextResolutionSource {
21    fn selector_display_names(
22        &self,
23        selector: &str,
24    ) -> Result<Vec<TextComponent>, CommandSyntaxError>;
25
26    fn score_selector_names(
27        &self,
28        selector: &str,
29    ) -> Result<Option<Vec<String>>, CommandSyntaxError>;
30
31    fn score(&self, holder: &str, objective: &str) -> Result<Option<i32>, CommandSyntaxError>;
32
33    fn nbt_source(&self, source: &NbtSource) -> Result<Vec<NbtTag>, CommandSyntaxError>;
34}
35
36/// Resolves selectors, scores, and NBT against one command source.
37pub(crate) struct CommandTextResolver<'a, S: ?Sized = CommandSource> {
38    source: &'a S,
39    default_scoreboard_name: Option<String>,
40}
41
42impl<'a> CommandTextResolver<'a, CommandSource> {
43    pub(crate) fn with_entity_override(source: &'a CommandSource, entity: &dyn Entity) -> Self {
44        Self {
45            source,
46            default_scoreboard_name: Some(entity.scoreboard_name()),
47        }
48    }
49}
50
51impl<S> TryTextResolutor for CommandTextResolver<'_, S>
52where
53    S: CommandTextResolutionSource + ?Sized,
54{
55    type Error = CommandSyntaxError;
56
57    fn try_resolve_content(
58        &self,
59        resolvable: &Resolvable,
60        recursion_depth: usize,
61    ) -> Result<TextComponent, Self::Error> {
62        match resolvable {
63            Resolvable::Entity {
64                selector,
65                separator,
66            } => {
67                let values = self.source.selector_display_names(selector)?;
68                let separator = separator
69                    .as_deref()
70                    .cloned()
71                    .unwrap_or_else(|| *Resolvable::entity_separator());
72                Ok(join_components(values, &separator))
73            }
74            Resolvable::Scoreboard {
75                selector,
76                objective,
77            } => self.resolve_score(selector, objective),
78            Resolvable::NBT {
79                path,
80                interpret,
81                plain,
82                separator,
83                source,
84            } => self.resolve_nbt(
85                path,
86                *interpret,
87                *plain,
88                separator.as_deref(),
89                source,
90                recursion_depth,
91            ),
92        }
93    }
94
95    fn try_resolve_custom(&self, data: &CustomData) -> Result<Option<TextComponent>, Self::Error> {
96        Ok(Some(TextComponent::from(data.clone())))
97    }
98}
99
100impl<S> CommandTextResolver<'_, S>
101where
102    S: CommandTextResolutionSource + ?Sized,
103{
104    fn resolve_score(
105        &self,
106        selector: &str,
107        objective: &str,
108    ) -> Result<TextComponent, CommandSyntaxError> {
109        let mut holder = match self.source.score_selector_names(selector)? {
110            Some(names) if names.len() > 1 => {
111                return Err(CommandSyntaxError::dynamic(TextComponent::from(
112                    &translations::ARGUMENT_ENTITY_TOOMANY,
113                )));
114            }
115            Some(mut names) => names.pop().unwrap_or_else(|| selector.to_owned()),
116            None => selector.to_owned(),
117        };
118        if holder == "*"
119            && let Some(default_name) = &self.default_scoreboard_name
120        {
121            default_name.clone_into(&mut holder);
122        }
123
124        Ok(self
125            .source
126            .score(&holder, objective)?
127            .map_or_else(TextComponent::new, |score| {
128                TextComponent::plain(score.to_string())
129            }))
130    }
131
132    fn resolve_nbt(
133        &self,
134        path: &str,
135        interpret: bool,
136        plain: bool,
137        separator: Option<&TextComponent>,
138        source: &NbtSource,
139        recursion_depth: usize,
140    ) -> Result<TextComponent, CommandSyntaxError> {
141        let path = parse_nbt_path(path).map_err(|error| {
142            CommandSyntaxError::dynamic(format!("Invalid NBT path '{path}': {error}"))
143        })?;
144        let selected = self
145            .source
146            .nbt_source(source)?
147            .into_iter()
148            .flat_map(|tag| path.get(&tag));
149        let separator = separator
150            .cloned()
151            .unwrap_or_else(|| *Resolvable::nbt_separator());
152
153        if !interpret {
154            return Ok(join_components(
155                selected.map(|tag| command_nbt_component(&tag, plain)),
156                &separator,
157            ));
158        }
159
160        let mut values = Vec::new();
161        for tag in selected {
162            let component = match TextComponent::try_from_nbt(&tag) {
163                Ok(component) => component,
164                Err(error) => {
165                    tracing::warn!(?tag, %error, "failed to parse component from command NBT");
166                    continue;
167                }
168            };
169            if let Err(error) = validate_component_syntax(&component) {
170                tracing::warn!(?tag, %error, "failed to compile component from command NBT");
171                continue;
172            }
173            match component.try_resolve_from_depth(self, recursion_depth) {
174                Ok(component) => values.push(component),
175                Err(error) => {
176                    tracing::warn!(?tag, %error, "failed to resolve component from command NBT");
177                }
178            }
179        }
180        Ok(join_components(values, &separator))
181    }
182}
183
184impl CommandTextResolutionSource for CommandSource {
185    fn selector_display_names(
186        &self,
187        selector: &str,
188    ) -> Result<Vec<TextComponent>, CommandSyntaxError> {
189        Ok(parse_entity_selector_text(selector)?
190            .find_entities(self)?
191            .into_iter()
192            .map(|entity| entity.display_name())
193            .collect())
194    }
195
196    fn score_selector_names(
197        &self,
198        selector: &str,
199    ) -> Result<Option<Vec<String>>, CommandSyntaxError> {
200        let Ok(selector) = parse_entity_selector_text(selector) else {
201            return Ok(None);
202        };
203        Ok(Some(
204            selector
205                .find_entities(self)?
206                .into_iter()
207                .map(|entity| entity.scoreboard_name())
208                .collect(),
209        ))
210    }
211
212    fn score(&self, holder: &str, objective: &str) -> Result<Option<i32>, CommandSyntaxError> {
213        let scoreboard = self
214            .server()
215            .scoreboards
216            .get(self.world().domain())
217            .ok_or_else(|| {
218                CommandSyntaxError::dynamic(format!(
219                    "Domain '{}' has no command scoreboard",
220                    self.world().domain()
221                ))
222            })?;
223        let Some(objective) = scoreboard.objective(objective) else {
224            return Ok(None);
225        };
226        Ok(scoreboard.score(&ScoreHolder::new(holder), &objective))
227    }
228
229    fn nbt_source(&self, source: &NbtSource) -> Result<Vec<NbtTag>, CommandSyntaxError> {
230        match source {
231            NbtSource::Entity(selector) => Ok(parse_entity_selector_text(selector)?
232                .find_entities(self)?
233                .into_iter()
234                .map(|entity| NbtTag::Compound(entity.nbt_for_data_compare()))
235                .collect()),
236            NbtSource::Block(coordinates) => {
237                let coordinates = parse_block_coordinates(coordinates)?;
238                let Some(block_entity) = self.world().get_block_entity(coordinates.block_pos(self))
239                else {
240                    return Ok(Vec::new());
241                };
242                Ok(vec![NbtTag::Compound(
243                    block_entity.save_with_full_metadata(),
244                )])
245            }
246            NbtSource::Storage(identifier) => {
247                let identifier = parse_resource_identifier(identifier).map_err(|error| {
248                    CommandSyntaxError::dynamic(format!(
249                        "Invalid command storage identifier '{identifier}': {error}"
250                    ))
251                })?;
252                let storage = self
253                    .server()
254                    .command_storage
255                    .get(self.world().domain())
256                    .ok_or_else(|| {
257                        CommandSyntaxError::dynamic(format!(
258                            "Domain '{}' has no command storage",
259                            self.world().domain()
260                        ))
261                    })?;
262                Ok(vec![NbtTag::Compound(storage.get(&identifier))])
263            }
264        }
265    }
266}
267
268fn parse_block_coordinates(raw: &str) -> Result<super::Coordinates, CommandSyntaxError> {
269    let mut reader = StringReader::new(raw);
270    let coordinates = parse_block_pos(&mut reader)?;
271    if reader.can_read() {
272        return Err(CommandSyntaxError::dynamic(format!(
273            "Invalid block coordinates '{raw}': trailing data"
274        )));
275    }
276    Ok(coordinates)
277}
278
279fn parse_resource_identifier(raw: &str) -> Result<Identifier, &'static str> {
280    let (namespace, path) =
281        raw.split_once(':')
282            .map_or((Identifier::VANILLA_NAMESPACE, raw), |(namespace, path)| {
283                if namespace.is_empty() {
284                    (Identifier::VANILLA_NAMESPACE, path)
285                } else {
286                    (namespace, path)
287                }
288            });
289    if namespace.is_empty() || path.is_empty() || !Identifier::validate(namespace, path) {
290        return Err("invalid resource location");
291    }
292    Ok(Identifier::new(namespace.to_owned(), path.to_owned()))
293}
294
295fn join_components(
296    values: impl IntoIterator<Item = TextComponent>,
297    separator: &TextComponent,
298) -> TextComponent {
299    let mut values = values.into_iter();
300    let Some(first) = values.next() else {
301        return TextComponent::new();
302    };
303    let Some(second) = values.next() else {
304        return first;
305    };
306
307    let mut result = TextComponent::new();
308    result.children.push(first);
309    result.children.push(separator.clone());
310    result.children.push(second);
311    for value in values {
312        result.children.push(separator.clone());
313        result.children.push(value);
314    }
315    result
316}
317
318/// Validates component strings that vanilla compiles as part of its component codec.
319pub(super) fn validate_component_syntax(component: &TextComponent) -> Result<(), String> {
320    match &component.content {
321        Content::Translate(message) => {
322            if let Some(arguments) = &message.args {
323                for argument in arguments {
324                    validate_component_syntax(argument)?;
325                }
326            }
327        }
328        Content::Object(Object::Atlas { fallback, .. } | Object::Player { fallback, .. }) => {
329            if let Some(fallback) = fallback {
330                validate_component_syntax(fallback)?;
331            }
332        }
333        Content::Resolvable(resolvable) => validate_resolvable_syntax(resolvable)?,
334        Content::Text { .. } | Content::Keybind { .. } | Content::Custom(_) => {}
335    }
336    for child in &component.children {
337        validate_component_syntax(child)?;
338    }
339    match &component.interactions.hover {
340        Some(
341            HoverEvent::ShowText { value }
342            | HoverEvent::ShowEntity {
343                name: Some(value), ..
344            },
345        ) => validate_component_syntax(value)?,
346        Some(HoverEvent::ShowItem { .. } | HoverEvent::ShowEntity { name: None, .. }) | None => {}
347    }
348    Ok(())
349}
350
351fn validate_resolvable_syntax(resolvable: &Resolvable) -> Result<(), String> {
352    match resolvable {
353        Resolvable::Scoreboard { .. } => {}
354        Resolvable::Entity {
355            selector,
356            separator,
357        } => {
358            parse_entity_selector_text(selector).map_err(|error| error.to_string())?;
359            if let Some(separator) = separator {
360                validate_component_syntax(separator)?;
361            }
362        }
363        Resolvable::NBT {
364            path,
365            separator,
366            source,
367            ..
368        } => {
369            parse_nbt_path(path).map_err(|error| error.to_string())?;
370            match source {
371                NbtSource::Entity(selector) => {
372                    parse_entity_selector_text(selector).map_err(|error| error.to_string())?;
373                }
374                NbtSource::Block(coordinates) => {
375                    parse_block_coordinates(coordinates).map_err(|error| error.to_string())?;
376                }
377                NbtSource::Storage(identifier) => {
378                    parse_resource_identifier(identifier).map_err(str::to_owned)?;
379                }
380            }
381            if let Some(separator) = separator {
382                validate_component_syntax(separator)?;
383            }
384        }
385    }
386    Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391    use std::collections::BTreeMap;
392
393    use simdnbt::owned::{NbtCompound, NbtList};
394    use text_components::{
395        Modifier as _,
396        content::{Content, NbtSource, Resolvable},
397        format::Color,
398    };
399
400    use steel_utils::text::DisplayResolutor;
401
402    use super::{
403        CommandSyntaxError, CommandTextResolutionSource, CommandTextResolver, NbtTag, TextComponent,
404    };
405
406    #[derive(Default)]
407    struct TestSource {
408        display_names: BTreeMap<String, Vec<TextComponent>>,
409        score_names: BTreeMap<String, Option<Vec<String>>>,
410        scores: BTreeMap<(String, String), i32>,
411        nbt: BTreeMap<String, Vec<NbtTag>>,
412    }
413
414    impl CommandTextResolutionSource for TestSource {
415        fn selector_display_names(
416            &self,
417            selector: &str,
418        ) -> Result<Vec<TextComponent>, CommandSyntaxError> {
419            Ok(self
420                .display_names
421                .get(selector)
422                .cloned()
423                .unwrap_or_default())
424        }
425
426        fn score_selector_names(
427            &self,
428            selector: &str,
429        ) -> Result<Option<Vec<String>>, CommandSyntaxError> {
430            Ok(self.score_names.get(selector).cloned().flatten())
431        }
432
433        fn score(&self, holder: &str, objective: &str) -> Result<Option<i32>, CommandSyntaxError> {
434            Ok(self
435                .scores
436                .get(&(holder.to_owned(), objective.to_owned()))
437                .copied())
438        }
439
440        fn nbt_source(&self, source: &NbtSource) -> Result<Vec<NbtTag>, CommandSyntaxError> {
441            let NbtSource::Storage(identifier) = source else {
442                return Ok(Vec::new());
443            };
444            Ok(self
445                .nbt
446                .get(identifier.as_ref())
447                .cloned()
448                .unwrap_or_default())
449        }
450    }
451
452    fn resolver<'a>(
453        source: &'a TestSource,
454        default_scoreboard_name: Option<&str>,
455    ) -> CommandTextResolver<'a, TestSource> {
456        CommandTextResolver {
457            source,
458            default_scoreboard_name: default_scoreboard_name.map(str::to_owned),
459        }
460    }
461
462    #[test]
463    fn selectors_use_resolved_separators_and_preserve_display_components() {
464        let mut source = TestSource::default();
465        source.display_names.insert(
466            "@a".to_owned(),
467            vec![
468                TextComponent::plain("Alex").color(Color::Red),
469                TextComponent::plain("Steve"),
470            ],
471        );
472        let component =
473            TextComponent::entity("@a", Some(TextComponent::plain(" | ").color(Color::Gold)));
474        let Ok(resolved) = component.try_resolve(&resolver(&source, None)) else {
475            panic!("selector component should resolve");
476        };
477
478        assert_eq!(resolved.to_plain(&DisplayResolutor), "Alex | Steve");
479        assert_eq!(resolved.children[0].format.color, Some(Color::Red));
480        assert_eq!(resolved.children[1].format.color, Some(Color::Gold));
481    }
482
483    #[test]
484    fn score_wildcard_resolves_separately_for_each_recipient() {
485        let mut source = TestSource::default();
486        source
487            .scores
488            .insert(("Alex".to_owned(), "points".to_owned()), 3);
489        source
490            .scores
491            .insert(("Steve".to_owned(), "points".to_owned()), 8);
492        let component = TextComponent::scoreboard("*", "points");
493
494        let Ok(alex) = component.try_resolve(&resolver(&source, Some("Alex"))) else {
495            panic!("Alex's score should resolve");
496        };
497        let Ok(steve) = component.try_resolve(&resolver(&source, Some("Steve"))) else {
498            panic!("Steve's score should resolve");
499        };
500        assert_eq!(alex.to_plain(&DisplayResolutor), "3");
501        assert_eq!(steve.to_plain(&DisplayResolutor), "8");
502    }
503
504    #[test]
505    fn score_selectors_require_at_most_one_entity_and_fall_back_when_empty() {
506        let mut source = TestSource::default();
507        source.score_names.insert(
508            "@a".to_owned(),
509            Some(vec!["Alex".to_owned(), "Steve".to_owned()]),
510        );
511        source
512            .scores
513            .insert(("@s".to_owned(), "points".to_owned()), 5);
514
515        assert!(
516            TextComponent::scoreboard("@a", "points")
517                .try_resolve(&resolver(&source, None))
518                .is_err()
519        );
520        let Ok(empty_selector) =
521            TextComponent::scoreboard("@s", "points").try_resolve(&resolver(&source, None))
522        else {
523            panic!("empty selector should fall back to its raw holder name");
524        };
525        assert_eq!(empty_selector.to_plain(&DisplayResolutor), "5");
526    }
527
528    #[test]
529    fn nbt_resolution_selects_paths_and_applies_plain_rendering() {
530        let mut first = NbtCompound::new();
531        first.insert("values", NbtList::Int(vec![1, 2]));
532        let mut source = TestSource::default();
533        source
534            .nbt
535            .insert("minecraft:test".to_owned(), vec![NbtTag::Compound(first)]);
536        let component = TextComponent {
537            content: Content::Resolvable(Resolvable::NBT {
538                path: "values[]".into(),
539                interpret: false,
540                plain: true,
541                separator: Some(Box::new(TextComponent::plain(" / "))),
542                source: NbtSource::storage("minecraft:test"),
543            }),
544            ..Default::default()
545        };
546        let Ok(resolved) = component.try_resolve(&resolver(&source, None)) else {
547            panic!("NBT component should resolve");
548        };
549
550        assert_eq!(resolved.to_plain(&DisplayResolutor), "1 / 2");
551        assert!(
552            resolved
553                .children
554                .iter()
555                .all(|child| child.format.color.is_none())
556        );
557    }
558
559    #[test]
560    fn interpreted_nbt_resolves_nested_scores_at_the_same_depth() {
561        let mut source = TestSource::default();
562        source
563            .scores
564            .insert(("Alex".to_owned(), "points".to_owned()), 12);
565        let mut component = NbtCompound::new();
566        let mut score = NbtCompound::new();
567        score.insert("name", "Alex");
568        score.insert("objective", "points");
569        component.insert("score", score);
570        let mut root = NbtCompound::new();
571        root.insert("message", component);
572        source
573            .nbt
574            .insert("minecraft:test".to_owned(), vec![NbtTag::Compound(root)]);
575        let component =
576            TextComponent::nbt("message", NbtSource::storage("minecraft:test"), true, None);
577        let Ok(resolved) = component.try_resolve(&resolver(&source, None)) else {
578            panic!("interpreted NBT component should resolve");
579        };
580
581        assert_eq!(resolved.to_plain(&DisplayResolutor), "12");
582    }
583
584    #[test]
585    fn interpreted_nbt_skips_components_with_invalid_compiled_strings() {
586        let mut invalid = NbtCompound::new();
587        invalid.insert("selector", "@e[");
588        let mut valid = NbtCompound::new();
589        valid.insert("text", "valid");
590        let mut root = NbtCompound::new();
591        root.insert("messages", NbtList::Compound(vec![invalid, valid]));
592        let mut source = TestSource::default();
593        source
594            .nbt
595            .insert("minecraft:test".to_owned(), vec![NbtTag::Compound(root)]);
596        let component = TextComponent::nbt(
597            "messages[]",
598            NbtSource::storage("minecraft:test"),
599            true,
600            None,
601        );
602        let Ok(resolved) = component.try_resolve(&resolver(&source, None)) else {
603            panic!("valid interpreted NBT components should still resolve");
604        };
605
606        assert_eq!(resolved.to_plain(&DisplayResolutor), "valid");
607    }
608}