1use encoding_rs::{Encoding, UTF_16BE, UTF_16LE, UTF_8};
16
17pub struct DecodedFile {
19 pub content: String,
21 pub encoding: String,
23 pub bom: &'static str,
25 pub line_ending: &'static str,
27 pub had_decode_errors: bool,
29}
30
31pub fn decode_file(bytes: &[u8]) -> DecodedFile {
37 if bytes.starts_with(b"\xFF\xFE\x00\x00") {
41 return decode_utf32(&bytes[4..], false, "utf-32le", "UTF-32LE");
42 }
43 if bytes.starts_with(b"\x00\x00\xFE\xFF") {
44 return decode_utf32(&bytes[4..], true, "utf-32be", "UTF-32BE");
45 }
46
47 let (enc, bom, body): (&'static Encoding, &'static str, &[u8]) =
48 if bytes.starts_with(b"\xEF\xBB\xBF") {
49 (UTF_8, "utf-8", &bytes[3..])
50 } else if bytes.starts_with(b"\xFF\xFE") {
51 (UTF_16LE, "utf-16le", &bytes[2..])
52 } else if bytes.starts_with(b"\xFE\xFF") {
53 (UTF_16BE, "utf-16be", &bytes[2..])
54 } else if std::str::from_utf8(bytes).is_ok() {
55 (UTF_8, "none", bytes)
56 } else {
57 let mut det = chardetng::EncodingDetector::new();
58 det.feed(bytes, true);
59 (det.guess(None, false), "none", bytes)
63 };
64
65 let (cow, had_decode_errors) = enc.decode_without_bom_handling(body);
66 let content = cow.into_owned();
67 let line_ending = detect_line_ending(&content);
68
69 DecodedFile {
70 content,
71 encoding: enc.name().to_string(),
72 bom,
73 line_ending,
74 had_decode_errors,
75 }
76}
77
78fn detect_line_ending(s: &str) -> &'static str {
82 match s.find('\n') {
83 Some(i) if i > 0 && s.as_bytes()[i - 1] == b'\r' => "crlf",
84 _ => "lf",
85 }
86}
87
88fn decode_utf32(body: &[u8], big_endian: bool, bom: &'static str, label: &'static str) -> DecodedFile {
90 let mut content = String::new();
91 let mut had_decode_errors = false;
92 for chunk in body.chunks(4) {
93 if chunk.len() < 4 {
94 had_decode_errors = true; break;
96 }
97 let cp = if big_endian {
98 u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
99 } else {
100 u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
101 };
102 match char::from_u32(cp) {
103 Some(c) => content.push(c),
104 None => {
105 content.push('\u{FFFD}');
106 had_decode_errors = true;
107 }
108 }
109 }
110 let line_ending = detect_line_ending(&content);
111 DecodedFile {
112 content,
113 encoding: label.to_string(),
114 bom,
115 line_ending,
116 had_decode_errors,
117 }
118}
119
120fn utf16_bytes(s: &str, big_endian: bool) -> Vec<u8> {
121 let mut v = Vec::with_capacity(s.len() * 2);
122 for u in s.encode_utf16() {
123 v.extend_from_slice(&if big_endian { u.to_be_bytes() } else { u.to_le_bytes() });
124 }
125 v
126}
127
128fn utf32_bytes(s: &str, big_endian: bool) -> Vec<u8> {
129 let mut v = Vec::with_capacity(s.chars().count() * 4);
130 for c in s.chars() {
131 let u = c as u32;
132 v.extend_from_slice(&if big_endian { u.to_be_bytes() } else { u.to_le_bytes() });
133 }
134 v
135}
136
137pub fn encode_file(
144 content: &str,
145 encoding_label: &str,
146 bom: &str,
147 line_ending: &str,
148) -> (Vec<u8>, bool) {
149 let normalized = if line_ending == "crlf" {
152 content.replace("\r\n", "\n").replace('\n', "\r\n")
153 } else {
154 content.to_string()
155 };
156
157 let (mut out, had_unmappable): (Vec<u8>, bool) = match encoding_label.to_ascii_lowercase().as_str() {
163 "utf-16le" => (utf16_bytes(&normalized, false), false),
164 "utf-16be" => (utf16_bytes(&normalized, true), false),
165 "utf-32le" => (utf32_bytes(&normalized, false), false),
166 "utf-32be" => (utf32_bytes(&normalized, true), false),
167 other => {
168 let enc = if other.is_empty() {
169 UTF_8
170 } else {
171 Encoding::for_label(other.as_bytes()).unwrap_or(UTF_8)
172 };
173 let (bytes, _enc, had_unmappable) = enc.encode(&normalized);
174 (bytes.into_owned(), had_unmappable)
175 }
176 };
177
178 let bom_bytes: &[u8] = match bom {
180 "utf-8" => b"\xEF\xBB\xBF",
181 "utf-16le" => b"\xFF\xFE",
182 "utf-16be" => b"\xFE\xFF",
183 "utf-32le" => b"\xFF\xFE\x00\x00",
184 "utf-32be" => b"\x00\x00\xFE\xFF",
185 _ => b"",
186 };
187 if !bom_bytes.is_empty() {
188 let mut prefixed = Vec::with_capacity(bom_bytes.len() + out.len());
189 prefixed.extend_from_slice(bom_bytes);
190 prefixed.append(&mut out);
191 out = prefixed;
192 }
193
194 (out, had_unmappable)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn plain_ascii_is_utf8() {
203 let d = decode_file(b"hello = world\n");
204 assert_eq!(d.content, "hello = world\n");
205 assert_eq!(d.encoding, "UTF-8");
206 assert_eq!(d.bom, "none");
207 assert_eq!(d.line_ending, "lf");
208 assert!(!d.had_decode_errors);
209 }
210
211 #[test]
212 fn windows_1252_ini_decodes() {
213 let bytes = b"name = O\x92Brien caf\xE9\r\n";
215 assert!(std::str::from_utf8(bytes).is_err(), "precondition: not UTF-8");
216 let d = decode_file(bytes);
217 assert!(d.content.contains("O\u{2019}Brien"), "got {:?}", d.content);
218 assert!(d.content.contains("caf\u{e9}"));
219 assert_eq!(d.line_ending, "crlf");
220 assert_ne!(d.encoding, "UTF-8"); }
222
223 #[test]
224 fn utf8_bom_is_stripped_and_remembered() {
225 let d = decode_file(b"\xEF\xBB\xBFhi");
226 assert_eq!(d.content, "hi");
227 assert_eq!(d.bom, "utf-8");
228 assert_eq!(d.encoding, "UTF-8");
229 }
230
231 #[test]
232 fn utf16le_bom_decodes() {
233 let d = decode_file(b"\xFF\xFEH\x00i\x00");
235 assert_eq!(d.content, "Hi");
236 assert_eq!(d.bom, "utf-16le");
237 }
238
239 #[test]
240 fn windows_1252_round_trips() {
241 let original = "café — O\u{2019}Brien";
242 let (bytes, unmappable) = encode_file(original, "windows-1252", "none", "lf");
243 assert!(!unmappable);
244 assert!(std::str::from_utf8(&bytes).is_err(), "should be legacy bytes");
245 let back = decode_file(&bytes);
246 assert_eq!(back.content, original);
248 }
249
250 #[test]
251 fn utf16le_round_trips_with_bom() {
252 let (bytes, _) = encode_file("Hi", "utf-16le", "utf-16le", "lf");
253 assert_eq!(&bytes[..2], b"\xFF\xFE");
254 let back = decode_file(&bytes);
255 assert_eq!(back.content, "Hi");
256 assert_eq!(back.bom, "utf-16le");
257 }
258
259 #[test]
260 fn crlf_line_ending_applied_on_write() {
261 let (bytes, _) = encode_file("a\nb", "UTF-8", "none", "crlf");
262 assert_eq!(bytes, b"a\r\nb");
263 }
264
265 #[test]
266 fn unknown_label_falls_back_to_utf8() {
267 let (bytes, _) = encode_file("hi", "definitely-not-an-encoding", "none", "lf");
268 assert_eq!(bytes, b"hi");
269 }
270
271 #[test]
272 fn line_ending_uses_first_break_not_any_crlf() {
273 assert_eq!(decode_file(b"a\nb\r\nc\n").line_ending, "lf");
276 assert_eq!(decode_file(b"a\r\nb\nc").line_ending, "crlf");
277 assert_eq!(decode_file(b"no breaks").line_ending, "lf");
278 }
279
280 #[test]
281 fn utf32le_bom_decodes_not_as_utf16() {
282 let bytes = b"\xFF\xFE\x00\x00H\x00\x00\x00i\x00\x00\x00";
284 let d = decode_file(bytes);
285 assert_eq!(d.content, "Hi");
286 assert_eq!(d.bom, "utf-32le");
287 assert_eq!(d.encoding, "UTF-32LE");
288 }
289
290 #[test]
291 fn utf32_round_trips() {
292 let (bytes, _) = encode_file("Hi", "utf-32le", "utf-32le", "lf");
293 assert_eq!(&bytes[..4], b"\xFF\xFE\x00\x00");
294 let back = decode_file(&bytes);
295 assert_eq!(back.content, "Hi");
296 assert_eq!(back.bom, "utf-32le");
297 }
298
299 #[test]
300 fn unmappable_char_is_flagged_not_lossy() {
301 let (_bytes, had_unmappable) = encode_file("hi 😀", "windows-1252", "none", "lf");
304 assert!(had_unmappable, "emoji in cp1252 must flag unmappable");
305 let (_b, u8_unmappable) = encode_file("hi 😀", "UTF-8", "none", "lf");
307 assert!(!u8_unmappable);
308 }
309}