1use 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
36pub(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
318pub(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}