Skip to main content

sup_xml_core/
encoding.rs

1//! Encoding detection and transcoding to UTF-8.
2//!
3//! Tier 1 support: UTF-8, US-ASCII, ISO-8859-1, Windows-1252.  These cover the
4//! large majority of legacy Western XML documents.  Other encodings (UTF-16,
5//! Shift-JIS, etc.) are reported as [`Encoding::Other`] and produce a clear
6//! error if you try to transcode them — Tier 2/3 work to follow.
7//!
8//! # Why composable
9//!
10//! Detection and transcoding live behind small, separate functions so callers
11//! can use them however suits their pipeline:
12//!
13//! ```no_run
14//! # use sup_xml_core::encoding::{detect, transcode_to_utf8};
15//! # use sup_xml_core::{parse_bytes, ParseOptions};
16//! # let bytes: &[u8] = b"";
17//! // Auto-detect + transcode in one call, then parse the resulting UTF-8.
18//! let utf8 = transcode_to_utf8(bytes)?;
19//! let doc  = parse_bytes(&utf8, &ParseOptions::default())?;
20//! # Ok::<(), sup_xml_core::XmlError>(())
21//! ```
22//!
23//! For UTF-8 inputs the transcode step is **zero-copy** (returns `Cow::Borrowed`)
24//! and only adds ~100 bytes of detection work to the parse path.
25
26use std::borrow::Cow;
27
28use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
29
30/// A character encoding the parser may encounter.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Encoding {
33    /// UTF-8 — variable-length, the modern default.
34    Utf8,
35    /// US-ASCII — 7-bit subset of UTF-8.  Transcoding is a no-op.
36    Ascii,
37    /// ISO-8859-1, also known as Latin-1.  Byte X maps directly to U+00XX.
38    Latin1,
39    /// Windows-1252 (codepage 1252).  Identical to Latin-1 outside the
40    /// 0x80–0x9F range, which Windows-1252 uses for printable characters
41    /// (curly quotes, em dash, ellipsis, €, etc.).
42    Windows1252,
43    /// UTF-16 little-endian.  Detected via the FF FE BOM or an explicit
44    /// `encoding="UTF-16LE"` declaration.
45    Utf16Le,
46    /// UTF-16 big-endian.  Detected via the FE FF BOM or an explicit
47    /// `encoding="UTF-16BE"` declaration.
48    Utf16Be,
49    /// UTF-32 little-endian.  Detected via the FF FE 00 00 BOM, the XML
50    /// Appendix F autodetect signature 3C 00 00 00, or an explicit
51    /// `encoding="UTF-32LE"` / `encoding="UCS-4LE"` declaration.
52    Utf32Le,
53    /// UTF-32 big-endian.  Detected via the 00 00 FE FF BOM, the XML
54    /// Appendix F autodetect signature 00 00 00 3C, or an explicit
55    /// `encoding="UTF-32BE"` / `encoding="UCS-4BE"` declaration.
56    Utf32Be,
57    /// IBM037 (CCSID 37 / CP037), the EBCDIC US/Canada Latin code page.
58    /// Detected via the XML spec's Appendix F autodetection signature
59    /// `4C 6F A7 94` (= "<?xm" in EBCDIC) — which all IBM-family variants
60    /// share — or an explicit `encoding="IBM037"` declaration.
61    Ebcdic037,
62    /// IBM500 (CCSID 500), International EBCDIC.  Same control-character
63    /// layout as IBM037 with seven ASCII-region punctuation positions
64    /// rearranged.  Detected via the IBM037 autodetect signature plus an
65    /// explicit `encoding="IBM500"` declaration.
66    Ebcdic500,
67    /// IBM1047 (CCSID 1047), EBCDIC Open Systems / z/OS Unix Services
68    /// Latin-1.  Differs from IBM037 in the IBM500 ASCII-region punctuation
69    /// plus the LF/NEL swap that makes EBCDIC text behave under Unix
70    /// line-handling code.
71    Ebcdic1047,
72    /// IBM1140 (CCSID 1140), EBCDIC US/Canada Latin with the Euro sign
73    /// update.  Identical to IBM037 except byte 0x9F maps to U+20AC (€)
74    /// instead of U+00A4 (¤).
75    Ebcdic1140,
76    /// A recognized encoding name we do not yet know how to transcode.
77    /// Stored as the name as written in the document's XML declaration.
78    Other(String),
79}
80
81impl Encoding {
82    /// Canonical name used in XML declarations.
83    pub fn name(&self) -> &str {
84        match self {
85            Encoding::Utf8        => "UTF-8",
86            Encoding::Ascii       => "US-ASCII",
87            Encoding::Latin1      => "ISO-8859-1",
88            Encoding::Windows1252 => "windows-1252",
89            Encoding::Utf16Le     => "UTF-16LE",
90            Encoding::Utf16Be     => "UTF-16BE",
91            Encoding::Utf32Le     => "UTF-32LE",
92            Encoding::Utf32Be     => "UTF-32BE",
93            Encoding::Ebcdic037   => "IBM037",
94            Encoding::Ebcdic500   => "IBM500",
95            Encoding::Ebcdic1047  => "IBM1047",
96            Encoding::Ebcdic1140  => "IBM1140",
97            Encoding::Other(s)    => s,
98        }
99    }
100}
101
102// ── detection ─────────────────────────────────────────────────────────────────
103
104/// Sniff the encoding of an XML document from its first bytes.
105///
106/// The algorithm:
107/// 1. If a BOM is present, use it (currently only UTF-8 BOM is handled in
108///    Tier 1; UTF-16 BOMs return [`Encoding::Other`] until we add UTF-16).
109/// 2. Otherwise, look for a `<?xml ... encoding="..."?>` declaration in the
110///    first ~200 bytes.  The XML spec guarantees the declaration is in an
111///    ASCII-compatible form for every encoding the spec mentions, so this
112///    lookahead works without knowing the encoding yet.
113/// 3. If neither is found, default to UTF-8.
114pub fn detect(bytes: &[u8]) -> Encoding {
115    // 1. BOMs.
116    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
117        return Encoding::Utf8;
118    }
119    // UTF-32 BOMs must be checked before UTF-16 BOMs — the UTF-32LE BOM
120    // `FF FE 00 00` starts with the UTF-16LE BOM bytes.
121    if bytes.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
122        return Encoding::Utf32Be;
123    }
124    if bytes.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) {
125        return Encoding::Utf32Le;
126    }
127    if bytes.starts_with(&[0xFE, 0xFF]) {
128        return Encoding::Utf16Be;
129    }
130    if bytes.starts_with(&[0xFF, 0xFE]) {
131        return Encoding::Utf16Le;
132    }
133
134    // 2a. XML 1.0 Appendix F auto-detection for EBCDIC.
135    //
136    // EBCDIC documents begin with `<?xm`, which is byte sequence
137    // `4C 6F A7 94` in every IBM-family EBCDIC code page we support
138    // (037, 500, 1047, 1140 all share the same Latin-letter positions).
139    // No other Tier 1/2/3 encoding produces this pattern at the document
140    // start.
141    //
142    // Once the signature matches we need to pick the *variant*.  Because
143    // all four variants encode the characters used in an XML declaration
144    // (`<?xml ... encoding="..." ?>`) the same way, we tentatively
145    // transcode the head with IBM037 and read the actual variant out of
146    // the resulting UTF-8 declaration.  Documents with no declaration —
147    // or a declaration naming something non-EBCDIC — fall back to
148    // IBM037, the most common variant.
149    if bytes.starts_with(&[0x4C, 0x6F, 0xA7, 0x94]) {
150        let head_len = bytes.len().min(200);
151        let head_utf8 = transcode_single_byte(&bytes[..head_len], &IBM037_TO_UTF8);
152        if let Some(name) = read_xml_decl_encoding(&head_utf8) {
153            let parsed = encoding_from_name(&name);
154            if matches!(parsed,
155                Encoding::Ebcdic037 | Encoding::Ebcdic500
156                | Encoding::Ebcdic1047 | Encoding::Ebcdic1140)
157            {
158                return parsed;
159            }
160        }
161        return Encoding::Ebcdic037;
162    }
163
164    // 2b. XML 1.0 Appendix F auto-detection for UTF-32 / UTF-16 without a BOM.
165    //
166    // Every well-formed XML document must begin with `<` (byte 0x3C) — either
167    // the start of `<?xml`, a comment `<!--`, a PI `<?`, or the root element.
168    // In UTF-32 the opening `<` gives a unique 4-byte signature with three
169    // NUL bytes; in UTF-16 the `<` plus the next ASCII char gives:
170    //   UTF-32BE: 00 00 00 3C
171    //   UTF-32LE: 3C 00 00 00
172    //   UTF-16BE: 00 3C 00 X   (where X is the ASCII byte after `<`)
173    //   UTF-16LE: 3C 00 X 00
174    // The UTF-16 patterns require byte[1]=0x3C or byte[2] non-zero, so they
175    // don't overlap with UTF-32 — but check UTF-32 first to make intent clear.
176    if bytes.len() >= 4 {
177        if bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0x00 && bytes[3] == 0x3C {
178            return Encoding::Utf32Be;
179        }
180        if bytes[0] == 0x3C && bytes[1] == 0x00 && bytes[2] == 0x00 && bytes[3] == 0x00 {
181            return Encoding::Utf32Le;
182        }
183        if bytes[0] == 0x00 && bytes[1] == 0x3C && bytes[2] == 0x00 && bytes[3] != 0x00 {
184            return Encoding::Utf16Be;
185        }
186        if bytes[0] == 0x3C && bytes[1] == 0x00 && bytes[2] != 0x00 && bytes[3] == 0x00 {
187            return Encoding::Utf16Le;
188        }
189    }
190
191    // 3. XML declaration (works for any ASCII-compatible byte encoding).
192    let head = &bytes[..bytes.len().min(200)];
193    if let Some(name) = read_xml_decl_encoding(head) {
194        return encoding_from_name(&name);
195    }
196
197    // 4. Default.
198    Encoding::Utf8
199}
200
201/// Map an encoding name (case-insensitive, with common aliases) onto an
202/// [`Encoding`] variant.  Unknown names become [`Encoding::Other`].
203pub fn encoding_from_name(name: &str) -> Encoding {
204    let lower = name.to_ascii_lowercase();
205    match lower.as_str() {
206        "utf-8" | "utf8"                              => Encoding::Utf8,
207        "us-ascii" | "ascii" | "ansi_x3.4-1968"       => Encoding::Ascii,
208        "iso-8859-1" | "iso_8859-1" | "latin1" | "latin-1" | "l1" | "8859-1"
209                                                      => Encoding::Latin1,
210        "windows-1252" | "cp1252" | "cp-1252" | "1252"
211                                                      => Encoding::Windows1252,
212        "utf-16le" | "utf16le" | "utf-16 le"          => Encoding::Utf16Le,
213        "utf-16be" | "utf16be" | "utf-16 be"          => Encoding::Utf16Be,
214        "utf-32le" | "utf32le" | "utf-32 le" | "ucs-4le" | "ucs4le" | "ucs-4-le"
215                                                      => Encoding::Utf32Le,
216        "utf-32be" | "utf32be" | "utf-32 be" | "ucs-4be" | "ucs4be" | "ucs-4-be"
217                                                      => Encoding::Utf32Be,
218        "ibm037" | "ibm-037" | "cp037" | "cp-037"
219            | "037" | "csibm037"
220            | "ebcdic-cp-us" | "ebcdic-cp-ca"
221            | "ebcdic-cp-wt" | "ebcdic-cp-nl"         => Encoding::Ebcdic037,
222        "ibm500" | "ibm-500" | "cp500" | "cp-500"
223            | "500" | "csibm500"
224            | "ebcdic-cp-be" | "ebcdic-cp-ch"         => Encoding::Ebcdic500,
225        "ibm1047" | "ibm-1047" | "cp1047" | "cp-1047"
226            | "1047" | "csibm1047"                    => Encoding::Ebcdic1047,
227        "ibm1140" | "ibm-1140" | "cp1140" | "cp-1140"
228            | "1140" | "csibm1140"
229            | "ibm01140" | "cp01140"
230            | "ebcdic-us-37+euro"                     => Encoding::Ebcdic1140,
231        // Generic "UTF-16" without an explicit endianness MUST come with a
232        // BOM per the XML spec; if a doc says encoding="UTF-16" but has no
233        // BOM we can't decode it, so we route this through Other and let the
234        // transcoder report a clear error.
235        _                                             => Encoding::Other(name.to_string()),
236    }
237}
238
239/// Return the encoding name as written in the document's `<?xml ...
240/// encoding="X"?>` declaration, if present.  Works on the raw bytes
241/// before any transcoding, so it reports the name a consumer-supplied
242/// converter should key on (e.g. `"EUC-JP"`), unnormalized.
243pub fn declared_encoding_name(bytes: &[u8]) -> Option<String> {
244    read_xml_decl_encoding(bytes)
245}
246
247/// Extract the `encoding="..."` value from an `<?xml ...?>` declaration if
248/// present, otherwise `None`.  Bytes-level so it works regardless of the
249/// document's actual encoding.
250fn read_xml_decl_encoding(head: &[u8]) -> Option<String> {
251    let start = find_bytes(head, b"<?xml")?;
252    let after = &head[start + 5..];
253    // First char after `<?xml` must be whitespace per the XML spec.
254    if !matches!(after.first()?, b' ' | b'\t' | b'\r' | b'\n') {
255        return None;
256    }
257    // Now find the `encoding` keyword somewhere inside the declaration.
258    let end = find_bytes(after, b"?>").unwrap_or(after.len());
259    let decl = &after[..end];
260    let kw = find_bytes(decl, b"encoding")?;
261    let mut i = kw + 8;
262    while i < decl.len() && matches!(decl[i], b' ' | b'\t' | b'\r' | b'\n') { i += 1; }
263    if i >= decl.len() || decl[i] != b'=' { return None; }
264    i += 1;
265    while i < decl.len() && matches!(decl[i], b' ' | b'\t' | b'\r' | b'\n') { i += 1; }
266    let quote = match decl.get(i)? {
267        b'"' | b'\'' => decl[i],
268        _ => return None,
269    };
270    i += 1;
271    let val_start = i;
272    while i < decl.len() && decl[i] != quote { i += 1; }
273    if i >= decl.len() { return None; }
274    // Encoding names are always ASCII per XML spec, so from_utf8 is safe here
275    // even for non-UTF-8 documents.
276    std::str::from_utf8(&decl[val_start..i]).ok().map(|s| s.to_string())
277}
278
279fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
280    if needle.is_empty() || haystack.len() < needle.len() { return None; }
281    haystack.windows(needle.len()).position(|w| w == needle)
282}
283
284// ── transcoding ───────────────────────────────────────────────────────────────
285
286/// Detect the encoding of `bytes` and transcode them into UTF-8.
287///
288/// For UTF-8 / US-ASCII input the result is [`Cow::Borrowed`] (zero copy).
289/// For Latin-1 / Windows-1252 it is [`Cow::Owned`] — these encodings produce
290/// UTF-8 about 1.0–1.5× the input size.
291///
292/// **Does not verify** that the inner `<?xml ... encoding="X" ?>` declaration
293/// agrees with the encoding detected from the bytes — for that, use
294/// [`transcode_to_utf8_strict`].
295pub fn transcode_to_utf8(bytes: &[u8]) -> Result<Cow<'_, [u8]>> {
296    let enc = detect(bytes);
297    transcode_to_utf8_as(bytes, enc)
298}
299
300/// Like [`transcode_to_utf8`] but **also verifies** that the inner XML
301/// declaration's `encoding="X"` attribute agrees with the encoding detected
302/// from the byte stream.  Catches malformed documents like a UTF-8 BOM
303/// paired with `encoding="ISO-8859-1"` or a UTF-16 BOM paired with
304/// `encoding="UTF-8"`.
305///
306/// Use this when consuming untrusted XML from external sources.  Synthetic
307/// re-encoded fixtures (e.g. a UTF-8 doc re-encoded to UTF-16 keeping its
308/// `encoding="UTF-8"` declaration) will fail this check — use the lenient
309/// [`transcode_to_utf8`] for those.
310pub fn transcode_to_utf8_strict(bytes: &[u8]) -> Result<Cow<'_, [u8]>> {
311    let enc = detect(bytes);
312    let transcoded = transcode_to_utf8_as(bytes, enc.clone())?;
313    verify_declaration_matches(&enc, &transcoded)?;
314    Ok(transcoded)
315}
316
317/// Read the inner `<?xml ... encoding="X" ?>` declaration from already-UTF-8
318/// `transcoded` bytes and verify that `X` is consistent with `detected`.
319fn verify_declaration_matches(detected: &Encoding, transcoded: &[u8]) -> Result<()> {
320    let head = &transcoded[..transcoded.len().min(200)];
321    let declared = match read_xml_decl_encoding(head) {
322        Some(s) => s,
323        None    => return Ok(()), // no declaration to check
324    };
325    if encodings_match(detected, &declared) {
326        return Ok(());
327    }
328    Err(XmlError::new(
329        ErrorDomain::Encoding,
330        ErrorLevel::Fatal,
331        format!(
332            "encoding declaration {declared:?} contradicts the encoding detected \
333             from the byte stream ({})",
334            detected.name(),
335        ),
336    ))
337}
338
339/// Whether the encoding `detected` from the bytes and the encoding name in
340/// the XML declaration mean the same thing.
341fn encodings_match(detected: &Encoding, declared: &str) -> bool {
342    let parsed = encoding_from_name(declared);
343    match (detected, &parsed) {
344        (a, b) if a == b => true,
345        // ASCII bytes can validly be labelled UTF-8 (and vice versa).
346        (Encoding::Utf8,  Encoding::Ascii) | (Encoding::Ascii, Encoding::Utf8) => true,
347        // Generic "UTF-16" (without endianness suffix) parses as `Other`;
348        // accept it as a match for either detected endianness.
349        (Encoding::Utf16Le | Encoding::Utf16Be, Encoding::Other(s))
350            if s.eq_ignore_ascii_case("UTF-16") || s.eq_ignore_ascii_case("UTF16") => true,
351        // Generic "UTF-32" / "UCS-4" (without endianness suffix) parses as
352        // `Other`; accept it as a match for either detected endianness.
353        (Encoding::Utf32Le | Encoding::Utf32Be, Encoding::Other(s))
354            if s.eq_ignore_ascii_case("UTF-32") || s.eq_ignore_ascii_case("UTF32")
355                || s.eq_ignore_ascii_case("UCS-4") || s.eq_ignore_ascii_case("UCS4") => true,
356        // Unknown labels — compare by name, case-insensitive.
357        (Encoding::Other(a), Encoding::Other(b)) if a.eq_ignore_ascii_case(b) => true,
358        _ => false,
359    }
360}
361
362/// Transcode `bytes` into UTF-8 assuming they are in the given `encoding`.
363///
364/// Use this when you already know the encoding (e.g. from an HTTP
365/// `Content-Type` header) and want to skip detection.  Returns an error for
366/// encodings not yet supported (currently anything beyond Tier 1).
367pub fn transcode_to_utf8_as(bytes: &[u8], encoding: Encoding) -> Result<Cow<'_, [u8]>> {
368    match encoding {
369        Encoding::Utf8 | Encoding::Ascii => Ok(Cow::Borrowed(strip_bom(bytes))),
370        Encoding::Latin1                 => Ok(Cow::Owned(transcode_latin1(strip_bom(bytes)))),
371        Encoding::Windows1252            => Ok(Cow::Owned(transcode_windows1252(strip_bom(bytes)))),
372        Encoding::Utf16Le                => Ok(Cow::Owned(transcode_utf16(strip_utf16_bom(bytes, false), false)?)),
373        Encoding::Utf16Be                => Ok(Cow::Owned(transcode_utf16(strip_utf16_bom(bytes, true), true)?)),
374        Encoding::Utf32Le                => Ok(Cow::Owned(transcode_utf32(strip_utf32_bom(bytes, false), false)?)),
375        Encoding::Utf32Be                => Ok(Cow::Owned(transcode_utf32(strip_utf32_bom(bytes, true), true)?)),
376        Encoding::Ebcdic037              => Ok(Cow::Owned(transcode_single_byte(bytes, &IBM037_TO_UTF8))),
377        Encoding::Ebcdic500              => Ok(Cow::Owned(transcode_single_byte(bytes, &IBM500_TO_UTF8))),
378        Encoding::Ebcdic1047             => Ok(Cow::Owned(transcode_single_byte(bytes, &IBM1047_TO_UTF8))),
379        Encoding::Ebcdic1140             => Ok(Cow::Owned(transcode_single_byte(bytes, &IBM1140_TO_UTF8))),
380        Encoding::Other(name)            => Ok(Cow::Owned(transcode_other(bytes, &name)?)),
381    }
382}
383
384/// IBM037 (EBCDIC US/Canada Latin) to Unicode mapping, one entry per byte.
385///
386/// Standard CCSID 37 / CP037 table.  Printable chars in the ASCII range and
387/// extended Latin-1 letters live in the 0x40–0xFE range; 0x00–0x3F are the
388/// EBCDIC control-character positions (NUL, SOH, etc.).
389///
390/// Exposed publicly so external tooling (test fixture generators, benchmark
391/// harnesses) can build the inverse map for UTF-8 → IBM037 round-tripping.
392pub const IBM037_TO_UNICODE: [u16; 256] = [
393    // 0x00-0x0F: NUL SOH STX ETX SEL HT  RNL DEL GE  SPS RPT VT  FF  CR  SO  SI
394    0x0000, 0x0001, 0x0002, 0x0003, 0x009C, 0x0009, 0x0086, 0x007F,
395    0x0097, 0x008D, 0x008E, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
396    // 0x10-0x1F: DLE DC1 DC2 DC3 RES NEL BS  POC CAN EM  UBS CU1 IFS IGS IRS IUS
397    0x0010, 0x0011, 0x0012, 0x0013, 0x009D, 0x0085, 0x0008, 0x0087,
398    0x0018, 0x0019, 0x0092, 0x008F, 0x001C, 0x001D, 0x001E, 0x001F,
399    // 0x20-0x2F: DS  SOS FS  WUS BYP LF  ETB ESC SA  SFE SM  CSP MFA ENQ ACK BEL
400    0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x000A, 0x0017, 0x001B,
401    0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x0005, 0x0006, 0x0007,
402    // 0x30-0x3F:                 SYN     PP  TRN NBS EOT SBS IT  RFF CU3 DC4 NAK     SUB
403    0x0090, 0x0091, 0x0016, 0x0093, 0x0094, 0x0095, 0x0096, 0x0004,
404    0x0098, 0x0099, 0x009A, 0x009B, 0x0014, 0x0015, 0x009E, 0x001A,
405    // 0x40-0x4F: SP   NBSP â     ä     à     á     ã     å     ç     ñ     ¢   .     <     (     +     |
406    0x0020, 0x00A0, 0x00E2, 0x00E4, 0x00E0, 0x00E1, 0x00E3, 0x00E5,
407    0x00E7, 0x00F1, 0x00A2, 0x002E, 0x003C, 0x0028, 0x002B, 0x007C,
408    // 0x50-0x5F: &    é     ê     ë     è     í     î     ï     ì     ß    !    $     *     )     ;     ¬
409    0x0026, 0x00E9, 0x00EA, 0x00EB, 0x00E8, 0x00ED, 0x00EE, 0x00EF,
410    0x00EC, 0x00DF, 0x0021, 0x0024, 0x002A, 0x0029, 0x003B, 0x00AC,
411    // 0x60-0x6F: -    /     Â     Ä     À     Á     Ã     Å     Ç     Ñ     ¦   ,     %     _     >     ?
412    0x002D, 0x002F, 0x00C2, 0x00C4, 0x00C0, 0x00C1, 0x00C3, 0x00C5,
413    0x00C7, 0x00D1, 0x00A6, 0x002C, 0x0025, 0x005F, 0x003E, 0x003F,
414    // 0x70-0x7F: ø    É     Ê     Ë     È     Í     Î     Ï     Ì     `    :    #     @     '     =     "
415    0x00F8, 0x00C9, 0x00CA, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF,
416    0x00CC, 0x0060, 0x003A, 0x0023, 0x0040, 0x0027, 0x003D, 0x0022,
417    // 0x80-0x8F: Ø    a     b     c     d     e     f     g     h     i     «    »     ð     ý     þ     ±
418    0x00D8, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
419    0x0068, 0x0069, 0x00AB, 0x00BB, 0x00F0, 0x00FD, 0x00FE, 0x00B1,
420    // 0x90-0x9F: °    j     k     l     m     n     o     p     q     r     ª    º     æ     ¸     Æ     ¤
421    0x00B0, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, 0x0070,
422    0x0071, 0x0072, 0x00AA, 0x00BA, 0x00E6, 0x00B8, 0x00C6, 0x00A4,
423    // 0xA0-0xAF: µ    ~     s     t     u     v     w     x     y     z     ¡    ¿     Ð     Ý     Þ     ®
424    0x00B5, 0x007E, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078,
425    0x0079, 0x007A, 0x00A1, 0x00BF, 0x00D0, 0x00DD, 0x00DE, 0x00AE,
426    // 0xB0-0xBF: ^    £     ¥     ·     ©     §     ¶     ¼     ½     ¾     [    ]     ¯     ¨     ´     ×
427    0x005E, 0x00A3, 0x00A5, 0x00B7, 0x00A9, 0x00A7, 0x00B6, 0x00BC,
428    0x00BD, 0x00BE, 0x005B, 0x005D, 0x00AF, 0x00A8, 0x00B4, 0x00D7,
429    // 0xC0-0xCF: {    A     B     C     D     E     F     G     H     I     SHY  ô     ö     ò     ó     õ
430    0x007B, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
431    0x0048, 0x0049, 0x00AD, 0x00F4, 0x00F6, 0x00F2, 0x00F3, 0x00F5,
432    // 0xD0-0xDF: }    J     K     L     M     N     O     P     Q     R     ¹    û     ü     ù     ú     ÿ
433    0x007D, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, 0x0050,
434    0x0051, 0x0052, 0x00B9, 0x00FB, 0x00FC, 0x00F9, 0x00FA, 0x00FF,
435    // 0xE0-0xEF: \    ÷     S     T     U     V     W     X     Y     Z     ²    Ô     Ö     Ò     Ó     Õ
436    0x005C, 0x00F7, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058,
437    0x0059, 0x005A, 0x00B2, 0x00D4, 0x00D6, 0x00D2, 0x00D3, 0x00D5,
438    // 0xF0-0xFF: 0    1     2     3     4     5     6     7     8     9     ³    Û     Ü     Ù     Ú     EO
439    0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
440    0x0038, 0x0039, 0x00B3, 0x00DB, 0x00DC, 0x00D9, 0x00DA, 0x009F,
441];
442
443/// IBM1140 (EBCDIC US/Canada Latin with Euro update) to Unicode mapping.
444///
445/// CCSID 1140 is CCSID 37 (IBM037) with **one** byte position updated to
446/// carry the Euro sign — adopted around 2000 when the EU adopted the euro.
447/// The change is at byte 0x9F: U+00A4 (currency symbol ¤) → U+20AC (€).
448///
449/// Reference: IBM CCSID 1140; Unicode Consortium mapping
450/// `MAPPINGS/VENDORS/MICSFT/EBCDIC/CP1140.TXT`.
451pub const IBM1140_TO_UNICODE: [u16; 256] = {
452    let mut t = IBM037_TO_UNICODE;
453    t[0x9F] = 0x20AC; // ¤ → € (Euro sign — CCSID 1140 update of CCSID 37)
454    t
455};
456
457/// IBM500 (International EBCDIC) to Unicode mapping.
458///
459/// CCSID 500 differs from CCSID 37 (IBM037) in exactly seven byte
460/// positions — the ASCII-region punctuation is rearranged so `[`, `]`,
461/// `!`, `^`, and `|` live where European national-variant EBCDIC pages
462/// historically placed them.  This is the "International" EBCDIC layout.
463///
464/// Reference: IBM CCSID 500; Unicode Consortium mapping
465/// `MAPPINGS/VENDORS/MICSFT/EBCDIC/CP500.TXT`.
466pub const IBM500_TO_UNICODE: [u16; 256] = {
467    let mut t = IBM037_TO_UNICODE;
468    // 7-byte delta vs IBM037:
469    t[0x4A] = 0x005B; // ¢ → [
470    t[0x4F] = 0x0021; // | → !
471    t[0x5A] = 0x005D; // ! → ]
472    t[0x5F] = 0x005E; // ¬ → ^
473    t[0xB0] = 0x00A2; // ^ → ¢
474    t[0xBA] = 0x00AC; // [ → ¬
475    t[0xBB] = 0x007C; // ] → |
476    t
477};
478
479/// IBM1047 (EBCDIC Open Systems / z/OS Unix Services Latin-1) to Unicode.
480///
481/// CCSID 1047 differs from CCSID 37 (IBM037) in two groups: it shares the
482/// IBM500 punctuation rearrangement (7 bytes), *plus* it swaps the LF and
483/// NEL code points at bytes 0x15 and 0x25 — the famous "z/OS Unix
484/// Services LF convention" that makes EBCDIC text behave correctly with
485/// Unix line-handling code.
486///
487/// Reference: IBM CCSID 1047; Unicode Consortium mapping
488/// `MAPPINGS/VENDORS/MICSFT/EBCDIC/CP1047.TXT`.
489pub const IBM1047_TO_UNICODE: [u16; 256] = {
490    let mut t = IBM037_TO_UNICODE;
491    // LF/NEL swap (z/OS Unix Services line-ending convention):
492    t[0x15] = 0x000A; // NEL → LF
493    t[0x25] = 0x0085; // LF  → NEL
494    // Same 7-byte ASCII-region rearrangement as IBM500:
495    t[0x4A] = 0x005B; // ¢ → [
496    t[0x4F] = 0x0021; // | → !
497    t[0x5A] = 0x005D; // ! → ]
498    t[0x5F] = 0x005E; // ¬ → ^
499    t[0xB0] = 0x00A2; // ^ → ¢
500    t[0xBA] = 0x00AC; // [ → ¬
501    t[0xBB] = 0x007C; // ] → |
502    t
503};
504
505
506/// Packed UTF-8 encoding for one byte of a single-byte legacy encoding.
507///
508/// Each entry is `[length, byte0, byte1, byte2]` — the first byte is the
509/// UTF-8 length (1, 2, or 3) and the next three are the UTF-8 bytes
510/// themselves, zero-padded for shorter sequences.  All EBCDIC variants we
511/// support emit BMP-only code points, so 3 bytes is the maximum (e.g., the
512/// Euro sign U+20AC in IBM1140 encodes to `E2 82 AC`).
513type SingleByteUtf8Table = [[u8; 4]; 256];
514
515/// Build the packed UTF-8 table from a byte-to-Unicode mapping.
516///
517/// Evaluated at compile time — each variant table costs zero runtime work
518/// to construct.
519const fn build_utf8_table(unicode_table: &[u16; 256]) -> SingleByteUtf8Table {
520    let mut t = [[0u8; 4]; 256];
521    let mut i = 0;
522    while i < 256 {
523        let cp = unicode_table[i] as u32;
524        if cp < 0x80 {
525            t[i] = [1, cp as u8, 0, 0];
526        } else if cp < 0x800 {
527            t[i] = [
528                2,
529                0xC0 | (cp >> 6) as u8,
530                0x80 | (cp & 0x3F) as u8,
531                0,
532            ];
533        } else {
534            t[i] = [
535                3,
536                0xE0 | (cp >> 12) as u8,
537                0x80 | ((cp >> 6) & 0x3F) as u8,
538                0x80 | (cp & 0x3F) as u8,
539            ];
540        }
541        i += 1;
542    }
543    t
544}
545
546const IBM037_TO_UTF8:  SingleByteUtf8Table = build_utf8_table(&IBM037_TO_UNICODE);
547const IBM500_TO_UTF8:  SingleByteUtf8Table = build_utf8_table(&IBM500_TO_UNICODE);
548const IBM1047_TO_UTF8: SingleByteUtf8Table = build_utf8_table(&IBM1047_TO_UNICODE);
549const IBM1140_TO_UTF8: SingleByteUtf8Table = build_utf8_table(&IBM1140_TO_UNICODE);
550
551/// Transcode bytes from a single-byte legacy encoding into UTF-8.
552///
553/// Hot loop is three unconditional memory writes + one branchless length
554/// update per input byte.  Each iteration writes 3 bytes (even when the
555/// UTF-8 sequence is shorter); `len` advances by the actual length stored
556/// in the table's first byte, so the unused bytes are overwritten by the
557/// next iteration.
558fn transcode_single_byte(bytes: &[u8], table: &SingleByteUtf8Table) -> Vec<u8> {
559    // Worst case: every input byte expands to 3 UTF-8 bytes (BMP triple-byte).
560    let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 3);
561    let ptr = out.as_mut_ptr();
562    let mut len = 0usize;
563
564    for &b in bytes {
565        let entry = table[b as usize];
566        // SAFETY: `out` has reserved capacity `bytes.len() * 3`.  At iteration
567        // start, `len ≤ 3 * (iterations_so_far)`, and we write at offsets
568        // `len`, `len + 1`, `len + 2` — the last is at most
569        // `3 * bytes.len() - 1`, within capacity.
570        unsafe {
571            ptr.add(len    ).write(entry[1]);
572            ptr.add(len + 1).write(entry[2]);
573            ptr.add(len + 2).write(entry[3]);
574        }
575        len += entry[0] as usize;
576    }
577
578    // SAFETY: `len` bytes have been initialized at offsets 0..len, and
579    // `len ≤ 3 * bytes.len()` which is the reserved capacity.
580    unsafe { out.set_len(len); }
581    out
582}
583
584/// Transcode an encoding we don't natively support in Tier 1/2.
585///
586/// With the `full-encodings` feature (default-on), this routes through the
587/// `encoding_rs` crate, which knows every encoding the WHATWG spec defines.
588/// Without the feature, this returns an `ErrorDomain::Encoding` error
589/// referencing the encoding name and telling the user how to enable support.
590#[cfg(feature = "full-encodings")]
591fn transcode_other(bytes: &[u8], name: &str) -> Result<Vec<u8>> {
592    let enc = encoding_rs::Encoding::for_label(name.as_bytes())
593        .ok_or_else(|| XmlError::new(
594            ErrorDomain::Encoding,
595            ErrorLevel::Fatal,
596            format!("encoding {name:?} is not recognized by encoding_rs"),
597        ))?;
598    // Decode without re-doing BOM handling — we've already done it.
599    let (decoded, _had_errors) = enc.decode_without_bom_handling(bytes);
600    // `decoded` is a Cow<str>; we want UTF-8 bytes out.  Invalid sequences
601    // become U+FFFD (Unicode replacement character), which is a legal XML
602    // character per XML 1.0 §2.2 (0xE000–0xFFFD), so the parser will accept
603    // them — surfacing the issue as data rather than a parse failure.
604    Ok(decoded.into_owned().into_bytes())
605}
606
607#[cfg(not(feature = "full-encodings"))]
608fn transcode_other(_bytes: &[u8], name: &str) -> Result<Vec<u8>> {
609    Err(XmlError::new(
610        ErrorDomain::Encoding,
611        ErrorLevel::Fatal,
612        format!(
613            "encoding {name:?} is not supported — rebuild sup-xml-core with \
614             the default 'full-encodings' feature enabled to pull in encoding_rs"
615        ),
616    ))
617}
618
619/// Strip a UTF-8 BOM (and only a UTF-8 BOM) from the front of `bytes`.
620fn strip_bom(bytes: &[u8]) -> &[u8] {
621    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { &bytes[3..] } else { bytes }
622}
623
624/// Strip a UTF-16 BOM matching the given endianness, if present.
625fn strip_utf16_bom(bytes: &[u8], big_endian: bool) -> &[u8] {
626    let bom: [u8; 2] = if big_endian { [0xFE, 0xFF] } else { [0xFF, 0xFE] };
627    if bytes.starts_with(&bom) { &bytes[2..] } else { bytes }
628}
629
630/// Strip a UTF-32 BOM matching the given endianness, if present.
631fn strip_utf32_bom(bytes: &[u8], big_endian: bool) -> &[u8] {
632    let bom: [u8; 4] = if big_endian {
633        [0x00, 0x00, 0xFE, 0xFF]
634    } else {
635        [0xFF, 0xFE, 0x00, 0x00]
636    };
637    if bytes.starts_with(&bom) { &bytes[4..] } else { bytes }
638}
639
640/// Transcode UTF-16 bytes into UTF-8, handling surrogate pairs.
641fn transcode_utf16(bytes: &[u8], big_endian: bool) -> Result<Vec<u8>> {
642    if bytes.len() % 2 != 0 {
643        return Err(XmlError::new(
644            ErrorDomain::Encoding,
645            ErrorLevel::Fatal,
646            "UTF-16 input length must be even",
647        ));
648    }
649    let decode_u16 = |i: usize| -> u16 {
650        if big_endian {
651            u16::from_be_bytes([bytes[i], bytes[i + 1]])
652        } else {
653            u16::from_le_bytes([bytes[i], bytes[i + 1]])
654        }
655    };
656
657    // UTF-16 → UTF-8 averages ~1.5× when input is Latin/ASCII heavy but can
658    // shrink for CJK (3 UTF-8 bytes for what was 2 UTF-16 bytes — already
659    // ~1.5×).  Either way, len() is a decent first-cut capacity.
660    let mut out = Vec::with_capacity(bytes.len());
661    let mut i = 0;
662    while i < bytes.len() {
663        let u = decode_u16(i);
664        i += 2;
665
666        let cp: u32 = if (0xD800..=0xDBFF).contains(&u) {
667            // High surrogate — must be followed by a low surrogate.
668            if i + 2 > bytes.len() {
669                return Err(XmlError::new(
670                    ErrorDomain::Encoding,
671                    ErrorLevel::Fatal,
672                    "lone UTF-16 high surrogate at end of input",
673                ));
674            }
675            let low = decode_u16(i);
676            if !(0xDC00..=0xDFFF).contains(&low) {
677                return Err(XmlError::new(
678                    ErrorDomain::Encoding,
679                    ErrorLevel::Fatal,
680                    format!("UTF-16 high surrogate U+{u:04X} not followed by low surrogate (got U+{low:04X})"),
681                ));
682            }
683            i += 2;
684            0x10000 + (((u as u32 - 0xD800) << 10) | (low as u32 - 0xDC00))
685        } else if (0xDC00..=0xDFFF).contains(&u) {
686            return Err(XmlError::new(
687                ErrorDomain::Encoding,
688                ErrorLevel::Fatal,
689                format!("lone UTF-16 low surrogate U+{u:04X}"),
690            ));
691        } else {
692            u as u32
693        };
694
695        encode_utf8_codepoint(cp, &mut out);
696    }
697    Ok(out)
698}
699
700/// Transcode UTF-32 bytes into UTF-8.
701///
702/// Each 4-byte chunk is a full Unicode scalar value — there are no surrogate
703/// pairs in UTF-32.  Validation rejects surrogates (U+D800..U+DFFF, which
704/// are not valid scalars) and code points above U+10FFFF (outside the
705/// Unicode range).
706fn transcode_utf32(bytes: &[u8], big_endian: bool) -> Result<Vec<u8>> {
707    if bytes.len() % 4 != 0 {
708        return Err(XmlError::new(
709            ErrorDomain::Encoding,
710            ErrorLevel::Fatal,
711            "UTF-32 input length must be a multiple of 4",
712        ));
713    }
714    let decode_u32 = |i: usize| -> u32 {
715        if big_endian {
716            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]])
717        } else {
718            u32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]])
719        }
720    };
721
722    // Worst case is 1 UTF-8 byte per UTF-32 byte (a non-BMP scalar encodes
723    // to 4 UTF-8 bytes from 4 UTF-32 bytes).  Typical text shrinks well
724    // below that — ASCII is 4× — but pre-sizing to bytes.len() avoids any
725    // reallocation.
726    let mut out = Vec::with_capacity(bytes.len());
727    let mut i = 0;
728    while i < bytes.len() {
729        let cp = decode_u32(i);
730        i += 4;
731
732        if cp > 0x10FFFF {
733            return Err(XmlError::new(
734                ErrorDomain::Encoding,
735                ErrorLevel::Fatal,
736                format!("UTF-32 code point U+{cp:08X} is outside the Unicode range"),
737            ));
738        }
739        if (0xD800..=0xDFFF).contains(&cp) {
740            return Err(XmlError::new(
741                ErrorDomain::Encoding,
742                ErrorLevel::Fatal,
743                format!("UTF-32 code point U+{cp:04X} is a surrogate (not a valid scalar)"),
744            ));
745        }
746        encode_utf8_codepoint(cp, &mut out);
747    }
748    Ok(out)
749}
750
751/// Transcode ISO-8859-1 (Latin-1) bytes into UTF-8.
752///
753/// Every byte X in the input is the Unicode scalar value U+00XX.  ASCII bytes
754/// (< 0x80) are passed through unchanged; 0x80–0xFF expand to 2-byte UTF-8.
755///
756/// # Implementation
757///
758/// SWAR (SIMD-within-a-register) scan in 8-byte chunks.  For each chunk we
759/// mask the high bits — if the result is zero, the whole chunk is ASCII and
760/// we bulk-copy it.  Otherwise the trailing-zeros count points us at the
761/// first non-ASCII byte, we copy the ASCII prefix, expand the one byte, and
762/// resume scanning from the byte after.  On mostly-ASCII XML this turns a
763/// per-byte branchy loop into a series of 8-byte memcpys.
764fn transcode_latin1(bytes: &[u8]) -> Vec<u8> {
765    const ASCII_MASK: u64 = 0x8080_8080_8080_8080;
766    // Worst case: every byte expands to 2 — pre-size for that to avoid reallocs.
767    let mut out = Vec::with_capacity(bytes.len() * 2);
768    let mut pos = 0;
769
770    while pos + 8 <= bytes.len() {
771        // SAFETY: pos + 8 <= bytes.len() guaranteed by the loop bound, and
772        // `try_into` on the slice gives us a [u8; 8] which has the right size.
773        let chunk = u64::from_le_bytes(bytes[pos..pos + 8].try_into().unwrap());
774        let hi = chunk & ASCII_MASK;
775        if hi == 0 {
776            // All 8 bytes are ASCII — bulk copy.
777            out.extend_from_slice(&bytes[pos..pos + 8]);
778            pos += 8;
779        } else {
780            // Position of the first high-bit byte within the chunk.
781            let off = (hi.trailing_zeros() / 8) as usize;
782            if off > 0 {
783                out.extend_from_slice(&bytes[pos..pos + off]);
784            }
785            let b = bytes[pos + off];
786            out.push(0xC0 | (b >> 6));
787            out.push(0x80 | (b & 0x3F));
788            pos += off + 1;
789        }
790    }
791
792    // Tail (< 8 bytes left) — fall back to the simple per-byte loop.
793    while pos < bytes.len() {
794        let b = bytes[pos];
795        if b < 0x80 {
796            out.push(b);
797        } else {
798            out.push(0xC0 | (b >> 6));
799            out.push(0x80 | (b & 0x3F));
800        }
801        pos += 1;
802    }
803
804    out
805}
806
807/// Mapping table for Windows-1252 code points in the 0x80–0x9F range.
808///
809/// Outside this range Windows-1252 == ISO-8859-1.  The five "undefined" slots
810/// (0x81, 0x8D, 0x8F, 0x90, 0x9D) are kept as their numeric value, which is
811/// what the WHATWG decoding spec and Python's `cp1252` codec both do.
812const WIN1252_HI: [u32; 32] = [
813    0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
814    0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F,
815    0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
816    0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
817];
818
819/// Transcode Windows-1252 bytes into UTF-8.
820///
821/// Same SWAR ASCII-run trick as [`transcode_latin1`].  Non-ASCII bytes split
822/// further: 0x80–0x9F go through the [`WIN1252_HI`] table (codepoint can be
823/// up to U+20AC → 3-byte UTF-8), while 0xA0–0xFF use the direct Latin-1
824/// expansion (always 2-byte UTF-8).
825fn transcode_windows1252(bytes: &[u8]) -> Vec<u8> {
826    const ASCII_MASK: u64 = 0x8080_8080_8080_8080;
827    // Worst case is 3 bytes out per byte in (for the curly-quote / euro range).
828    let mut out = Vec::with_capacity(bytes.len() * 2);
829    let mut pos = 0;
830
831    #[inline]
832    fn emit_non_ascii(out: &mut Vec<u8>, b: u8) {
833        if (0x80..0xA0).contains(&b) {
834            let cp = WIN1252_HI[(b - 0x80) as usize];
835            if cp < 0x80 {
836                out.push(cp as u8);
837            } else if cp < 0x800 {
838                out.push(0xC0 | (cp >> 6) as u8);
839                out.push(0x80 | (cp & 0x3F) as u8);
840            } else {
841                out.push(0xE0 | (cp >> 12) as u8);
842                out.push(0x80 | ((cp >> 6) & 0x3F) as u8);
843                out.push(0x80 | (cp & 0x3F) as u8);
844            }
845        } else {
846            out.push(0xC0 | (b >> 6));
847            out.push(0x80 | (b & 0x3F));
848        }
849    }
850
851    while pos + 8 <= bytes.len() {
852        let chunk = u64::from_le_bytes(bytes[pos..pos + 8].try_into().unwrap());
853        let hi = chunk & ASCII_MASK;
854        if hi == 0 {
855            out.extend_from_slice(&bytes[pos..pos + 8]);
856            pos += 8;
857        } else {
858            let off = (hi.trailing_zeros() / 8) as usize;
859            if off > 0 {
860                out.extend_from_slice(&bytes[pos..pos + off]);
861            }
862            emit_non_ascii(&mut out, bytes[pos + off]);
863            pos += off + 1;
864        }
865    }
866
867    while pos < bytes.len() {
868        let b = bytes[pos];
869        if b < 0x80 {
870            out.push(b);
871        } else {
872            emit_non_ascii(&mut out, b);
873        }
874        pos += 1;
875    }
876
877    out
878}
879
880/// Append the UTF-8 encoding of `cp` to `out`.  Generic for future encodings
881/// that produce code points outside the Tier 1 range.
882fn encode_utf8_codepoint(cp: u32, out: &mut Vec<u8>) {
883    if cp < 0x80 {
884        out.push(cp as u8);
885    } else if cp < 0x800 {
886        out.push(0xC0 | (cp >> 6) as u8);
887        out.push(0x80 | (cp & 0x3F) as u8);
888    } else if cp < 0x10000 {
889        out.push(0xE0 | (cp >> 12) as u8);
890        out.push(0x80 | ((cp >> 6) & 0x3F) as u8);
891        out.push(0x80 | (cp & 0x3F) as u8);
892    } else {
893        out.push(0xF0 | (cp >> 18) as u8);
894        out.push(0x80 | ((cp >> 12) & 0x3F) as u8);
895        out.push(0x80 | ((cp >> 6) & 0x3F) as u8);
896        out.push(0x80 | (cp & 0x3F) as u8);
897    }
898}
899
900// ── unit tests ────────────────────────────────────────────────────────────────
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    #[test]
907    fn detect_defaults_to_utf8() {
908        assert_eq!(detect(b"<r/>"), Encoding::Utf8);
909    }
910
911    #[test]
912    fn detect_utf8_bom() {
913        let bytes = [0xEF, 0xBB, 0xBF, b'<', b'r', b'/', b'>'];
914        assert_eq!(detect(&bytes), Encoding::Utf8);
915    }
916
917    #[test]
918    fn detect_utf16_bom() {
919        assert_eq!(detect(&[0xFE, 0xFF, 0, b'<']), Encoding::Utf16Be);
920        assert_eq!(detect(&[0xFF, 0xFE, b'<', 0]), Encoding::Utf16Le);
921    }
922
923    #[test]
924    fn detect_utf16_named_in_declaration() {
925        let be = br#"<?xml version="1.0" encoding="UTF-16BE"?><r/>"#;
926        let le = br#"<?xml version="1.0" encoding="UTF-16LE"?><r/>"#;
927        assert_eq!(detect(be), Encoding::Utf16Be);
928        assert_eq!(detect(le), Encoding::Utf16Le);
929    }
930
931    #[test]
932    fn detect_generic_utf16_no_bom_is_other() {
933        // "UTF-16" without endianness AND without a BOM is technically invalid;
934        // we surface it as Other and let transcode error cleanly.
935        let bytes = br#"<?xml version="1.0" encoding="UTF-16"?><r/>"#;
936        assert!(matches!(detect(bytes), Encoding::Other(s) if s == "UTF-16"));
937    }
938
939    #[test]
940    fn transcode_utf16_le_with_bom() {
941        // BOM 0xFF 0xFE, then ASCII chars as 2-byte little-endian sequences:
942        // '<' 'r' '/' '>' → 3C 00 72 00 2F 00 3E 00
943        let bytes: &[u8] = &[
944            0xFF, 0xFE,
945            0x3C, 0x00, 0x72, 0x00, 0x2F, 0x00, 0x3E, 0x00,
946        ];
947        let out = transcode_to_utf8(bytes).unwrap();
948        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
949    }
950
951    #[test]
952    fn transcode_utf16_be_with_bom() {
953        let bytes: &[u8] = &[
954            0xFE, 0xFF,
955            0x00, 0x3C, 0x00, 0x72, 0x00, 0x2F, 0x00, 0x3E,
956        ];
957        let out = transcode_to_utf8(bytes).unwrap();
958        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
959    }
960
961    #[test]
962    fn transcode_utf16_le_surrogate_pair() {
963        // U+1F600 GRINNING FACE — surrogate pair 0xD83D 0xDE00 in UTF-16.
964        // LE bytes: 3D D8 00 DE
965        let bytes: &[u8] = &[0xFF, 0xFE, 0x3D, 0xD8, 0x00, 0xDE];
966        let out = transcode_to_utf8(bytes).unwrap();
967        // UTF-8 of U+1F600: F0 9F 98 80
968        assert_eq!(&*out, &[0xF0, 0x9F, 0x98, 0x80]);
969        assert_eq!(std::str::from_utf8(&out).unwrap(), "😀");
970    }
971
972    #[test]
973    fn transcode_utf16_lone_high_surrogate_errors() {
974        // 0xD83D LE with no following code unit
975        let bytes: &[u8] = &[0xFF, 0xFE, 0x3D, 0xD8];
976        let err = transcode_to_utf8(bytes).unwrap_err();
977        assert!(err.message.contains("high surrogate"), "got: {:?}", err.message);
978    }
979
980    #[test]
981    fn transcode_utf16_lone_low_surrogate_errors() {
982        // 0xDE00 LE — low surrogate without a preceding high one
983        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0xDE];
984        let err = transcode_to_utf8(bytes).unwrap_err();
985        assert!(err.message.contains("low surrogate"), "got: {:?}", err.message);
986    }
987
988    #[test]
989    fn transcode_utf16_odd_length_errors() {
990        let bytes: &[u8] = &[0xFF, 0xFE, 0x3C];
991        let err = transcode_to_utf8(bytes).unwrap_err();
992        assert!(err.message.contains("even"), "got: {:?}", err.message);
993    }
994
995    #[test]
996    fn detect_utf16_be_without_bom() {
997        // "<?xml" in UTF-16BE → 00 3C 00 3F 00 78 ...
998        let bytes: &[u8] = &[0x00, 0x3C, 0x00, 0x3F, 0x00, 0x78];
999        assert_eq!(detect(bytes), Encoding::Utf16Be);
1000    }
1001
1002    #[test]
1003    fn detect_utf16_le_without_bom() {
1004        // "<?xml" in UTF-16LE → 3C 00 3F 00 78 00 ...
1005        let bytes: &[u8] = &[0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00];
1006        assert_eq!(detect(bytes), Encoding::Utf16Le);
1007    }
1008
1009    #[test]
1010    fn transcode_utf16_be_without_bom_resilient() {
1011        // Same as detect test but actually transcode end-to-end.  Bytes for
1012        // "<r/>" in UTF-16BE without BOM.
1013        let bytes: &[u8] = &[0x00, 0x3C, 0x00, 0x72, 0x00, 0x2F, 0x00, 0x3E];
1014        let out = transcode_to_utf8(bytes).unwrap();
1015        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
1016    }
1017
1018    #[test]
1019    fn detect_utf32_be_bom() {
1020        let bytes: &[u8] = &[0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, 0x3C];
1021        assert_eq!(detect(bytes), Encoding::Utf32Be);
1022    }
1023
1024    #[test]
1025    fn detect_utf32_le_bom() {
1026        // Note: BOM starts with FF FE, which is also the UTF-16LE BOM — the
1027        // ordering in detect() must check UTF-32LE first.
1028        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00];
1029        assert_eq!(detect(bytes), Encoding::Utf32Le);
1030    }
1031
1032    #[test]
1033    fn detect_utf32_be_without_bom() {
1034        // "<?" first chars in UTF-32BE: 00 00 00 3C 00 00 00 3F
1035        let bytes: &[u8] = &[0x00, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x3F];
1036        assert_eq!(detect(bytes), Encoding::Utf32Be);
1037    }
1038
1039    #[test]
1040    fn detect_utf32_le_without_bom() {
1041        // "<?" first chars in UTF-32LE: 3C 00 00 00 3F 00 00 00
1042        let bytes: &[u8] = &[0x3C, 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00];
1043        assert_eq!(detect(bytes), Encoding::Utf32Le);
1044    }
1045
1046    #[test]
1047    fn detect_utf32_named_in_declaration() {
1048        let be = br#"<?xml version="1.0" encoding="UTF-32BE"?><r/>"#;
1049        let le = br#"<?xml version="1.0" encoding="UTF-32LE"?><r/>"#;
1050        assert_eq!(detect(be), Encoding::Utf32Be);
1051        assert_eq!(detect(le), Encoding::Utf32Le);
1052    }
1053
1054    #[test]
1055    fn detect_ucs4_aliases() {
1056        let be = br#"<?xml version="1.0" encoding="UCS-4BE"?><r/>"#;
1057        let le = br#"<?xml version="1.0" encoding="UCS-4LE"?><r/>"#;
1058        assert_eq!(detect(be), Encoding::Utf32Be);
1059        assert_eq!(detect(le), Encoding::Utf32Le);
1060    }
1061
1062    #[test]
1063    fn detect_generic_utf32_no_bom_is_other() {
1064        // "UTF-32" without endianness AND without a BOM is invalid; surface
1065        // as Other and let transcode error cleanly.
1066        let bytes = br#"<?xml version="1.0" encoding="UTF-32"?><r/>"#;
1067        assert!(matches!(detect(bytes), Encoding::Other(s) if s == "UTF-32"));
1068    }
1069
1070    #[test]
1071    fn transcode_utf32_le_with_bom() {
1072        // BOM, then '<' 'r' '/' '>' each as a 4-byte LE scalar.
1073        let bytes: &[u8] = &[
1074            0xFF, 0xFE, 0x00, 0x00,
1075            0x3C, 0x00, 0x00, 0x00,
1076            0x72, 0x00, 0x00, 0x00,
1077            0x2F, 0x00, 0x00, 0x00,
1078            0x3E, 0x00, 0x00, 0x00,
1079        ];
1080        let out = transcode_to_utf8(bytes).unwrap();
1081        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
1082    }
1083
1084    #[test]
1085    fn transcode_utf32_be_with_bom() {
1086        let bytes: &[u8] = &[
1087            0x00, 0x00, 0xFE, 0xFF,
1088            0x00, 0x00, 0x00, 0x3C,
1089            0x00, 0x00, 0x00, 0x72,
1090            0x00, 0x00, 0x00, 0x2F,
1091            0x00, 0x00, 0x00, 0x3E,
1092        ];
1093        let out = transcode_to_utf8(bytes).unwrap();
1094        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
1095    }
1096
1097    #[test]
1098    fn transcode_utf32_be_without_bom_resilient() {
1099        // "<r/>" in UTF-32BE without BOM.
1100        let bytes: &[u8] = &[
1101            0x00, 0x00, 0x00, 0x3C,
1102            0x00, 0x00, 0x00, 0x72,
1103            0x00, 0x00, 0x00, 0x2F,
1104            0x00, 0x00, 0x00, 0x3E,
1105        ];
1106        let out = transcode_to_utf8(bytes).unwrap();
1107        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
1108    }
1109
1110    #[test]
1111    fn transcode_utf32_smp_codepoint() {
1112        // U+1F600 GRINNING FACE — UTF-32 stores it directly, no surrogates.
1113        // LE bytes: 00 F6 01 00
1114        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x00, 0xF6, 0x01, 0x00];
1115        let out = transcode_to_utf8(bytes).unwrap();
1116        // UTF-8 of U+1F600: F0 9F 98 80
1117        assert_eq!(&*out, &[0xF0, 0x9F, 0x98, 0x80]);
1118        assert_eq!(std::str::from_utf8(&out).unwrap(), "😀");
1119    }
1120
1121    #[test]
1122    fn transcode_utf32_surrogate_errors() {
1123        // 0x0000D83D is a high surrogate — invalid as a UTF-32 scalar.
1124        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x3D, 0xD8, 0x00, 0x00];
1125        let err = transcode_to_utf8(bytes).unwrap_err();
1126        assert!(err.message.contains("surrogate"), "got: {:?}", err.message);
1127    }
1128
1129    #[test]
1130    fn transcode_utf32_out_of_range_errors() {
1131        // 0x00110000 is one past U+10FFFF (the Unicode maximum).
1132        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x11, 0x00];
1133        let err = transcode_to_utf8(bytes).unwrap_err();
1134        assert!(err.message.contains("Unicode range"), "got: {:?}", err.message);
1135    }
1136
1137    #[test]
1138    fn transcode_utf32_misaligned_length_errors() {
1139        // 6 bytes after the BOM — not a multiple of 4.
1140        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00, 0x72, 0x00];
1141        let err = transcode_to_utf8(bytes).unwrap_err();
1142        assert!(err.message.contains("multiple of 4"), "got: {:?}", err.message);
1143    }
1144
1145    #[test]
1146    fn transcode_utf32_strips_bom_only_once() {
1147        // BOM, then '<' as LE.  Verify the BOM is consumed and we don't
1148        // emit a stray U+FEFF in the output.
1149        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x00, 0x3C, 0x00, 0x00, 0x00];
1150        let out = transcode_to_utf8(bytes).unwrap();
1151        assert_eq!(out.as_ref(), b"<");
1152    }
1153
1154    #[test]
1155    fn detect_ebcdic_signature() {
1156        // "<?xm" in IBM037 → 4C 6F A7 94
1157        let bytes: &[u8] = &[0x4C, 0x6F, 0xA7, 0x94];
1158        assert_eq!(detect(bytes), Encoding::Ebcdic037);
1159    }
1160
1161    #[test]
1162    fn detect_ebcdic_named() {
1163        let bytes = br#"<?xml version="1.0" encoding="IBM037"?><r/>"#;
1164        assert_eq!(detect(bytes), Encoding::Ebcdic037);
1165        let bytes = br#"<?xml version="1.0" encoding="CP037"?><r/>"#;
1166        assert_eq!(detect(bytes), Encoding::Ebcdic037);
1167        let bytes = br#"<?xml version="1.0" encoding="EBCDIC-CP-US"?><r/>"#;
1168        assert_eq!(detect(bytes), Encoding::Ebcdic037);
1169    }
1170
1171    #[test]
1172    fn transcode_ebcdic_minimal_via_explicit_encoding() {
1173        // "<r/>" in IBM037 alone has no autodetect signature (autodetection
1174        // requires `<?xml` → "4C 6F A7 94").  Use the `_as` variant when you
1175        // know the encoding rather than relying on detection.
1176        let bytes: &[u8] = &[0x4C, 0x99, 0x61, 0x6E];
1177        let out = transcode_to_utf8_as(bytes, Encoding::Ebcdic037).unwrap();
1178        assert_eq!(std::str::from_utf8(&out).unwrap(), "<r/>");
1179    }
1180
1181    #[test]
1182    fn transcode_ebcdic_xml_declaration() {
1183        // "<?xml version='1.0' encoding='IBM037'?><r>café</r>" in IBM037.
1184        //   '<' 4C   '?' 6F   'x' A7   'm' 94   'l' 93
1185        //   ' ' 40
1186        //   'v' A5   'e' 85   'r' 99   's' A2   'i' 89   'o' 96   'n' 95
1187        //   '=' 7E   '\'' 7D   '1' F1   '.' 4B   '0' F0   '\'' 7D
1188        //   ' ' 40
1189        //   'e' 85   'n' 95   'c' 83   'o' 96   'd' 84   'i' 89   'n' 95   'g' 87
1190        //   '=' 7E   '\'' 7D   'I' C9   'B' C2   'M' D4   '0' F0   '3' F3   '7' F7   '\'' 7D
1191        //   '?' 6F   '>' 6E
1192        //   '<' 4C   'r' 99   '>' 6E
1193        //   'c' 83   'a' 81   'f' 86   'é' 51   (note: 'é' in IBM037 is 0x51!)
1194        //   '<' 4C   '/' 61   'r' 99   '>' 6E
1195        let bytes: &[u8] = &[
1196            0x4C, 0x6F, 0xA7, 0x94, 0x93, 0x40,
1197            0xA5, 0x85, 0x99, 0xA2, 0x89, 0x96, 0x95, 0x7E, 0x7D, 0xF1, 0x4B, 0xF0, 0x7D, 0x40,
1198            0x85, 0x95, 0x83, 0x96, 0x84, 0x89, 0x95, 0x87, 0x7E, 0x7D,
1199            0xC9, 0xC2, 0xD4, 0xF0, 0xF3, 0xF7, 0x7D, 0x6F, 0x6E,
1200            0x4C, 0x99, 0x6E,
1201            0x83, 0x81, 0x86, 0x51,
1202            0x4C, 0x61, 0x99, 0x6E,
1203        ];
1204        let out = transcode_to_utf8(bytes).unwrap();
1205        let s   = std::str::from_utf8(&out).unwrap();
1206        assert!(s.contains("café"), "expected 'café' in output, got: {s:?}");
1207        assert!(s.contains("encoding='IBM037'"), "expected encoding decl preserved, got: {s:?}");
1208    }
1209
1210    // ── EBCDIC variants: IBM1140, IBM500, IBM1047 ────────────────────────────
1211
1212    #[test]
1213    fn ibm1140_differs_from_ibm037_at_byte_9f_only() {
1214        // CCSID 1140 is CCSID 37 with byte 0x9F updated to carry the Euro
1215        // sign.  Verify the table is *exactly* the IBM037 table with that
1216        // one change.
1217        for i in 0..256 {
1218            if i == 0x9F {
1219                assert_eq!(IBM1140_TO_UNICODE[i], 0x20AC,
1220                           "IBM1140 0x9F must map to Euro sign");
1221                assert_eq!(IBM037_TO_UNICODE[i], 0x00A4,
1222                           "IBM037 0x9F must remain currency sign ¤");
1223            } else {
1224                assert_eq!(IBM1140_TO_UNICODE[i], IBM037_TO_UNICODE[i],
1225                           "IBM1140 should match IBM037 at byte 0x{i:02X}");
1226            }
1227        }
1228    }
1229
1230    #[test]
1231    fn ibm500_seven_byte_punctuation_swap_from_ibm037() {
1232        // International EBCDIC moves [, ], !, ^, |, ¬, ¢ around.  Verify
1233        // each delta and that nothing else changed.
1234        let deltas: &[(u8, u16, u16)] = &[
1235            (0x4A, 0x00A2, 0x005B),  // ¢ → [
1236            (0x4F, 0x007C, 0x0021),  // | → !
1237            (0x5A, 0x0021, 0x005D),  // ! → ]
1238            (0x5F, 0x00AC, 0x005E),  // ¬ → ^
1239            (0xB0, 0x005E, 0x00A2),  // ^ → ¢
1240            (0xBA, 0x005B, 0x00AC),  // [ → ¬
1241            (0xBB, 0x005D, 0x007C),  // ] → |
1242        ];
1243        for &(byte, ibm037_cp, ibm500_cp) in deltas {
1244            assert_eq!(IBM037_TO_UNICODE[byte as usize], ibm037_cp);
1245            assert_eq!(IBM500_TO_UNICODE[byte as usize], ibm500_cp);
1246        }
1247        let changed: std::collections::HashSet<u8> = deltas.iter().map(|(b, _, _)| *b).collect();
1248        for i in 0..=255u8 {
1249            if !changed.contains(&i) {
1250                assert_eq!(IBM500_TO_UNICODE[i as usize], IBM037_TO_UNICODE[i as usize],
1251                           "IBM500 should match IBM037 at byte 0x{i:02X}");
1252            }
1253        }
1254    }
1255
1256    #[test]
1257    fn ibm1047_swaps_lf_and_nel_in_addition_to_ibm500_deltas() {
1258        // IBM1047 = IBM500 + LF/NEL swap.  Cross-check.
1259        assert_eq!(IBM1047_TO_UNICODE[0x15], 0x000A, "IBM1047 0x15 = LF");
1260        assert_eq!(IBM1047_TO_UNICODE[0x25], 0x0085, "IBM1047 0x25 = NEL");
1261        assert_eq!(IBM037_TO_UNICODE [0x15], 0x0085, "IBM037 0x15 = NEL");
1262        assert_eq!(IBM037_TO_UNICODE [0x25], 0x000A, "IBM037 0x25 = LF");
1263        // Punctuation rearrangement matches IBM500.
1264        assert_eq!(IBM1047_TO_UNICODE[0x4A], IBM500_TO_UNICODE[0x4A]);
1265        assert_eq!(IBM1047_TO_UNICODE[0xBB], IBM500_TO_UNICODE[0xBB]);
1266        // Outside the deltas, IBM1047 matches IBM037.
1267        assert_eq!(IBM1047_TO_UNICODE[0xC1], IBM037_TO_UNICODE[0xC1]); // 'A'
1268        assert_eq!(IBM1047_TO_UNICODE[0xF0], IBM037_TO_UNICODE[0xF0]); // '0'
1269    }
1270
1271    #[test]
1272    fn detect_ibm1140_via_declaration_after_ebcdic_signature() {
1273        // "<?xml version='1.0' encoding='IBM1140'?><r/>" in IBM1140.
1274        // IBM1140 encodes ASCII letters identically to IBM037, so we can
1275        // build the bytes by hand from IBM037 positions.
1276        let bytes: &[u8] = &[
1277            0x4C, 0x6F, 0xA7, 0x94, 0x93, 0x40, // <?xml SP
1278            0xA5, 0x85, 0x99, 0xA2, 0x89, 0x96, 0x95, 0x7E, 0x7D, // version='
1279            0xF1, 0x4B, 0xF0, 0x7D, 0x40, // 1.0' SP
1280            0x85, 0x95, 0x83, 0x96, 0x84, 0x89, 0x95, 0x87, 0x7E, 0x7D, // encoding='
1281            0xC9, 0xC2, 0xD4, 0xF1, 0xF1, 0xF4, 0xF0, 0x7D, // IBM1140'
1282            0x6F, 0x6E, // ?>
1283            0x4C, 0x99, 0x61, 0x6E, // <r/>
1284        ];
1285        assert_eq!(detect(bytes), Encoding::Ebcdic1140);
1286    }
1287
1288    #[test]
1289    fn detect_ibm500_via_declaration_after_ebcdic_signature() {
1290        let bytes: &[u8] = &[
1291            0x4C, 0x6F, 0xA7, 0x94, 0x93, 0x40, // <?xml SP
1292            0xA5, 0x85, 0x99, 0xA2, 0x89, 0x96, 0x95, 0x7E, 0x7D, // version='
1293            0xF1, 0x4B, 0xF0, 0x7D, 0x40,
1294            0x85, 0x95, 0x83, 0x96, 0x84, 0x89, 0x95, 0x87, 0x7E, 0x7D, // encoding='
1295            0xC9, 0xC2, 0xD4, 0xF5, 0xF0, 0xF0, 0x7D, // IBM500'
1296            0x6F, 0x6E,
1297            0x4C, 0x99, 0x61, 0x6E,
1298        ];
1299        assert_eq!(detect(bytes), Encoding::Ebcdic500);
1300    }
1301
1302    #[test]
1303    fn detect_ibm1047_via_declaration_after_ebcdic_signature() {
1304        let bytes: &[u8] = &[
1305            0x4C, 0x6F, 0xA7, 0x94, 0x93, 0x40,
1306            0xA5, 0x85, 0x99, 0xA2, 0x89, 0x96, 0x95, 0x7E, 0x7D,
1307            0xF1, 0x4B, 0xF0, 0x7D, 0x40,
1308            0x85, 0x95, 0x83, 0x96, 0x84, 0x89, 0x95, 0x87, 0x7E, 0x7D,
1309            0xC9, 0xC2, 0xD4, 0xF1, 0xF0, 0xF4, 0xF7, 0x7D, // IBM1047'
1310            0x6F, 0x6E,
1311            0x4C, 0x99, 0x61, 0x6E,
1312        ];
1313        assert_eq!(detect(bytes), Encoding::Ebcdic1047);
1314    }
1315
1316    #[test]
1317    fn ibm1140_euro_sign_round_trips() {
1318        // Single-element doc with a Euro sign.  IBM1140 byte 0x9F → €.
1319        let bytes: &[u8] = &[0x4C, 0x99, 0x6E, 0x9F, 0x4C, 0x61, 0x99, 0x6E];
1320        let out = transcode_to_utf8_as(bytes, Encoding::Ebcdic1140).unwrap();
1321        let s   = std::str::from_utf8(&out).unwrap();
1322        assert_eq!(s, "<r>€</r>", "got: {s:?}");
1323    }
1324
1325    #[test]
1326    fn ibm1140_byte_9f_is_currency_under_ibm037() {
1327        // Same input as the Euro test above, but decoded as plain IBM037 —
1328        // byte 0x9F should come out as ¤ (currency sign), proving the
1329        // variants really do differ at this position.
1330        let bytes: &[u8] = &[0x4C, 0x99, 0x6E, 0x9F, 0x4C, 0x61, 0x99, 0x6E];
1331        let out = transcode_to_utf8_as(bytes, Encoding::Ebcdic037).unwrap();
1332        let s   = std::str::from_utf8(&out).unwrap();
1333        assert_eq!(s, "<r>¤</r>", "got: {s:?}");
1334    }
1335
1336    #[test]
1337    fn ibm500_left_bracket_round_trips() {
1338        // Byte 0x4A in IBM500 is `[`.  Decode and confirm.
1339        let bytes: &[u8] = &[0x4A]; // '['
1340        let out = transcode_to_utf8_as(bytes, Encoding::Ebcdic500).unwrap();
1341        assert_eq!(&*out, b"[", "IBM500 0x4A must decode to '['");
1342        // Same byte under IBM037 is `¢`.
1343        let out2 = transcode_to_utf8_as(bytes, Encoding::Ebcdic037).unwrap();
1344        assert_eq!(std::str::from_utf8(&out2).unwrap(), "¢",
1345                   "IBM037 0x4A must decode to ¢");
1346    }
1347
1348    #[test]
1349    fn ibm1047_lf_and_punctuation_round_trip() {
1350        // 0x15 in IBM1047 is LF (U+000A).  Plus 0x4A = '[', 0xBB = '|'
1351        // (both inherited from the IBM500-style punctuation layout).
1352        let bytes: &[u8] = &[0x15, 0x4A, 0xBB];
1353        let out = transcode_to_utf8_as(bytes, Encoding::Ebcdic1047).unwrap();
1354        assert_eq!(&*out, b"\n[|", "IBM1047 line/bracket/pipe round-trip");
1355        // The same bytes under IBM037 give NEL + ¢ + ]
1356        let out2 = transcode_to_utf8_as(bytes, Encoding::Ebcdic037).unwrap();
1357        let s2 = std::str::from_utf8(&out2).unwrap();
1358        assert!(s2.starts_with("\u{0085}"), "IBM037 0x15 must be NEL (U+0085)");
1359        assert!(s2.contains('¢'),           "IBM037 0x4A must be ¢");
1360        assert!(s2.ends_with(']'),          "IBM037 0xBB must be ]");
1361    }
1362
1363    #[test]
1364    fn ibm1140_aliases_resolve() {
1365        // Various ways callers might spell IBM1140 in an XML declaration.
1366        for name in ["IBM1140", "ibm1140", "CP1140", "cp01140", "IBM01140",
1367                     "csibm1140", "ebcdic-us-37+euro"]
1368        {
1369            let bytes = format!("<?xml version=\"1.0\" encoding=\"{name}\"?><r/>");
1370            assert_eq!(detect(bytes.as_bytes()), Encoding::Ebcdic1140,
1371                       "{name} should resolve to Ebcdic1140");
1372        }
1373    }
1374
1375    #[test]
1376    fn detect_xml_decl_iso_8859_1() {
1377        let bytes = br#"<?xml version="1.0" encoding="ISO-8859-1"?><r/>"#;
1378        assert_eq!(detect(bytes), Encoding::Latin1);
1379    }
1380
1381    #[test]
1382    fn detect_xml_decl_windows_1252() {
1383        let bytes = br#"<?xml version="1.0" encoding="Windows-1252"?><r/>"#;
1384        assert_eq!(detect(bytes), Encoding::Windows1252);
1385    }
1386
1387    #[test]
1388    fn detect_xml_decl_us_ascii() {
1389        let bytes = br#"<?xml version="1.0" encoding="US-ASCII"?><r/>"#;
1390        assert_eq!(detect(bytes), Encoding::Ascii);
1391    }
1392
1393    #[test]
1394    fn detect_xml_decl_utf_8_explicit() {
1395        let bytes = br#"<?xml version="1.0" encoding="UTF-8"?><r/>"#;
1396        assert_eq!(detect(bytes), Encoding::Utf8);
1397    }
1398
1399    #[test]
1400    fn detect_xml_decl_single_quoted() {
1401        let bytes = br#"<?xml version='1.0' encoding='ISO-8859-1'?><r/>"#;
1402        assert_eq!(detect(bytes), Encoding::Latin1);
1403    }
1404
1405    #[test]
1406    fn detect_xml_decl_unknown_encoding_is_other() {
1407        let bytes = br#"<?xml version="1.0" encoding="ISO-8859-2"?><r/>"#;
1408        assert!(matches!(detect(bytes), Encoding::Other(s) if s == "ISO-8859-2"));
1409    }
1410
1411    #[test]
1412    fn transcode_utf8_is_zero_copy() {
1413        let bytes: &[u8] = b"<r>plain ascii</r>";
1414        let out = transcode_to_utf8(bytes).unwrap();
1415        assert!(matches!(out, Cow::Borrowed(_)));
1416        assert_eq!(out.as_ref(), bytes);
1417    }
1418
1419    #[test]
1420    fn transcode_latin1_caf_e_acute() {
1421        // <r>café</r> in Latin-1: 'é' is byte 0xE9.
1422        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><r>caf\xe9</r>";
1423        let out = transcode_to_utf8(bytes).unwrap();
1424        assert!(matches!(out, Cow::Owned(_)));
1425        let s = std::str::from_utf8(&out).expect("output is valid UTF-8");
1426        assert!(s.contains("café"), "expected 'café' in output, got: {s:?}");
1427    }
1428
1429    #[test]
1430    fn transcode_windows1252_ellipsis() {
1431        // Byte 0x85 is the horizontal ellipsis '…' (U+2026) in Windows-1252.
1432        // (In Latin-1 0x85 is the NEL control character, which is wrong here.)
1433        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"Windows-1252\"?><r>and\x85</r>";
1434        let out = transcode_to_utf8(bytes).unwrap();
1435        let s = std::str::from_utf8(&out).expect("output is valid UTF-8");
1436        assert!(s.contains("and…"), "expected 'and…' (U+2026 ellipsis), got: {s:?}");
1437    }
1438
1439    /// With `full-encodings` (default), GB2312 routes through encoding_rs and
1440    /// decodes cleanly.  Without the feature, it returns an Encoding error.
1441    #[cfg(feature = "full-encodings")]
1442    #[test]
1443    fn transcode_other_encoding_decodes_via_encoding_rs() {
1444        // "<r>中</r>" in GBK (a superset of GB2312).  '中' is byte pair D6 D0.
1445        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"GB2312\"?><r>\xD6\xD0</r>";
1446        let out = transcode_to_utf8(bytes).expect("encoding_rs decodes GB2312");
1447        let s = std::str::from_utf8(&out).expect("decoded bytes are valid UTF-8");
1448        assert!(s.contains("中"), "expected '中' (U+4E2D) in decoded text, got: {s:?}");
1449    }
1450
1451    #[cfg(not(feature = "full-encodings"))]
1452    #[test]
1453    fn transcode_other_encoding_errors_without_feature() {
1454        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"GB2312\"?><r/>";
1455        let err = transcode_to_utf8(bytes).unwrap_err();
1456        assert_eq!(err.domain, ErrorDomain::Encoding);
1457        assert!(err.message.contains("GB2312"), "expected error to mention GB2312, got: {:?}", err.message);
1458    }
1459
1460    #[cfg(feature = "full-encodings")]
1461    #[test]
1462    fn transcode_other_encoding_unknown_label_errors() {
1463        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"not-a-real-encoding-name\"?><r/>";
1464        let err = transcode_to_utf8(bytes).unwrap_err();
1465        assert_eq!(err.domain, ErrorDomain::Encoding);
1466        assert!(
1467            err.message.contains("not recognized"),
1468            "expected message to flag the label as unrecognized, got: {:?}", err.message,
1469        );
1470    }
1471
1472    #[cfg(feature = "full-encodings")]
1473    #[test]
1474    fn transcode_iso_8859_2_via_encoding_rs() {
1475        // ISO-8859-2 is Latin-2; we don't have it in Tier 1.  Byte 0xB1
1476        // is 'ą' (U+0105) in Latin-2.
1477        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"ISO-8859-2\"?><r>\xB1</r>";
1478        let out = transcode_to_utf8(bytes).expect("encoding_rs decodes Latin-2");
1479        let s = std::str::from_utf8(&out).unwrap();
1480        assert!(s.contains("ą"), "expected 'ą' (U+0105) in decoded text, got: {s:?}");
1481    }
1482
1483    #[cfg(feature = "full-encodings")]
1484    #[test]
1485    fn transcode_shift_jis_via_encoding_rs() {
1486        // Shift_JIS: 'あ' (U+3042) is byte pair 0x82 0xA0.
1487        let bytes: &[u8] = b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><r>\x82\xA0</r>";
1488        let out = transcode_to_utf8(bytes).expect("encoding_rs decodes Shift_JIS");
1489        let s = std::str::from_utf8(&out).unwrap();
1490        assert!(s.contains("あ"), "expected 'あ' (U+3042) in decoded text, got: {s:?}");
1491    }
1492
1493    #[test]
1494    fn transcode_strips_utf8_bom() {
1495        let bytes: &[u8] = &[0xEF, 0xBB, 0xBF, b'<', b'r', b'/', b'>'];
1496        let out = transcode_to_utf8(bytes).unwrap();
1497        assert_eq!(out.as_ref(), b"<r/>");
1498    }
1499
1500    #[test]
1501    fn transcode_to_utf8_as_explicit() {
1502        let bytes: &[u8] = b"caf\xe9";
1503        let out = transcode_to_utf8_as(bytes, Encoding::Latin1).unwrap();
1504        assert_eq!(std::str::from_utf8(&out).unwrap(), "café");
1505    }
1506}