@@ -258,74 +258,108 @@ where
258258 T : DeserializeOwned ,
259259{
260260 let bytes = fs:: read ( file_path) ?;
261- let text = decode_json_text ( & bytes, file_path) ?;
261+ let text = decode_text ( & bytes)
262+ . map_err ( |detail| Error :: DecodeFile ( path_to_string ( file_path) , detail) ) ?;
262263 let obj: T = serde_json:: from_str ( & text) ?;
263264
264265 Ok ( obj)
265266}
266267
267- /// Detects the text encoding of `bytes` and returns it as
268- /// (code unit width in bytes, big endian, BOM length in bytes).
269- /// width: 1 for UTF-8, 2 for UTF-16, 4 for UTF-32
270- /// big_endian: true for BE, false for LE
271- /// bom length length of bom
268+ /// The Unicode transformation format of the detected encoding.
269+ #[ derive( Clone , Copy ) ]
270+ enum TextEncoding {
271+ /// UTF-8 (and plain ASCII) - 1 byte per code unit.
272+ Utf8 ,
273+ /// UTF-16 - 2 bytes per code unit.
274+ Utf16 ,
275+ /// UTF-32 - 4 bytes per code unit.
276+ Utf32 ,
277+ }
278+
279+ /// The text encoding detected from the leading bytes of a file.
280+ struct DetectedEncoding {
281+ /// The Unicode transformation format.
282+ text_encoding : TextEncoding ,
283+ /// True for big endian, false for little endian. Not meaningful for UTF-8.
284+ big_endian : bool ,
285+ /// Length of the BOM in bytes, 0 when the file has no BOM.
286+ bom_len : usize ,
287+ }
288+
289+ impl DetectedEncoding {
290+ const fn new ( text_encoding : TextEncoding , big_endian : bool , bom_len : usize ) -> Self {
291+ Self {
292+ text_encoding,
293+ big_endian,
294+ bom_len,
295+ }
296+ }
297+ }
298+
299+ /// Detects the text encoding of `bytes`.
300+ ///
301+ /// A BOM is a Unicode construct rather than a JSON one, so BOM detection here is
302+ /// format agnostic. The BOM-less fallback, however, assumes the document starts
303+ /// with an ASCII character - true for JSON, XML and most text config formats.
272304///
273305/// Wider BOMs must be tested first: the UTF-32LE BOM (FF FE 00 00) starts with
274306/// the UTF-16LE BOM (FF FE), so a shortest-first scan would mis-detect a
275307/// UTF-32LE file as UTF-16LE.
276- fn detect_json_encoding ( bytes : & [ u8 ] ) -> ( usize , bool , usize ) {
308+ fn detect_text_encoding ( bytes : & [ u8 ] ) -> DetectedEncoding {
277309 if bytes. starts_with ( & [ 0x00 , 0x00 , 0xFE , 0xFF ] ) {
278- ( 4 , true , 4 ) // UTF-32BE with BOM
310+ DetectedEncoding :: new ( TextEncoding :: Utf32 , true , 4 ) // UTF-32BE with BOM
279311 } else if bytes. starts_with ( & [ 0xFF , 0xFE , 0x00 , 0x00 ] ) {
280- ( 4 , false , 4 ) // UTF-32LE with BOM
312+ DetectedEncoding :: new ( TextEncoding :: Utf32 , false , 4 ) // UTF-32LE with BOM
281313 } else if bytes. starts_with ( & [ 0xEF , 0xBB , 0xBF ] ) {
282- ( 1 , false , 3 ) // UTF-8 with BOM
314+ DetectedEncoding :: new ( TextEncoding :: Utf8 , false , 3 ) // UTF-8 with BOM
283315 } else if bytes. starts_with ( & [ 0xFE , 0xFF ] ) {
284- ( 2 , true , 2 ) // UTF-16BE with BOM
316+ DetectedEncoding :: new ( TextEncoding :: Utf16 , true , 2 ) // UTF-16BE with BOM
285317 } else if bytes. starts_with ( & [ 0xFF , 0xFE ] ) {
286- ( 2 , false , 2 ) // UTF-16LE with BOM
318+ DetectedEncoding :: new ( TextEncoding :: Utf16 , false , 2 ) // UTF-16LE with BOM
287319 } else {
288- // No BOM. A JSON document always starts with an ASCII character (`[`,
289- // ` {`, `"`, a digit or whitespace), so the NUL padding around the first
290- // code unit identifies both the width and the byte order. The 4-byte
291- // patterns are checked first because they are a superset of the 2-byte
292- // ones.
320+ // No BOM. The document is expected to start with an ASCII character
321+ // (for JSON that is `[`, ` {`, `"`, a digit or whitespace), so the NUL
322+ // padding around the first code unit identifies both the width and the
323+ // byte order. The 4-byte patterns are checked first because they are a
324+ // superset of the 2-byte ones.
293325 let is_nul = |i : usize | bytes. get ( i) == Some ( & 0x00 ) ;
294326 let is_text = |i : usize | matches ! ( bytes. get( i) , Some ( b) if * b != 0x00 ) ;
295327
296328 if is_nul ( 0 ) && is_nul ( 1 ) && is_nul ( 2 ) && is_text ( 3 ) {
297- ( 4 , true , 0 ) // 00 00 00 xx -> UTF-32BE
329+ DetectedEncoding :: new ( TextEncoding :: Utf32 , true , 0 ) // 00 00 00 xx -> UTF-32BE
298330 } else if is_text ( 0 ) && is_nul ( 1 ) && is_nul ( 2 ) && is_nul ( 3 ) {
299- ( 4 , false , 0 ) // xx 00 00 00 -> UTF-32LE
331+ DetectedEncoding :: new ( TextEncoding :: Utf32 , false , 0 ) // xx 00 00 00 -> UTF-32LE
300332 } else if is_nul ( 0 ) && is_text ( 1 ) {
301- ( 2 , true , 0 ) // 00 xx -> UTF-16BE
333+ DetectedEncoding :: new ( TextEncoding :: Utf16 , true , 0 ) // 00 xx -> UTF-16BE
302334 } else if is_text ( 0 ) && is_nul ( 1 ) {
303- ( 2 , false , 0 ) // xx 00 -> UTF-16LE
335+ DetectedEncoding :: new ( TextEncoding :: Utf16 , false , 0 ) // xx 00 -> UTF-16LE
304336 } else {
305- ( 1 , false , 0 ) // anything else, including plain ASCII / UTF-8
337+ DetectedEncoding :: new ( TextEncoding :: Utf8 , false , 0 ) // anything else, including plain ASCII / UTF-8
306338 }
307339 }
308340}
309341
310342/// Decodes `bytes` into UTF-8 text using the encoding detected by
311- /// [`detect_json_encoding `], skipping the BOM when present.
343+ /// [`detect_text_encoding `], skipping the BOM when present.
312344/// UTF-8 input is borrowed as-is, so the common case does not allocate.
313- fn decode_json_text < ' a > ( bytes : & ' a [ u8 ] , file_path : & Path ) -> Result < Cow < ' a , str > > {
314- let decode_error = |detail : String | Error :: DecodeFile ( path_to_string ( file_path) , detail) ;
315-
316- let ( code_unit_len, big_endian, bom_len) = detect_json_encoding ( bytes) ;
317- let payload = & bytes[ bom_len..] ;
318-
319- match code_unit_len {
320- 1 => std:: str:: from_utf8 ( payload)
345+ ///
346+ /// On failure it returns only a description of what made the content
347+ /// undecodable; the caller attaches the source of the bytes.
348+ fn decode_text ( bytes : & [ u8 ] ) -> std:: result:: Result < Cow < ' _ , str > , String > {
349+ let encoding = detect_text_encoding ( bytes) ;
350+ let big_endian = encoding. big_endian ;
351+ let payload = & bytes[ encoding. bom_len ..] ;
352+
353+ match encoding. text_encoding {
354+ TextEncoding :: Utf8 => std:: str:: from_utf8 ( payload)
321355 . map ( Cow :: Borrowed )
322- . map_err ( |e| decode_error ( format ! ( "content is not valid UTF-8: {e}" ) ) ) ,
323- 2 => {
356+ . map_err ( |e| format ! ( "content is not valid UTF-8: {e}" ) ) ,
357+ TextEncoding :: Utf16 => {
324358 if !payload. len ( ) . is_multiple_of ( 2 ) {
325- return Err ( decode_error ( format ! (
359+ return Err ( format ! (
326360 "UTF-16 content is truncated: {} bytes is not a whole number of 16-bit code units" ,
327361 payload. len( )
328- ) ) ) ;
362+ ) ) ;
329363 }
330364
331365 let code_units = payload. chunks_exact ( 2 ) . map ( |chunk| {
@@ -343,14 +377,14 @@ fn decode_json_text<'a>(bytes: &'a [u8], file_path: &Path) -> Result<Cow<'a, str
343377 char:: decode_utf16 ( code_units)
344378 . collect :: < std:: result:: Result < String , _ > > ( )
345379 . map ( Cow :: Owned )
346- . map_err ( |e| decode_error ( format ! ( "UTF-16 content has an unpaired surrogate: {e}" ) ) )
380+ . map_err ( |e| format ! ( "UTF-16 content has an unpaired surrogate: {e}" ) )
347381 }
348- _ => {
382+ TextEncoding :: Utf32 => {
349383 if !payload. len ( ) . is_multiple_of ( 4 ) {
350- return Err ( decode_error ( format ! (
384+ return Err ( format ! (
351385 "UTF-32 content is truncated: {} bytes is not a whole number of 32-bit code units" ,
352386 payload. len( )
353- ) ) ) ;
387+ ) ) ;
354388 }
355389
356390 payload
@@ -363,12 +397,10 @@ fn decode_json_text<'a>(bytes: &'a [u8], file_path: &Path) -> Result<Cow<'a, str
363397 u32:: from_le_bytes ( unit)
364398 } ;
365399 char:: from_u32 ( scalar) . ok_or_else ( || {
366- decode_error ( format ! (
367- "UTF-32 content has an invalid scalar value: {scalar:#010X}"
368- ) )
400+ format ! ( "UTF-32 content has an invalid scalar value: {scalar:#010X}" )
369401 } )
370402 } )
371- . collect :: < Result < String > > ( )
403+ . collect :: < std :: result :: Result < String , String > > ( )
372404 . map ( Cow :: Owned )
373405 }
374406 }
0 commit comments