use encoding_rs::{UTF_16BE, UTF_16LE, WINDOWS_1252};
const SNIFF_BYTES: usize = 4096;
const NUL_RATIO: f32 = 0.20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectedEncoding {
Utf8,
Utf8Bom,
Utf16Le,
Utf16Be,
Windows1252,
}
impl DetectedEncoding {
#[allow(dead_code)]
pub fn as_str(self) -> &'static str {
match self {
DetectedEncoding::Utf8 => "utf-8",
DetectedEncoding::Utf8Bom => "utf-8-bom",
DetectedEncoding::Utf16Le => "utf-16le",
DetectedEncoding::Utf16Be => "utf-16be",
DetectedEncoding::Windows1252 => "windows-1252",
}
}
}
pub fn decode_text(bytes: &[u8]) -> (String, DetectedEncoding) {
let (text, encoding) = decode_raw(bytes);
(normalize_newlines(text), encoding)
}
pub fn normalize_newlines(text: String) -> String {
if !text.contains('\r') {
return text;
}
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.push('\n');
} else {
out.push(ch);
}
}
out
}
pub fn decode_utf8_document(bytes: &[u8]) -> String {
let text = match std::str::from_utf8(bytes) {
Ok(v) => v.to_string(),
Err(_) => String::from_utf8_lossy(bytes).to_string(),
};
normalize_newlines(text)
}
fn decode_raw(bytes: &[u8]) -> (String, DetectedEncoding) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (lossy_utf8(rest), DetectedEncoding::Utf8Bom);
}
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
return (UTF_16LE.decode(rest).0.into_owned(), DetectedEncoding::Utf16Le);
}
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
return (UTF_16BE.decode(rest).0.into_owned(), DetectedEncoding::Utf16Be);
}
if let Some(enc) = sniff_utf16(bytes) {
let decoded = match enc {
DetectedEncoding::Utf16Be => UTF_16BE.decode(bytes).0.into_owned(),
_ => UTF_16LE.decode(bytes).0.into_owned(),
};
return (decoded, enc);
}
if let Ok(text) = std::str::from_utf8(bytes) {
return (text.to_string(), DetectedEncoding::Utf8);
}
(
WINDOWS_1252.decode(bytes).0.into_owned(),
DetectedEncoding::Windows1252,
)
}
fn lossy_utf8(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => String::from_utf8_lossy(bytes).into_owned(),
}
}
fn sniff_utf16(bytes: &[u8]) -> Option<DetectedEncoding> {
let window = &bytes[..bytes.len().min(SNIFF_BYTES)];
if window.len() < 4 {
return None;
}
let (mut even_nuls, mut odd_nuls) = (0usize, 0usize);
for (i, b) in window.iter().enumerate() {
if *b == 0 {
if i % 2 == 0 {
even_nuls += 1;
} else {
odd_nuls += 1;
}
}
}
let half = (window.len() / 2) as f32;
if half == 0.0 {
return None;
}
let (even_ratio, odd_ratio) = (even_nuls as f32 / half, odd_nuls as f32 / half);
if odd_ratio >= NUL_RATIO && even_ratio < NUL_RATIO / 4.0 {
return Some(DetectedEncoding::Utf16Le);
}
if even_ratio >= NUL_RATIO && odd_ratio < NUL_RATIO / 4.0 {
return Some(DetectedEncoding::Utf16Be);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn utf16le(s: &str, bom: bool) -> Vec<u8> {
let mut out = if bom { vec![0xFF, 0xFE] } else { Vec::new() };
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_le_bytes());
}
out
}
fn utf16be(s: &str, bom: bool) -> Vec<u8> {
let mut out = if bom { vec![0xFE, 0xFF] } else { Vec::new() };
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_be_bytes());
}
out
}
const SAMPLE: &str = "The quick brown fox jumps over the lazy dog. Sentence two here.";
#[test]
fn plain_utf8_is_unchanged() {
let (text, enc) = decode_text("héllo — wörld".as_bytes());
assert_eq!(text, "héllo — wörld");
assert_eq!(enc, DetectedEncoding::Utf8);
}
#[test]
fn utf8_bom_is_stripped_not_leaked_into_the_text() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"# Heading");
let (text, enc) = decode_text(&bytes);
assert_eq!(text, "# Heading");
assert!(!text.starts_with('\u{feff}'));
assert_eq!(enc, DetectedEncoding::Utf8Bom);
}
#[test]
fn utf16_with_bom_decodes_both_endiannesses() {
assert_eq!(decode_text(&utf16le(SAMPLE, true)).0, SAMPLE);
assert_eq!(decode_text(&utf16be(SAMPLE, true)).0, SAMPLE);
}
#[test]
fn utf16_without_bom_is_sniffed_from_the_nul_pattern() {
let (le, le_enc) = decode_text(&utf16le(SAMPLE, false));
assert_eq!(le, SAMPLE);
assert_eq!(le_enc, DetectedEncoding::Utf16Le);
let (be, be_enc) = decode_text(&utf16be(SAMPLE, false));
assert_eq!(be, SAMPLE);
assert_eq!(be_enc, DetectedEncoding::Utf16Be);
}
#[test]
fn cp1252_punctuation_survives_instead_of_becoming_replacement_chars() {
let bytes = [
b'S', b'a', b'y', b' ', 0x93, b'h', b'i', 0x94, b' ', 0x97, b' ', 0xE9, b' ', 0xA3, b'5',
];
let (text, enc) = decode_text(&bytes);
assert_eq!(text, "Say “hi” — é £5");
assert!(!text.contains('\u{fffd}'));
assert_eq!(enc, DetectedEncoding::Windows1252);
}
#[test]
fn a_nul_heavy_binary_blob_is_not_mistaken_for_utf16() {
let bytes: Vec<u8> = (0..512u16).map(|i| if i % 3 == 0 { 0 } else { 0xC3 }).collect();
assert!(sniff_utf16(&bytes).is_none());
}
#[test]
fn empty_and_tiny_inputs_do_not_panic() {
assert_eq!(decode_text(b"").0, "");
assert_eq!(decode_text(b"a").0, "a");
assert_eq!(decode_text(&[0xFF, 0xFE]).0, "");
}
}
#[cfg(test)]
mod newline_tests {
use super::*;
#[test]
fn a_windows_paragraph_break_becomes_a_plain_one() {
let (text, _) = decode_text(b"HEADING\r\n\r\nBody text.\r\n");
assert_eq!(text, "HEADING\n\nBody text.\n");
assert!(text.contains("\n\n"), "the block splitter must see a break");
}
#[test]
fn a_bare_cr_becomes_a_line_feed() {
let (text, _) = decode_text(b"FIRST\r\rSecond line.\r");
assert_eq!(text, "FIRST\n\nSecond line.\n");
assert!(!text.contains('\r'), "no raw CR may reach the caller");
}
#[test]
fn mixed_terminators_all_normalise() {
let (text, _) = decode_text(b"a\r\nb\rc\nd");
assert_eq!(text, "a\nb\nc\nd");
}
#[test]
fn crlf_is_one_terminator_not_two() {
let (text, _) = decode_text(b"one\r\ntwo\r\nthree");
assert_eq!(text.matches('\n').count(), 2);
}
#[test]
fn normalisation_applies_after_utf16_decoding() {
let utf16: Vec<u8> = "HEAD\r\n\r\nBody.\r\n"
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let mut bytes = vec![0xFF, 0xFE];
bytes.extend(utf16);
let (text, enc) = decode_text(&bytes);
assert_eq!(enc, DetectedEncoding::Utf16Le);
assert_eq!(text, "HEAD\n\nBody.\n");
}
#[test]
fn a_unix_document_is_returned_unchanged() {
let src = "already\nnormal\n\ntext\n".to_string();
assert_eq!(normalize_newlines(src.clone()), src);
}
}