1use std::fmt::Write as _;
2
3use simdnbt::owned::NbtTag;
4
5use crate::nbt::nbt_list_values;
6
7#[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}