use std::borrow::Cow;
use std::hash::{Hash, Hasher};
use std::iter::StepBy;
use std::ops::{ControlFlow, Range};
use std::path::Path;
use std::sync::Arc;
use rustc_hash::{FxHashMap, FxHashSet, FxHasher};
use crate::chunks::{ChunkCutter, ChunkSource, Signature};
use crate::codecs::{self, DecodeError, Errors};
use crate::encoding::{self, any_specified_encoding, identify_sig_or_bom, should_strip_sig_or_bom};
use crate::log::{Level, Logger, NoLogger, emit};
use crate::matches::{CharsetMatch, CharsetMatches, PendingMatches};
use crate::{TOO_BIG_SEQUENCE, TOO_SMALL_SEQUENCE, coherence, mess, pyfloat};
#[derive(Clone, Debug, PartialEq)]
pub struct DetectionOptions {
pub steps: usize,
pub chunk_size: usize,
pub threshold: f64,
pub cp_isolation: Vec<String>,
pub cp_exclusion: Vec<String>,
pub preemptive_behaviour: bool,
pub explain: bool,
pub language_threshold: f64,
pub enable_fallback: bool,
}
impl Default for DetectionOptions {
fn default() -> Self {
Self {
steps: 5,
chunk_size: 512,
threshold: 0.2,
cp_isolation: Vec::new(),
cp_exclusion: Vec::new(),
preemptive_behaviour: true,
explain: false,
language_threshold: 0.1,
enable_fallback: true,
}
}
}
fn decode(bytes: &[u8], encoding: &str) -> Result<String, DecodeError> {
codecs::decode(bytes, encoding, Errors::Strict)
}
fn decode_into(bytes: &[u8], encoding: &str, out: &mut String) -> Result<(), DecodeError> {
codecs::decode_into(bytes, encoding, Errors::Strict, out)
}
#[must_use]
pub fn from_bytes(payload: &[u8]) -> CharsetMatches {
from_bytes_with(payload, &DetectionOptions::default(), &NoLogger)
}
pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<CharsetMatches> {
Ok(from_bytes(&std::fs::read(path)?))
}
#[must_use]
pub fn is_binary(payload: &[u8]) -> bool {
let options = DetectionOptions {
enable_fallback: false,
..DetectionOptions::default()
};
from_bytes_with(payload, &options, &NoLogger).is_empty()
}
#[must_use]
pub fn from_bytes_with(
payload: &[u8],
options: &DetectionOptions,
logger: &dyn Logger,
) -> CharsetMatches {
detect(&Arc::from(payload), options, logger)
}
#[must_use]
#[cfg_attr(feature = "hotpath", hotpath::measure)]
pub fn detect(
payload: &Arc<[u8]>,
options: &DetectionOptions,
logger: &dyn Logger,
) -> CharsetMatches {
if payload.is_empty() {
emit(logger, Level::Debug, || {
"Encoding detection on empty bytes, assuming utf_8 intention.".to_owned()
});
return CharsetMatches::from_sorted(vec![CharsetMatch::new(
payload.clone(),
"utf_8",
0.0,
false,
Vec::new(),
Some(String::new()),
None,
)]);
}
Detector::new(payload, options, logger).run()
}
fn is_unicode_family(encoding: &str) -> bool {
matches!(
encoding,
"utf_8"
| "utf_8_sig"
| "utf_16"
| "utf_16_be"
| "utf_16_le"
| "utf_32"
| "utf_32_be"
| "utf_32_le"
| "utf_7"
)
}
#[derive(Default)]
struct ChunkStore {
arena: String,
spans: Vec<(usize, usize)>,
mess: Vec<f64>,
heads: FxHashMap<u64, u32>,
next: Vec<u32>,
}
impl ChunkStore {
fn hash(text: &str) -> u64 {
let mut hasher = FxHasher::default();
text.hash(&mut hasher);
hasher.finish()
}
fn text(&self, id: u32) -> &str {
let (start, end) = self.spans[id as usize];
&self.arena[start..end]
}
fn find(&self, hash: u64, text: &str) -> Option<u32> {
let mut current = *self.heads.get(&hash)?;
while current != u32::MAX {
if self.text(current) == text {
return Some(current);
}
current = self.next[current as usize];
}
None
}
fn intern(&mut self, text: &str, mess: impl FnOnce() -> f64) -> u32 {
let hash = Self::hash(text);
if let Some(id) = self.find(hash, text) {
return id;
}
let id = u32::try_from(self.spans.len()).expect("fewer than 2^32 chunks");
let start = self.arena.len();
self.arena.push_str(text);
self.spans.push((start, self.arena.len()));
self.mess.push(mess());
self.next
.push(self.heads.insert(hash, id).unwrap_or(u32::MAX));
id
}
fn clear(&mut self) {
self.arena.clear();
self.spans.clear();
self.mess.clear();
self.heads.clear();
self.next.clear();
}
}
type CoherenceCache = FxHashMap<(&'static [&'static str], u32), (usize, usize)>;
#[derive(Default)]
struct Buffers {
chunks: ChunkStore,
coherence_cache: CoherenceCache,
coherence_values: Vec<(&'static str, f64)>,
coherence_scratch: coherence::Scratch,
merged: Vec<(&'static str, f64)>,
merge_groups: coherence::Groups,
chunk_ids: Vec<u32>,
text: String,
chunk: String,
tested: FxHashSet<&'static str>,
soft_skip: FxHashSet<&'static str>,
}
impl Buffers {
const KEEP_LIMIT: usize = 1 << 20;
thread_local! {
static POOL: std::cell::RefCell<Option<Buffers>> = const { std::cell::RefCell::new(None) };
}
fn take() -> Self {
Self::POOL
.try_with(|pool| pool.borrow_mut().take())
.ok()
.flatten()
.unwrap_or_default()
}
fn recycle(mut self) {
self.chunks.clear();
self.coherence_cache.clear();
self.coherence_values.clear();
self.chunk_ids.clear();
self.tested.clear();
self.soft_skip.clear();
if self.text.capacity() > Self::KEEP_LIMIT {
self.text = String::new();
}
if self.chunks.arena.capacity() > Self::KEEP_LIMIT {
self.chunks = ChunkStore::default();
}
let _ = Self::POOL.try_with(|pool| *pool.borrow_mut() = Some(self));
}
}
struct Sample {
mean: f64,
early_stop: usize,
max_give_up: usize,
}
struct Candidate {
encoding: &'static str,
bom: bool,
strip_bom: bool,
multibyte: bool,
languages: &'static [&'static str],
}
struct Detector<'a> {
payload: &'a Arc<[u8]>,
options: &'a DetectionOptions,
logger: &'a dyn Logger,
steps: usize,
chunk_size: usize,
is_too_large: bool,
specified: Option<&'static str>,
sig_encoding: Option<&'static str>,
sig: &'static [u8],
isolation: Vec<String>,
exclusion: Vec<String>,
explain_mess: bool,
results: PendingMatches,
early_results: PendingMatches,
fallback_ascii: Option<CharsetMatch>,
fallback_utf8: Option<CharsetMatch>,
fallback_specified: Option<CharsetMatch>,
definitive_languages: Option<FxHashSet<&'static str>>,
post_definitive_success: usize,
multibyte_definitive: bool,
buffers: Buffers,
}
impl<'a> Detector<'a> {
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn new(payload: &'a Arc<[u8]>, options: &'a DetectionOptions, logger: &'a dyn Logger) -> Self {
let length = payload.len();
let mut steps = options.steps;
let mut chunk_size = options.chunk_size;
if length <= chunk_size.saturating_mul(steps) {
steps = 1;
chunk_size = length;
}
if steps > 1 && length / steps < chunk_size {
chunk_size = length / steps;
}
if length < TOO_SMALL_SEQUENCE {
emit(logger, Level::Trace, || {
format!("Trying to detect encoding from a tiny portion of ({length}) byte(s).")
});
}
let normalize = |values: &[String]| -> Vec<String> {
values
.iter()
.map(|value| encoding::iana_name(value, false).unwrap_or_default())
.collect()
};
let isolation = normalize(&options.cp_isolation);
let exclusion = normalize(&options.cp_exclusion);
let (sig_encoding, sig) = identify_sig_or_bom(payload);
Self {
payload,
options,
logger,
steps,
chunk_size,
is_too_large: length >= TOO_BIG_SEQUENCE,
specified: if options.preemptive_behaviour {
any_specified_encoding(payload, 8192)
} else {
None
},
sig_encoding,
sig,
explain_mess: options.explain && (1..=2).contains(&isolation.len()),
isolation,
exclusion,
results: PendingMatches::new(),
early_results: PendingMatches::new(),
fallback_ascii: None,
fallback_utf8: None,
fallback_specified: None,
definitive_languages: None,
post_definitive_success: 0,
multibyte_definitive: false,
buffers: Buffers::take(),
}
}
fn run(mut self) -> CharsetMatches {
let mut answer = None;
for encoding in self.prioritized() {
if let ControlFlow::Break(matches) = self.try_encoding(encoding) {
answer = Some(matches);
break;
}
}
let matches = answer.unwrap_or_else(|| self.finish());
self.buffers.recycle();
matches
}
fn prioritized(&self) -> impl Iterator<Item = &'static str> + use<> {
let mut first: [Option<&'static str>; 4] = [self.sig_encoding, self.specified, None, None];
if first[0].is_none() {
first.rotate_left(1);
}
let mut count = first.iter().flatten().count();
first[count] = Some("ascii");
count += 1;
if !first[..count].contains(&Some("utf_8")) {
first[count] = Some("utf_8");
}
first
.into_iter()
.flatten()
.chain(encoding::supported_encodings().iter().copied())
}
fn new_match(
&self,
encoding: &'static str,
chaos: f64,
bom: bool,
languages: impl Into<Arc<[(Cow<'static, str>, f64)]>>,
decoded: Option<String>,
) -> CharsetMatch {
CharsetMatch::new(
self.payload.clone(),
encoding,
chaos,
bom,
languages,
decoded,
self.specified.map(Cow::Borrowed),
)
}
fn candidate(&mut self, encoding: &'static str) -> Option<Candidate> {
if (!self.isolation.is_empty() && !self.isolation.iter().any(|value| value == encoding))
|| self.exclusion.iter().any(|value| value == encoding)
|| !self.buffers.tested.insert(encoding)
{
return None;
}
let bom = self.sig_encoding == Some(encoding);
if (matches!(encoding, "utf_16" | "utf_32" | "utf_7") && !bom)
|| self.buffers.soft_skip.contains(encoding)
|| !codecs::is_known(encoding)
{
return None;
}
let multibyte = encoding::is_multi_byte_encoding(encoding);
let languages = encoding::target_languages(encoding);
if let Some(definitive) = &self.definitive_languages
&& (!languages
.iter()
.any(|language| definitive.contains(language))
|| (!multibyte && self.post_definitive_success >= 7))
{
return None;
}
if self.multibyte_definitive && !multibyte {
return None;
}
Some(Candidate {
encoding,
bom,
strip_bom: bom && should_strip_sig_or_bom(encoding),
multibyte,
languages,
})
}
fn is_preferred(&self, encoding: &str) -> bool {
self.specified == Some(encoding) || matches!(encoding, "ascii" | "utf_8")
}
fn source(&self, candidate: &Candidate) -> &'a [u8] {
let payload: &'a [u8] = self.payload;
if candidate.strip_bom {
&payload[self.sig.len()..]
} else {
payload
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn initial_decode(
&self,
candidate: &Candidate,
text: &mut String,
) -> Result<bool, DecodeError> {
let encoding = candidate.encoding;
let source = self.source(candidate);
if self.is_too_large && !candidate.multibyte {
decode_into(&source[..source.len().min(500_000)], encoding, text)?;
return Ok(false);
}
if candidate.multibyte {
let bom_kept_utf7 = encoding == "utf_7" && candidate.bom;
let payload: &[u8] = self.payload;
decode_into(if bom_kept_utf7 { payload } else { source }, encoding, text)?;
if bom_kept_utf7 && text.starts_with('\u{feff}') {
text.remove(0);
}
return Ok(true);
}
Ok(false)
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn sample(&mut self, candidate: &Candidate, decoded: Option<&str>) -> Option<Sample> {
let length = self.payload.len();
let deferred = !candidate.multibyte && !self.is_too_large;
let offset_start = if candidate.bom { self.sig.len() } else { 0 };
let offsets: StepBy<Range<usize>> = (offset_start..length).step_by(length / self.steps);
let max_give_up = (offsets.len() / 4).max(2);
let mut cutter = ChunkCutter::new(
self.payload,
candidate.encoding,
offsets,
self.chunk_size,
Signature::from_flags(candidate.bom, candidate.strip_bom, self.sig),
ChunkSource::select(candidate.encoding, decoded, candidate.multibyte, deferred),
);
cutter.validate().ok()?;
let threshold = self.options.threshold;
let (explain, logger) = (self.explain_mess, self.logger);
let buffers = &mut self.buffers;
buffers.chunk_ids.clear();
let mut total = pyfloat::Sum::default();
let mut early_stop = 0usize;
while let Some(result) = cutter.next_into(&mut buffers.chunk) {
result.ok()?;
let chunk = &buffers.chunk;
let id = buffers.chunks.intern(chunk, || {
mess::mess_ratio_with(chunk, threshold, explain, logger)
});
let ratio = buffers.chunks.mess[id as usize];
buffers.chunk_ids.push(id);
total.add(ratio);
if ratio >= threshold {
early_stop += 1;
}
if early_stop >= max_give_up || (candidate.bom && !candidate.strip_bom) {
break;
}
}
let mean = if buffers.chunk_ids.is_empty() {
0.0
} else {
total.value() / buffers.chunk_ids.len() as f64
};
Some(Sample {
mean,
early_stop,
max_give_up,
})
}
fn record_fallback(&mut self, candidate: &Candidate, mut decoded: Option<String>) {
let encoding = candidate.encoding;
let eligible = matches!(encoding, "ascii" | "utf_8" | "utf_16" | "utf_32")
|| self.specified == Some(encoding);
if !self.options.enable_fallback || !eligible {
return;
}
if decoded.is_none() {
match decode(self.source(candidate), encoding) {
Ok(value) => decoded = (!self.is_too_large).then_some(value),
Err(_) => return,
}
}
let fallback = self.new_match(
encoding,
self.options.threshold,
candidate.bom,
Vec::new(),
decoded,
);
if self.specified == Some(encoding) {
self.fallback_specified = Some(fallback);
} else if encoding == "ascii" {
self.fallback_ascii = Some(fallback);
} else {
self.fallback_utf8 = Some(fallback);
}
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn coherence(&mut self, candidate: &Candidate) {
let buffers = &mut self.buffers;
buffers.merged.clear();
if candidate.encoding == "ascii" {
return;
}
let languages = candidate.languages;
for &id in &buffers.chunk_ids {
if !buffers.coherence_cache.contains_key(&(languages, id)) {
let start = buffers.coherence_values.len();
if coherence::coherence_into(
buffers.chunks.text(id),
self.options.language_threshold,
languages,
&mut buffers.coherence_scratch,
&mut buffers.coherence_values,
)
.is_err()
{
buffers.coherence_values.truncate(start);
}
let end = buffers.coherence_values.len();
buffers
.coherence_cache
.insert((languages, id), (start, end));
}
}
let values = &buffers.coherence_values;
let cache = &buffers.coherence_cache;
coherence::merge_into(
buffers.chunk_ids.iter().map(|id| {
let (start, end) = cache[&(languages, *id)];
&values[start..end]
}),
&mut buffers.merge_groups,
&mut buffers.merged,
);
}
fn try_encoding(&mut self, encoding: &'static str) -> ControlFlow<CharsetMatches> {
let Some(candidate) = self.candidate(encoding) else {
return ControlFlow::Continue(());
};
let mut text = std::mem::take(&mut self.buffers.text);
let flow = self.evaluate(&candidate, &mut text);
self.buffers.text = text;
flow
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn evaluate(
&mut self,
candidate: &Candidate,
text: &mut String,
) -> ControlFlow<CharsetMatches> {
let encoding = candidate.encoding;
let Ok(mut decoded) = self.initial_decode(candidate, text) else {
return ControlFlow::Continue(());
};
let decoded_chars = decoded.then(|| text.chars().count());
let Some(sample) = self.sample(candidate, decoded.then_some(text.as_str())) else {
return ControlFlow::Continue(());
};
let rejected =
sample.mean >= self.options.threshold || sample.early_stop >= sample.max_give_up;
if self.is_too_large
&& !candidate.multibyte
&& !rejected
&& decode_into(
&self.payload[50_000.min(self.payload.len())..],
encoding,
text,
)
.is_err()
{
return ControlFlow::Continue(());
}
if rejected {
self.buffers
.soft_skip
.extend(encoding::similar_encodings(encoding).iter().copied());
let decoded_text = decoded.then(|| std::mem::take(text));
self.record_fallback(candidate, decoded_text);
return ControlFlow::Continue(());
}
if !candidate.multibyte && !self.is_too_large {
if self.is_preferred(encoding) {
if decode_into(self.source(candidate), encoding, text).is_err() {
return ControlFlow::Continue(());
}
decoded = true;
} else if !codecs::is_valid(self.source(candidate), encoding).unwrap_or(false) {
return ControlFlow::Continue(());
}
}
self.coherence(candidate);
self.accept(candidate, &sample, decoded.then_some(text), decoded_chars)
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn accept(
&mut self,
candidate: &Candidate,
sample: &Sample,
decoded: Option<&mut String>,
decoded_chars: Option<usize>,
) -> ControlFlow<CharsetMatches> {
let encoding = candidate.encoding;
let length = self.payload.len();
let mean = sample.mean;
let merged = &self.buffers.merged;
let best_coherence = merged.iter().map(|item| item.1).fold(0.0, f64::max);
let preferred = self.is_preferred(encoding);
let retained = if !self.is_too_large || preferred {
decoded.map(std::mem::take)
} else {
None
};
let languages: Arc<[(Cow<'static, str>, f64)]> = merged
.iter()
.map(|&(language, ratio)| (Cow::Borrowed(language), ratio))
.collect();
let current = self.new_match(encoding, mean, candidate.bom, languages, retained);
self.results.push(current.clone());
if self.definitive_languages.is_some() && !candidate.multibyte && mean < 0.02 {
self.post_definitive_success += 1;
}
if preferred && mean < 0.1 {
if mean == 0.0 {
emit(self.logger, Level::Debug, || {
format!("Encoding detection: {encoding} is most likely the one.")
});
return ControlFlow::Break(CharsetMatches::from_sorted(vec![current]));
}
self.early_results.push(current.clone());
}
let tested = &self.buffers.tested;
let baseline_tested = tested.contains("ascii") && tested.contains("utf_8");
if self.early_results.len() > 0
&& self.specified.is_none_or(|value| tested.contains(value))
&& baseline_tested
{
let early = std::mem::replace(&mut self.early_results, PendingMatches::new());
return ControlFlow::Break(CharsetMatches::from_sorted(
early.take_best().into_iter().collect(),
));
}
if self.definitive_languages.is_none()
&& !candidate.multibyte
&& best_coherence >= 0.5
&& baseline_tested
{
self.definitive_languages = Some(candidate.languages.iter().copied().collect());
}
let multibyte_bonus = decoded_chars.is_some_and(|count| count < length);
let mostly_multi_byte =
decoded_chars.is_some_and(|count| (count as f64) < length as f64 * 0.98);
if !self.multibyte_definitive
&& candidate.multibyte
&& multibyte_bonus
&& mostly_multi_byte
&& !is_unicode_family(encoding)
&& baseline_tested
{
self.multibyte_definitive = true;
}
if self.sig_encoding == Some(encoding) {
return ControlFlow::Break(CharsetMatches::from_sorted(vec![current]));
}
ControlFlow::Continue(())
}
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn finish(&mut self) -> CharsetMatches {
if self.results.len() == 0
&& let Some(value) = self
.fallback_specified
.take()
.or(self.fallback_utf8.take())
.or(self.fallback_ascii.take())
{
self.results.push(value);
}
if self.results.len() > 0 {
let alternatives = self.results.len() - 1;
if let Some(best) = self.results.best() {
let encoding = best.encoding().to_owned();
emit(self.logger, Level::Debug, || {
format!(
"Encoding detection: Found {encoding} as plausible (best-candidate) for content. With {alternatives} alternatives."
)
});
}
} else {
emit(self.logger, Level::Debug, || {
"Encoding detection: Unable to determine any suitable charset.".to_owned()
});
}
std::mem::replace(&mut self.results, PendingMatches::new()).into_matches()
}
}