Skip to main content

steel_utils/text/
nbt.rs

1//! Vanilla-shaped text rendering for command-visible NBT values.
2
3use std::fmt::Write as _;
4
5use simdnbt::owned::{NbtCompound, NbtTag};
6use text_components::{Modifier as _, TextComponent, format::Color};
7
8const MAX_DEPTH: usize = 64;
9const MAX_ARRAY_LENGTH: usize = 128;
10
11/// Renders one NBT tag using vanilla's command-component syntax highlighting.
12///
13/// Float and double digits intentionally use Rust formatting. All surrounding
14/// SNBT syntax and styling follows `TextComponentTagVisitor` from vanilla 26.2.
15#[must_use]
16pub fn command_nbt_component(tag: &NbtTag, plain: bool) -> TextComponent {
17    let mut visitor = NbtComponentVisitor {
18        plain,
19        sort_keys: tracing::enabled!(tracing::Level::DEBUG),
20        result: TextComponent::new(),
21    };
22    visitor.visit(tag, 0);
23    visitor.result
24}
25
26struct NbtComponentVisitor {
27    plain: bool,
28    sort_keys: bool,
29    result: TextComponent,
30}
31
32impl NbtComponentVisitor {
33    fn visit(&mut self, tag: &NbtTag, depth: usize) {
34        match tag {
35            NbtTag::Byte(value) => {
36                self.number((*value).to_string());
37                self.number_type("b");
38            }
39            NbtTag::Short(value) => {
40                self.number(value.to_string());
41                self.number_type("s");
42            }
43            NbtTag::Int(value) => self.number(value.to_string()),
44            NbtTag::Long(value) => {
45                self.number(value.to_string());
46                self.number_type("L");
47            }
48            NbtTag::Float(value) => {
49                self.number(format!("{value:?}"));
50                self.number_type("f");
51            }
52            NbtTag::Double(value) => {
53                self.number(format!("{value:?}"));
54                self.number_type("d");
55            }
56            NbtTag::ByteArray(values) => self.byte_array(values),
57            NbtTag::String(value) => self.string(&value.to_string()),
58            NbtTag::List(values) => {
59                let values = values.as_nbt_tags();
60                self.list(&values, depth);
61            }
62            NbtTag::Compound(value) => self.compound(value, depth),
63            NbtTag::IntArray(values) => self.int_array(values),
64            NbtTag::LongArray(values) => self.long_array(values),
65        }
66    }
67
68    fn byte_array(&mut self, values: &[u8]) {
69        self.token("[");
70        self.number_type("B");
71        self.token(";");
72        for (index, value) in values.iter().take(MAX_ARRAY_LENGTH).enumerate() {
73            self.token(" ");
74            self.number((*value as i8).to_string());
75            self.number_type("b");
76            if index + 1 != values.len() {
77                self.token(",");
78            }
79        }
80        if values.len() > MAX_ARRAY_LENGTH {
81            self.folded();
82        }
83        self.token("]");
84    }
85
86    fn int_array(&mut self, values: &[i32]) {
87        self.token("[");
88        self.number_type("I");
89        self.token(";");
90        for (index, value) in values.iter().take(MAX_ARRAY_LENGTH).enumerate() {
91            self.token(" ");
92            self.number(value.to_string());
93            if index + 1 != values.len() {
94                self.token(",");
95            }
96        }
97        if values.len() > MAX_ARRAY_LENGTH {
98            self.folded();
99        }
100        self.token("]");
101    }
102
103    fn long_array(&mut self, values: &[i64]) {
104        self.token("[");
105        self.number_type("L");
106        self.token(";");
107        for (index, value) in values.iter().take(MAX_ARRAY_LENGTH).enumerate() {
108            self.token(" ");
109            self.number(value.to_string());
110            self.number_type("L");
111            if index + 1 != values.len() {
112                self.token(",");
113            }
114        }
115        if values.len() > MAX_ARRAY_LENGTH {
116            self.folded();
117        }
118        self.token("]");
119    }
120
121    fn list(&mut self, values: &[NbtTag], depth: usize) {
122        self.token("[");
123        if values.is_empty() {
124            self.token("]");
125            return;
126        }
127        if depth >= MAX_DEPTH {
128            self.folded();
129            self.token("]");
130            return;
131        }
132        for (index, value) in values.iter().enumerate() {
133            if index != 0 {
134                self.token(",");
135                self.token(" ");
136            }
137            self.visit(value, depth + 1);
138        }
139        self.token("]");
140    }
141
142    fn compound(&mut self, value: &NbtCompound, depth: usize) {
143        self.token("{");
144        if value.is_empty() {
145            self.token("}");
146            return;
147        }
148        if depth >= MAX_DEPTH {
149            self.folded();
150            self.token("}");
151            return;
152        }
153        let mut entries = value
154            .iter()
155            .map(|(key, tag)| (key.to_string(), tag))
156            .collect::<Vec<_>>();
157        if self.sort_keys {
158            entries.sort_by(|(left, _), (right, _)| left.encode_utf16().cmp(right.encode_utf16()));
159        }
160        for (index, (key, tag)) in entries.into_iter().enumerate() {
161            if index != 0 {
162                self.token(",");
163                self.token(" ");
164            }
165            self.key(&key);
166            self.token(":");
167            self.token(" ");
168            self.visit(tag, depth + 1);
169        }
170        self.token("}");
171    }
172
173    fn string(&mut self, value: &str) {
174        let (quote, escaped) = quote_and_escape(value);
175        self.token(&quote.to_string());
176        self.string_value(escaped);
177        self.token(&quote.to_string());
178    }
179
180    fn key(&mut self, value: &str) {
181        if is_simple_value(value) {
182            self.key_value(value.to_owned());
183            return;
184        }
185        let (quote, escaped) = quote_and_escape(value);
186        self.token(&quote.to_string());
187        self.key_value(escaped);
188        self.token(&quote.to_string());
189    }
190
191    fn token(&mut self, value: &str) {
192        self.result
193            .children
194            .push(TextComponent::plain(value.to_owned()));
195    }
196
197    fn folded(&mut self) {
198        self.styled("<...>".to_owned(), Color::Gray);
199    }
200
201    fn key_value(&mut self, value: String) {
202        self.styled(value, Color::Aqua);
203    }
204
205    fn string_value(&mut self, value: String) {
206        self.styled(value, Color::Green);
207    }
208
209    fn number(&mut self, value: String) {
210        self.styled(value, Color::Gold);
211    }
212
213    fn number_type(&mut self, value: &str) {
214        self.styled(value.to_owned(), Color::Red);
215    }
216
217    fn styled(&mut self, value: String, color: Color) {
218        let component = if self.plain {
219            TextComponent::plain(value)
220        } else {
221            TextComponent::plain(value).color(color)
222        };
223        self.result.children.push(component);
224    }
225}
226
227fn is_simple_value(value: &str) -> bool {
228    !value.is_empty()
229        && value
230            .bytes()
231            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
232}
233
234fn quote_and_escape(value: &str) -> (char, String) {
235    let quote = value
236        .chars()
237        .find_map(|character| match character {
238            '"' => Some('\''),
239            '\'' => Some('"'),
240            _ => None,
241        })
242        .unwrap_or('"');
243    let mut escaped = String::new();
244    for character in value.chars() {
245        match character {
246            '\\' => escaped.push_str("\\\\"),
247            character if character == quote => {
248                escaped.push('\\');
249                escaped.push(character);
250            }
251            '\u{0008}' => escaped.push_str("\\b"),
252            '\t' => escaped.push_str("\\t"),
253            '\n' => escaped.push_str("\\n"),
254            '\u{000c}' => escaped.push_str("\\f"),
255            '\r' => escaped.push_str("\\r"),
256            character if character < ' ' => {
257                let _ = write!(escaped, "\\x{:02x}", u32::from(character));
258            }
259            character => escaped.push(character),
260        }
261    }
262    (quote, escaped)
263}
264
265#[cfg(test)]
266mod tests {
267    use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
268
269    use crate::text::DisplayResolutor;
270
271    use super::command_nbt_component;
272
273    fn plain(tag: &NbtTag) -> String {
274        command_nbt_component(tag, true).to_plain(&DisplayResolutor)
275    }
276
277    #[test]
278    fn renders_vanilla_suffixes_arrays_and_signed_bytes() {
279        assert_eq!(plain(&NbtTag::Long(7)), "7L");
280        assert_eq!(plain(&NbtTag::ByteArray(vec![255, 1])), "[B; -1b, 1b]");
281        assert_eq!(plain(&NbtTag::LongArray(vec![2, 3])), "[L; 2L, 3L]");
282    }
283
284    #[test]
285    fn quotes_and_escapes_strings_and_compound_keys() {
286        let mut compound = NbtCompound::new();
287        compound.insert("simple", NbtTag::String("can't \"stop\"\n".into()));
288        compound.insert("needs space", NbtTag::Int(1));
289
290        assert_eq!(
291            plain(&NbtTag::Compound(compound)),
292            "{simple: \"can't \\\"stop\\\"\\n\", \"needs space\": 1}"
293        );
294    }
295
296    #[test]
297    fn folds_nested_collections_at_vanillas_depth_limit() {
298        let mut tag = NbtTag::Compound(NbtCompound::new());
299        for _ in 0..=64 {
300            let mut parent = NbtCompound::new();
301            parent.insert("value", tag);
302            tag = NbtTag::Compound(parent);
303        }
304
305        assert!(plain(&tag).contains("{<...>}"));
306    }
307
308    #[test]
309    fn keeps_empty_collections_visible_at_vanillas_depth_limit() {
310        let mut tag = NbtTag::Compound(NbtCompound::new());
311        for _ in 0..64 {
312            let mut parent = NbtCompound::new();
313            parent.insert("value", tag);
314            tag = NbtTag::Compound(parent);
315        }
316
317        assert!(!plain(&tag).contains("<...>"));
318    }
319
320    #[test]
321    fn folds_arrays_after_vanillas_element_limit() {
322        let rendered = plain(&NbtTag::IntArray((0..129).collect()));
323
324        assert!(rendered.ends_with("127,<...>]"));
325        assert!(!rendered.contains(" 128"));
326    }
327
328    #[test]
329    fn renders_lists_with_vanillas_spacing() {
330        let tag = NbtTag::List(NbtList::String(vec!["one".into(), "two".into()]));
331
332        assert_eq!(plain(&tag), "[\"one\", \"two\"]");
333    }
334}