Skip to main content

steel_utils/nbt/snbt/
writer.rs

1use std::fmt::Write as _;
2
3use simdnbt::owned::NbtTag;
4
5use crate::nbt::nbt_list_values;
6
7/// Renders an NBT value with Vanilla `Tag::toString` semantics.
8///
9/// Compound keys use Java string ordering, and floating-point values use the
10/// decimal selected by `Float.toString` / `Double.toString`.
11#[must_use]
12pub fn to_canonical_snbt(tag: &NbtTag) -> Option<String> {
13    let mut output = String::new();
14    write_canonical_snbt(tag, &mut output)?;
15    Some(output)
16}
17
18fn write_canonical_snbt(tag: &NbtTag, output: &mut String) -> Option<()> {
19    match tag {
20        NbtTag::Byte(value) => write!(output, "{value}b").ok()?,
21        NbtTag::Short(value) => write!(output, "{value}s").ok()?,
22        NbtTag::Int(value) => write!(output, "{value}").ok()?,
23        NbtTag::Long(value) => write!(output, "{value}L").ok()?,
24        NbtTag::Float(value) => write!(output, "{}f", java_float_string(*value)).ok()?,
25        NbtTag::Double(value) => write!(output, "{}d", java_double_string(*value)).ok()?,
26        NbtTag::ByteArray(values) => {
27            output.push_str("[B;");
28            for (index, value) in values.iter().enumerate() {
29                if index != 0 {
30                    output.push(',');
31                }
32                write!(output, "{}B", *value as i8).ok()?;
33            }
34            output.push(']');
35        }
36        NbtTag::String(value) => {
37            quote_and_escape(&value.to_owned().try_into_string().ok()?, output);
38        }
39        NbtTag::List(values) => {
40            output.push('[');
41            for (index, value) in nbt_list_values(values).iter().enumerate() {
42                if index != 0 {
43                    output.push(',');
44                }
45                write_canonical_snbt(value, output)?;
46            }
47            output.push(']');
48        }
49        NbtTag::Compound(compound) => {
50            let mut entries = compound
51                .iter()
52                .map(|(key, value)| Some((key.to_owned().try_into_string().ok()?, value)))
53                .collect::<Option<Vec<_>>>()?;
54            entries.sort_by(|(left, _), (right, _)| left.encode_utf16().cmp(right.encode_utf16()));
55            output.push('{');
56            for (index, (key, value)) in entries.into_iter().enumerate() {
57                if index != 0 {
58                    output.push(',');
59                }
60                write_key(&key, output);
61                output.push(':');
62                write_canonical_snbt(value, output)?;
63            }
64            output.push('}');
65        }
66        NbtTag::IntArray(values) => {
67            output.push_str("[I;");
68            for (index, value) in values.iter().enumerate() {
69                if index != 0 {
70                    output.push(',');
71                }
72                write!(output, "{value}").ok()?;
73            }
74            output.push(']');
75        }
76        NbtTag::LongArray(values) => {
77            output.push_str("[L;");
78            for (index, value) in values.iter().enumerate() {
79                if index != 0 {
80                    output.push(',');
81                }
82                write!(output, "{value}L").ok()?;
83            }
84            output.push(']');
85        }
86    }
87    Some(())
88}
89
90fn write_key(value: &str, output: &mut String) {
91    let mut chars = value.chars();
92    let simple = !value.eq_ignore_ascii_case("true")
93        && !value.eq_ignore_ascii_case("false")
94        && chars.next().is_some_and(|character| {
95            character.is_ascii_alphabetic() || matches!(character, '.' | '_')
96        })
97        && chars.all(|character| {
98            character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '+' | '-')
99        });
100    if simple {
101        output.push_str(value);
102    } else {
103        quote_and_escape(value, output);
104    }
105}
106
107fn quote_and_escape(value: &str, output: &mut String) {
108    let quote = value
109        .chars()
110        .find_map(|character| match character {
111            '"' => Some('\''),
112            '\'' => Some('"'),
113            _ => None,
114        })
115        .unwrap_or('"');
116    output.push(quote);
117    for character in value.chars() {
118        match character {
119            '\\' => output.push_str("\\\\"),
120            character if character == quote => {
121                output.push('\\');
122                output.push(character);
123            }
124            '\u{0008}' => output.push_str("\\b"),
125            '\t' => output.push_str("\\t"),
126            '\n' => output.push_str("\\n"),
127            '\u{000c}' => output.push_str("\\f"),
128            '\r' => output.push_str("\\r"),
129            character if character < ' ' => {
130                let _ = write!(output, "\\x{:02x}", u32::from(character));
131            }
132            character => output.push(character),
133        }
134    }
135    output.push(quote);
136}
137
138fn java_float_string(value: f32) -> String {
139    java_floating_string(
140        value.is_sign_negative(),
141        value.is_nan(),
142        value.is_infinite(),
143        value == 0.0,
144        &format!("{:.8e}", value.abs()),
145        9,
146        |precision| format!("{:.*e}", precision, value.abs()),
147        |candidate| candidate.parse::<f32>().ok().map(f32::to_bits) == Some(value.abs().to_bits()),
148    )
149}
150
151fn java_double_string(value: f64) -> String {
152    java_floating_string(
153        value.is_sign_negative(),
154        value.is_nan(),
155        value.is_infinite(),
156        value == 0.0,
157        &format!("{:.16e}", value.abs()),
158        17,
159        |precision| format!("{:.*e}", precision, value.abs()),
160        |candidate| candidate.parse::<f64>().ok().map(f64::to_bits) == Some(value.abs().to_bits()),
161    )
162}
163
164#[expect(
165    clippy::too_many_arguments,
166    clippy::fn_params_excessive_bools,
167    reason = "shared Java float formatting parameters"
168)]
169fn java_floating_string(
170    negative: bool,
171    nan: bool,
172    infinite: bool,
173    zero: bool,
174    precise: &str,
175    max_digits: usize,
176    scientific: impl Fn(usize) -> String,
177    rounds_to_value: impl Fn(&str) -> bool,
178) -> String {
179    if nan {
180        return "NaN".to_owned();
181    }
182    if infinite {
183        return if negative { "-Infinity" } else { "Infinity" }.to_owned();
184    }
185    if zero {
186        return if negative { "-0.0" } else { "0.0" }.to_owned();
187    }
188
189    let Some((precise_mantissa, _)) = precise.split_once('e') else {
190        panic!("Rust scientific formatting omitted its exponent");
191    };
192    let precise_digits = precise_mantissa.replace('.', "");
193    let one_digit_is_exact = precise_digits[1..].bytes().all(|digit| digit == b'0');
194    let mut selected = None;
195    for length in 1..=max_digits {
196        let formatted = scientific(length - 1);
197        let Some((mantissa, exponent)) = formatted.split_once('e') else {
198            panic!("Rust scientific formatting omitted its exponent");
199        };
200        if !rounds_to_value(&formatted) {
201            continue;
202        }
203        let Ok(exponent) = exponent.parse::<i32>() else {
204            panic!("Rust scientific formatting emitted a non-decimal exponent");
205        };
206        let digits = mantissa.replace('.', "");
207        selected = Some((digits, exponent - length as i32 + 1));
208        if length >= 2 || one_digit_is_exact {
209            break;
210        }
211    }
212    let Some((mut digits, decimal_exponent)) = selected else {
213        panic!("full-precision Rust decimal did not round-trip");
214    };
215    while digits.ends_with('0') {
216        digits.pop();
217    }
218    let scientific_exponent = digits.len() as i32 + decimal_exponent - 1;
219    let mut output = if (-3..0).contains(&scientific_exponent) {
220        format!(
221            "0.{}{}",
222            "0".repeat((-scientific_exponent - 1) as usize),
223            digits
224        )
225    } else if (0..7).contains(&scientific_exponent) {
226        let decimal_position = (scientific_exponent + 1) as usize;
227        if decimal_position >= digits.len() {
228            format!(
229                "{}{}.0",
230                digits,
231                "0".repeat(decimal_position - digits.len())
232            )
233        } else {
234            format!(
235                "{}.{}",
236                &digits[..decimal_position],
237                &digits[decimal_position..]
238            )
239        }
240    } else {
241        let fraction = if digits.len() == 1 { "0" } else { &digits[1..] };
242        format!("{}.{}E{scientific_exponent}", &digits[..1], fraction)
243    };
244    if negative {
245        output.insert(0, '-');
246    }
247    output
248}