1use std::borrow::Cow;
4use std::hash::{Hash, Hasher};
5use std::iter::StepBy;
6use std::ops::{ControlFlow, Range};
7use std::path::Path;
8use std::sync::Arc;
9
10use rustc_hash::{FxHashMap, FxHashSet, FxHasher};
11
12use crate::chunks::{ChunkCutter, ChunkSource, Signature};
13use crate::codecs::{self, DecodeError, Errors};
14use crate::encoding::{self, any_specified_encoding, identify_sig_or_bom, should_strip_sig_or_bom};
15use crate::log::{Level, Logger, NoLogger, emit};
16use crate::matches::{CharsetMatch, CharsetMatches, PendingMatches};
17use crate::{TOO_BIG_SEQUENCE, TOO_SMALL_SEQUENCE, coherence, mess, pyfloat};
18
19#[derive(Clone, Debug, PartialEq)]
37pub struct DetectionOptions {
38 pub steps: usize,
44 pub chunk_size: usize,
49 pub threshold: f64,
54 pub cp_isolation: Vec<String>,
58 pub cp_exclusion: Vec<String>,
60 pub preemptive_behaviour: bool,
68 pub explain: bool,
74 pub language_threshold: f64,
77 pub enable_fallback: bool,
84}
85
86impl Default for DetectionOptions {
87 fn default() -> Self {
88 Self {
89 steps: 5,
90 chunk_size: 512,
91 threshold: 0.2,
92 cp_isolation: Vec::new(),
93 cp_exclusion: Vec::new(),
94 preemptive_behaviour: true,
95 explain: false,
96 language_threshold: 0.1,
97 enable_fallback: true,
98 }
99 }
100}
101
102fn decode(bytes: &[u8], encoding: &str) -> Result<String, DecodeError> {
103 codecs::decode(bytes, encoding, Errors::Strict)
104}
105
106fn decode_into(bytes: &[u8], encoding: &str, out: &mut String) -> Result<(), DecodeError> {
107 codecs::decode_into(bytes, encoding, Errors::Strict, out)
108}
109
110#[must_use]
130pub fn from_bytes(payload: &[u8]) -> CharsetMatches {
131 from_bytes_with(payload, &DetectionOptions::default(), &NoLogger)
132}
133
134pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<CharsetMatches> {
151 Ok(from_bytes(&std::fs::read(path)?))
152}
153
154#[must_use]
165pub fn is_binary(payload: &[u8]) -> bool {
166 let options = DetectionOptions {
167 enable_fallback: false,
168 ..DetectionOptions::default()
169 };
170 from_bytes_with(payload, &options, &NoLogger).is_empty()
171}
172
173#[must_use]
192pub fn from_bytes_with(
193 payload: &[u8],
194 options: &DetectionOptions,
195 logger: &dyn Logger,
196) -> CharsetMatches {
197 detect(&Arc::from(payload), options, logger)
198}
199
200#[must_use]
212#[cfg_attr(feature = "hotpath", hotpath::measure)]
213pub fn detect(
214 payload: &Arc<[u8]>,
215 options: &DetectionOptions,
216 logger: &dyn Logger,
217) -> CharsetMatches {
218 if payload.is_empty() {
219 emit(logger, Level::Debug, || {
220 "Encoding detection on empty bytes, assuming utf_8 intention.".to_owned()
221 });
222 return CharsetMatches::from_sorted(vec![CharsetMatch::new(
223 payload.clone(),
224 "utf_8",
225 0.0,
226 false,
227 Vec::new(),
228 Some(String::new()),
229 None,
230 )]);
231 }
232 Detector::new(payload, options, logger).run()
233}
234
235fn is_unicode_family(encoding: &str) -> bool {
237 matches!(
238 encoding,
239 "utf_8"
240 | "utf_8_sig"
241 | "utf_16"
242 | "utf_16_be"
243 | "utf_16_le"
244 | "utf_32"
245 | "utf_32_be"
246 | "utf_32_le"
247 | "utf_7"
248 )
249}
250
251#[derive(Default)]
254struct ChunkStore {
255 arena: String,
256 spans: Vec<(usize, usize)>,
258 mess: Vec<f64>,
259 heads: FxHashMap<u64, u32>,
261 next: Vec<u32>,
263}
264
265impl ChunkStore {
266 fn hash(text: &str) -> u64 {
267 let mut hasher = FxHasher::default();
268 text.hash(&mut hasher);
269 hasher.finish()
270 }
271
272 fn text(&self, id: u32) -> &str {
273 let (start, end) = self.spans[id as usize];
274 &self.arena[start..end]
275 }
276
277 fn find(&self, hash: u64, text: &str) -> Option<u32> {
278 let mut current = *self.heads.get(&hash)?;
279 while current != u32::MAX {
280 if self.text(current) == text {
281 return Some(current);
282 }
283 current = self.next[current as usize];
284 }
285 None
286 }
287
288 fn intern(&mut self, text: &str, mess: impl FnOnce() -> f64) -> u32 {
290 let hash = Self::hash(text);
291 if let Some(id) = self.find(hash, text) {
292 return id;
293 }
294 let id = u32::try_from(self.spans.len()).expect("fewer than 2^32 chunks");
295 let start = self.arena.len();
296 self.arena.push_str(text);
297 self.spans.push((start, self.arena.len()));
298 self.mess.push(mess());
299 self.next
300 .push(self.heads.insert(hash, id).unwrap_or(u32::MAX));
301 id
302 }
303
304 fn clear(&mut self) {
305 self.arena.clear();
306 self.spans.clear();
307 self.mess.clear();
308 self.heads.clear();
309 self.next.clear();
310 }
311}
312
313type CoherenceCache = FxHashMap<(&'static [&'static str], u32), (usize, usize)>;
316
317#[derive(Default)]
319struct Buffers {
320 chunks: ChunkStore,
321 coherence_cache: CoherenceCache,
322 coherence_values: Vec<(&'static str, f64)>,
323 coherence_scratch: coherence::Scratch,
324 merged: Vec<(&'static str, f64)>,
325 merge_groups: coherence::Groups,
326 chunk_ids: Vec<u32>,
328 text: String,
330 chunk: String,
331 tested: FxHashSet<&'static str>,
332 soft_skip: FxHashSet<&'static str>,
333}
334
335impl Buffers {
336 const KEEP_LIMIT: usize = 1 << 20;
338
339 thread_local! {
340 static POOL: std::cell::RefCell<Option<Buffers>> = const { std::cell::RefCell::new(None) };
341 }
342
343 fn take() -> Self {
344 Self::POOL
345 .try_with(|pool| pool.borrow_mut().take())
346 .ok()
347 .flatten()
348 .unwrap_or_default()
349 }
350
351 fn recycle(mut self) {
353 self.chunks.clear();
354 self.coherence_cache.clear();
355 self.coherence_values.clear();
356 self.chunk_ids.clear();
357 self.tested.clear();
358 self.soft_skip.clear();
359 if self.text.capacity() > Self::KEEP_LIMIT {
360 self.text = String::new();
361 }
362 if self.chunks.arena.capacity() > Self::KEEP_LIMIT {
363 self.chunks = ChunkStore::default();
364 }
365 let _ = Self::POOL.try_with(|pool| *pool.borrow_mut() = Some(self));
366 }
367}
368
369struct Sample {
372 mean: f64,
373 early_stop: usize,
374 max_give_up: usize,
375}
376
377struct Candidate {
379 encoding: &'static str,
380 bom: bool,
381 strip_bom: bool,
382 multibyte: bool,
383 languages: &'static [&'static str],
384}
385
386struct Detector<'a> {
389 payload: &'a Arc<[u8]>,
390 options: &'a DetectionOptions,
391 logger: &'a dyn Logger,
392 steps: usize,
393 chunk_size: usize,
394 is_too_large: bool,
395 specified: Option<&'static str>,
396 sig_encoding: Option<&'static str>,
397 sig: &'static [u8],
398 isolation: Vec<String>,
399 exclusion: Vec<String>,
400 explain_mess: bool,
401 results: PendingMatches,
402 early_results: PendingMatches,
403 fallback_ascii: Option<CharsetMatch>,
404 fallback_utf8: Option<CharsetMatch>,
405 fallback_specified: Option<CharsetMatch>,
406 definitive_languages: Option<FxHashSet<&'static str>>,
408 post_definitive_success: usize,
409 multibyte_definitive: bool,
410 buffers: Buffers,
411}
412
413impl<'a> Detector<'a> {
414 #[cfg_attr(feature = "hotpath", hotpath::measure)]
415 fn new(payload: &'a Arc<[u8]>, options: &'a DetectionOptions, logger: &'a dyn Logger) -> Self {
416 let length = payload.len();
417 let mut steps = options.steps;
418 let mut chunk_size = options.chunk_size;
419 if length <= chunk_size.saturating_mul(steps) {
420 steps = 1;
421 chunk_size = length;
422 }
423 if steps > 1 && length / steps < chunk_size {
424 chunk_size = length / steps;
425 }
426 if length < TOO_SMALL_SEQUENCE {
427 emit(logger, Level::Trace, || {
428 format!("Trying to detect encoding from a tiny portion of ({length}) byte(s).")
429 });
430 }
431 let normalize = |values: &[String]| -> Vec<String> {
432 values
433 .iter()
434 .map(|value| encoding::iana_name(value, false).unwrap_or_default())
435 .collect()
436 };
437 let isolation = normalize(&options.cp_isolation);
438 let exclusion = normalize(&options.cp_exclusion);
439 let (sig_encoding, sig) = identify_sig_or_bom(payload);
440 Self {
441 payload,
442 options,
443 logger,
444 steps,
445 chunk_size,
446 is_too_large: length >= TOO_BIG_SEQUENCE,
447 specified: if options.preemptive_behaviour {
448 any_specified_encoding(payload, 8192)
449 } else {
450 None
451 },
452 sig_encoding,
453 sig,
454 explain_mess: options.explain && (1..=2).contains(&isolation.len()),
455 isolation,
456 exclusion,
457 results: PendingMatches::new(),
458 early_results: PendingMatches::new(),
459 fallback_ascii: None,
460 fallback_utf8: None,
461 fallback_specified: None,
462 definitive_languages: None,
463 post_definitive_success: 0,
464 multibyte_definitive: false,
465 buffers: Buffers::take(),
466 }
467 }
468
469 fn run(mut self) -> CharsetMatches {
470 let mut answer = None;
471 for encoding in self.prioritized() {
472 if let ControlFlow::Break(matches) = self.try_encoding(encoding) {
473 answer = Some(matches);
474 break;
475 }
476 }
477 let matches = answer.unwrap_or_else(|| self.finish());
478 self.buffers.recycle();
479 matches
480 }
481
482 fn prioritized(&self) -> impl Iterator<Item = &'static str> + use<> {
485 let mut first: [Option<&'static str>; 4] = [self.sig_encoding, self.specified, None, None];
486 if first[0].is_none() {
487 first.rotate_left(1);
488 }
489 let mut count = first.iter().flatten().count();
490 first[count] = Some("ascii");
491 count += 1;
492 if !first[..count].contains(&Some("utf_8")) {
493 first[count] = Some("utf_8");
494 }
495 first
496 .into_iter()
497 .flatten()
498 .chain(encoding::supported_encodings().iter().copied())
499 }
500
501 fn new_match(
502 &self,
503 encoding: &'static str,
504 chaos: f64,
505 bom: bool,
506 languages: impl Into<Arc<[(Cow<'static, str>, f64)]>>,
507 decoded: Option<String>,
508 ) -> CharsetMatch {
509 CharsetMatch::new(
510 self.payload.clone(),
511 encoding,
512 chaos,
513 bom,
514 languages,
515 decoded,
516 self.specified.map(Cow::Borrowed),
517 )
518 }
519
520 fn candidate(&mut self, encoding: &'static str) -> Option<Candidate> {
522 if (!self.isolation.is_empty() && !self.isolation.iter().any(|value| value == encoding))
523 || self.exclusion.iter().any(|value| value == encoding)
524 || !self.buffers.tested.insert(encoding)
525 {
526 return None;
527 }
528 let bom = self.sig_encoding == Some(encoding);
529 if (matches!(encoding, "utf_16" | "utf_32" | "utf_7") && !bom)
530 || self.buffers.soft_skip.contains(encoding)
531 || !codecs::is_known(encoding)
532 {
533 return None;
534 }
535 let multibyte = encoding::is_multi_byte_encoding(encoding);
536 let languages = encoding::target_languages(encoding);
537 if let Some(definitive) = &self.definitive_languages
538 && (!languages
539 .iter()
540 .any(|language| definitive.contains(language))
541 || (!multibyte && self.post_definitive_success >= 7))
542 {
543 return None;
544 }
545 if self.multibyte_definitive && !multibyte {
546 return None;
547 }
548 Some(Candidate {
549 encoding,
550 bom,
551 strip_bom: bom && should_strip_sig_or_bom(encoding),
552 multibyte,
553 languages,
554 })
555 }
556
557 fn is_preferred(&self, encoding: &str) -> bool {
560 self.specified == Some(encoding) || matches!(encoding, "ascii" | "utf_8")
561 }
562
563 fn source(&self, candidate: &Candidate) -> &'a [u8] {
565 let payload: &'a [u8] = self.payload;
566 if candidate.strip_bom {
567 &payload[self.sig.len()..]
568 } else {
569 payload
570 }
571 }
572
573 #[cfg_attr(feature = "hotpath", hotpath::measure)]
577 fn initial_decode(
578 &self,
579 candidate: &Candidate,
580 text: &mut String,
581 ) -> Result<bool, DecodeError> {
582 let encoding = candidate.encoding;
583 let source = self.source(candidate);
584 if self.is_too_large && !candidate.multibyte {
585 decode_into(&source[..source.len().min(500_000)], encoding, text)?;
586 return Ok(false);
587 }
588 if candidate.multibyte {
589 let bom_kept_utf7 = encoding == "utf_7" && candidate.bom;
590 let payload: &[u8] = self.payload;
591 decode_into(if bom_kept_utf7 { payload } else { source }, encoding, text)?;
592 if bom_kept_utf7 && text.starts_with('\u{feff}') {
593 text.remove(0);
594 }
595 return Ok(true);
596 }
597 Ok(false)
598 }
599
600 #[cfg_attr(feature = "hotpath", hotpath::measure)]
603 fn sample(&mut self, candidate: &Candidate, decoded: Option<&str>) -> Option<Sample> {
604 let length = self.payload.len();
605 let deferred = !candidate.multibyte && !self.is_too_large;
606 let offset_start = if candidate.bom { self.sig.len() } else { 0 };
607 let offsets: StepBy<Range<usize>> = (offset_start..length).step_by(length / self.steps);
608 let max_give_up = (offsets.len() / 4).max(2);
609 let mut cutter = ChunkCutter::new(
610 self.payload,
611 candidate.encoding,
612 offsets,
613 self.chunk_size,
614 Signature::from_flags(candidate.bom, candidate.strip_bom, self.sig),
615 ChunkSource::select(candidate.encoding, decoded, candidate.multibyte, deferred),
616 );
617 cutter.validate().ok()?;
618 let threshold = self.options.threshold;
619 let (explain, logger) = (self.explain_mess, self.logger);
620 let buffers = &mut self.buffers;
621 buffers.chunk_ids.clear();
622 let mut total = pyfloat::Sum::default();
623 let mut early_stop = 0usize;
624 while let Some(result) = cutter.next_into(&mut buffers.chunk) {
625 result.ok()?;
626 let chunk = &buffers.chunk;
627 let id = buffers.chunks.intern(chunk, || {
628 mess::mess_ratio_with(chunk, threshold, explain, logger)
629 });
630 let ratio = buffers.chunks.mess[id as usize];
631 buffers.chunk_ids.push(id);
632 total.add(ratio);
633 if ratio >= threshold {
634 early_stop += 1;
635 }
636 if early_stop >= max_give_up || (candidate.bom && !candidate.strip_bom) {
637 break;
638 }
639 }
640 let mean = if buffers.chunk_ids.is_empty() {
641 0.0
642 } else {
643 total.value() / buffers.chunk_ids.len() as f64
644 };
645 Some(Sample {
646 mean,
647 early_stop,
648 max_give_up,
649 })
650 }
651
652 fn record_fallback(&mut self, candidate: &Candidate, mut decoded: Option<String>) {
654 let encoding = candidate.encoding;
655 let eligible = matches!(encoding, "ascii" | "utf_8" | "utf_16" | "utf_32")
656 || self.specified == Some(encoding);
657 if !self.options.enable_fallback || !eligible {
658 return;
659 }
660 if decoded.is_none() {
661 match decode(self.source(candidate), encoding) {
662 Ok(value) => decoded = (!self.is_too_large).then_some(value),
663 Err(_) => return,
664 }
665 }
666 let fallback = self.new_match(
667 encoding,
668 self.options.threshold,
669 candidate.bom,
670 Vec::new(),
671 decoded,
672 );
673 if self.specified == Some(encoding) {
674 self.fallback_specified = Some(fallback);
675 } else if encoding == "ascii" {
676 self.fallback_ascii = Some(fallback);
677 } else {
678 self.fallback_utf8 = Some(fallback);
679 }
680 }
681
682 #[cfg_attr(feature = "hotpath", hotpath::measure)]
685 fn coherence(&mut self, candidate: &Candidate) {
686 let buffers = &mut self.buffers;
687 buffers.merged.clear();
688 if candidate.encoding == "ascii" {
689 return;
690 }
691 let languages = candidate.languages;
692 for &id in &buffers.chunk_ids {
693 if !buffers.coherence_cache.contains_key(&(languages, id)) {
694 let start = buffers.coherence_values.len();
695 if coherence::coherence_into(
697 buffers.chunks.text(id),
698 self.options.language_threshold,
699 languages,
700 &mut buffers.coherence_scratch,
701 &mut buffers.coherence_values,
702 )
703 .is_err()
704 {
705 buffers.coherence_values.truncate(start);
706 }
707 let end = buffers.coherence_values.len();
708 buffers
709 .coherence_cache
710 .insert((languages, id), (start, end));
711 }
712 }
713 let values = &buffers.coherence_values;
714 let cache = &buffers.coherence_cache;
715 coherence::merge_into(
716 buffers.chunk_ids.iter().map(|id| {
717 let (start, end) = cache[&(languages, *id)];
718 &values[start..end]
719 }),
720 &mut buffers.merge_groups,
721 &mut buffers.merged,
722 );
723 }
724
725 fn try_encoding(&mut self, encoding: &'static str) -> ControlFlow<CharsetMatches> {
727 let Some(candidate) = self.candidate(encoding) else {
728 return ControlFlow::Continue(());
729 };
730 let mut text = std::mem::take(&mut self.buffers.text);
731 let flow = self.evaluate(&candidate, &mut text);
732 self.buffers.text = text;
733 flow
734 }
735
736 #[cfg_attr(feature = "hotpath", hotpath::measure)]
738 fn evaluate(
739 &mut self,
740 candidate: &Candidate,
741 text: &mut String,
742 ) -> ControlFlow<CharsetMatches> {
743 let encoding = candidate.encoding;
744 let Ok(mut decoded) = self.initial_decode(candidate, text) else {
745 return ControlFlow::Continue(());
746 };
747 let decoded_chars = decoded.then(|| text.chars().count());
749 let Some(sample) = self.sample(candidate, decoded.then_some(text.as_str())) else {
750 return ControlFlow::Continue(());
751 };
752 let rejected =
753 sample.mean >= self.options.threshold || sample.early_stop >= sample.max_give_up;
754 if self.is_too_large
755 && !candidate.multibyte
756 && !rejected
757 && decode_into(
758 &self.payload[50_000.min(self.payload.len())..],
759 encoding,
760 text,
761 )
762 .is_err()
763 {
764 return ControlFlow::Continue(());
765 }
766 if rejected {
767 self.buffers
768 .soft_skip
769 .extend(encoding::similar_encodings(encoding).iter().copied());
770 let decoded_text = decoded.then(|| std::mem::take(text));
771 self.record_fallback(candidate, decoded_text);
772 return ControlFlow::Continue(());
773 }
774 if !candidate.multibyte && !self.is_too_large {
775 if self.is_preferred(encoding) {
780 if decode_into(self.source(candidate), encoding, text).is_err() {
781 return ControlFlow::Continue(());
782 }
783 decoded = true;
784 } else if !codecs::is_valid(self.source(candidate), encoding).unwrap_or(false) {
785 return ControlFlow::Continue(());
786 }
787 }
788 self.coherence(candidate);
789 self.accept(candidate, &sample, decoded.then_some(text), decoded_chars)
790 }
791
792 #[cfg_attr(feature = "hotpath", hotpath::measure)]
794 fn accept(
795 &mut self,
796 candidate: &Candidate,
797 sample: &Sample,
798 decoded: Option<&mut String>,
799 decoded_chars: Option<usize>,
800 ) -> ControlFlow<CharsetMatches> {
801 let encoding = candidate.encoding;
802 let length = self.payload.len();
803 let mean = sample.mean;
804 let merged = &self.buffers.merged;
805 let best_coherence = merged.iter().map(|item| item.1).fold(0.0, f64::max);
806 let preferred = self.is_preferred(encoding);
807 let retained = if !self.is_too_large || preferred {
808 decoded.map(std::mem::take)
809 } else {
810 None
811 };
812 let languages: Arc<[(Cow<'static, str>, f64)]> = merged
813 .iter()
814 .map(|&(language, ratio)| (Cow::Borrowed(language), ratio))
815 .collect();
816 let current = self.new_match(encoding, mean, candidate.bom, languages, retained);
817 self.results.push(current.clone());
818 if self.definitive_languages.is_some() && !candidate.multibyte && mean < 0.02 {
819 self.post_definitive_success += 1;
820 }
821 if preferred && mean < 0.1 {
822 if mean == 0.0 {
823 emit(self.logger, Level::Debug, || {
824 format!("Encoding detection: {encoding} is most likely the one.")
825 });
826 return ControlFlow::Break(CharsetMatches::from_sorted(vec![current]));
827 }
828 self.early_results.push(current.clone());
829 }
830 let tested = &self.buffers.tested;
831 let baseline_tested = tested.contains("ascii") && tested.contains("utf_8");
832 if self.early_results.len() > 0
833 && self.specified.is_none_or(|value| tested.contains(value))
834 && baseline_tested
835 {
836 let early = std::mem::replace(&mut self.early_results, PendingMatches::new());
837 return ControlFlow::Break(CharsetMatches::from_sorted(
838 early.take_best().into_iter().collect(),
839 ));
840 }
841 if self.definitive_languages.is_none()
842 && !candidate.multibyte
843 && best_coherence >= 0.5
844 && baseline_tested
845 {
846 self.definitive_languages = Some(candidate.languages.iter().copied().collect());
847 }
848 let multibyte_bonus = decoded_chars.is_some_and(|count| count < length);
850 let mostly_multi_byte =
851 decoded_chars.is_some_and(|count| (count as f64) < length as f64 * 0.98);
852 if !self.multibyte_definitive
853 && candidate.multibyte
854 && multibyte_bonus
855 && mostly_multi_byte
856 && !is_unicode_family(encoding)
857 && baseline_tested
858 {
859 self.multibyte_definitive = true;
860 }
861 if self.sig_encoding == Some(encoding) {
862 return ControlFlow::Break(CharsetMatches::from_sorted(vec![current]));
863 }
864 ControlFlow::Continue(())
865 }
866
867 #[cfg_attr(feature = "hotpath", hotpath::measure)]
868 fn finish(&mut self) -> CharsetMatches {
869 if self.results.len() == 0
870 && let Some(value) = self
871 .fallback_specified
872 .take()
873 .or(self.fallback_utf8.take())
874 .or(self.fallback_ascii.take())
875 {
876 self.results.push(value);
877 }
878 if self.results.len() > 0 {
879 let alternatives = self.results.len() - 1;
880 if let Some(best) = self.results.best() {
881 let encoding = best.encoding().to_owned();
882 emit(self.logger, Level::Debug, || {
883 format!(
884 "Encoding detection: Found {encoding} as plausible (best-candidate) for content. With {alternatives} alternatives."
885 )
886 });
887 }
888 } else {
889 emit(self.logger, Level::Debug, || {
890 "Encoding detection: Unable to determine any suitable charset.".to_owned()
891 });
892 }
893 std::mem::replace(&mut self.results, PendingMatches::new()).into_matches()
894 }
895}