Skip to main content

charset_norm/
detect.rs

1//! The detector: [`from_bytes`] and friends, re-exported at the crate root.
2
3use 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/// Tuning knobs of [`from_bytes_with`] and [`detect`].
20///
21/// The [`Default`] values match the Python package's `from_bytes`. Override
22/// only the fields you need with struct update syntax:
23///
24/// ```
25/// use charset_norm::DetectionOptions;
26///
27/// let options = DetectionOptions {
28///     // Never consider these code pages, whatever the payload looks like.
29///     cp_exclusion: vec!["utf_7".into(), "hz".into()],
30///     // Look at more of the payload.
31///     steps: 10,
32///     ..DetectionOptions::default()
33/// };
34/// assert_eq!(options.chunk_size, 512);
35/// ```
36#[derive(Clone, Debug, PartialEq)]
37pub struct DetectionOptions {
38    /// Number of chunks sampled from the payload. Default: `5`.
39    ///
40    /// Chunks are spread evenly over the payload. When the payload is not
41    /// longer than `steps * chunk_size` bytes, it is analysed whole, as a
42    /// single chunk.
43    pub steps: usize,
44    /// Size of each sampled chunk, in bytes. Default: `512`.
45    ///
46    /// Shrunk automatically when the payload is too short to hold `steps`
47    /// chunks of this size.
48    pub chunk_size: usize,
49    /// Maximum acceptable mean chaos (see [`mess::mess_ratio`]) for a
50    /// candidate encoding. Default: `0.2`.
51    ///
52    /// Lower values reject more candidates; raise it to accept noisier text.
53    pub threshold: f64,
54    /// When not empty, only these encodings are tried. Default: empty.
55    ///
56    /// Names may be any alias (`"windows-1252"`, `"UTF-8"`, ...).
57    pub cp_isolation: Vec<String>,
58    /// Encodings never tried, by any alias. Default: empty.
59    pub cp_exclusion: Vec<String>,
60    /// Favour an encoding declared inside the payload, such as
61    /// `<meta charset="...">` in HTML or a `# coding: ...` comment in Python
62    /// sources. Default: `true`.
63    ///
64    /// Only the first 8 KiB are searched. A declared encoding is tried right
65    /// after any BOM-announced one, and detection stops there when it
66    /// decodes the payload without any chaos.
67    pub preemptive_behaviour: bool,
68    /// Log the chaos breakdown of every sampled chunk at [`Level::Trace`].
69    /// Default: `false`.
70    ///
71    /// Only takes effect when [`cp_isolation`](Self::cp_isolation) names one
72    /// or two encodings, to keep the output readable.
73    pub explain: bool,
74    /// Minimum coherence (in `[0, 1]`) for a language to be reported.
75    /// Default: `0.1`.
76    pub language_threshold: f64,
77    /// Fall back on ASCII, UTF-8 or a declared encoding when every candidate
78    /// is rejected but one of those still decodes the payload. Default:
79    /// `true`.
80    ///
81    /// Without the fallback, text that is merely noisy can come back as no
82    /// match at all (see [`is_binary`]).
83    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/// Detect the plausible encodings of `payload` with default options.
111///
112/// Results are ranked best first; [`CharsetMatches::best`] is the verdict.
113/// An empty result means no encoding fits, which usually means `payload` is
114/// binary. An empty payload yields a single `"utf_8"` match.
115///
116/// Use [`from_bytes_with`] to tune detection or collect diagnostics.
117///
118/// ```
119/// use charset_norm::codecs;
120///
121/// let text = "Всеки човек има право на образование. Образованието трябва да бъде безплатно.";
122/// let payload = codecs::encode(text, "cp1251").unwrap();
123///
124/// let results = charset_norm::from_bytes(&payload);
125/// let best = results.best().unwrap();
126/// assert_eq!(best.encoding(), "cp1251");
127/// assert_eq!(best.decoded().unwrap(), text);
128/// ```
129#[must_use]
130pub fn from_bytes(payload: &[u8]) -> CharsetMatches {
131    from_bytes_with(payload, &DetectionOptions::default(), &NoLogger)
132}
133
134/// Read a file and detect its plausible encodings with default options.
135///
136/// The whole file is read into memory; payloads of [`TOO_BIG_SEQUENCE`]
137/// bytes or more are sampled rather than fully decoded.
138///
139/// ```no_run
140/// let results = charset_norm::from_path("subtitles.srt")?;
141/// if let Some(best) = results.best() {
142///     println!("{} ({})", best.encoding(), best.language());
143/// }
144/// # Ok::<(), std::io::Error>(())
145/// ```
146///
147/// # Errors
148///
149/// Any error reading the file.
150pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<CharsetMatches> {
151    Ok(from_bytes(&std::fs::read(path)?))
152}
153
154/// Whether `payload` looks like binary data rather than text.
155///
156/// This runs a full detection with
157/// [`enable_fallback`](DetectionOptions::enable_fallback) turned off and
158/// reports whether no encoding fits.
159///
160/// ```
161/// assert!(!charset_norm::is_binary("Plain old text, nothing to see here.".as_bytes()));
162/// assert!(charset_norm::is_binary(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\x08\x06\x00\x00\x00\x5c\x72\xa8\x66\x00\x00\x00\x04\x73\x42\x49\x54\x08\x08"));
163/// ```
164#[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/// Detect the plausible encodings of `payload` with custom `options`,
174/// reporting progress to `logger`.
175///
176/// Pass [`NoLogger`] to discard diagnostics.
177///
178/// ```
179/// use charset_norm::{DetectionOptions, NoLogger, codecs, from_bytes_with};
180///
181/// let payload = codecs::encode("Le cœur a ses raisons que la raison ne connaît point.", "cp1252").unwrap();
182/// let options = DetectionOptions {
183///     cp_isolation: vec!["windows-1252".into(), "iso-8859-15".into()],
184///     ..DetectionOptions::default()
185/// };
186///
187/// let results = from_bytes_with(&payload, &options, &NoLogger);
188/// let best = results.best().unwrap();
189/// assert_eq!(best.encoding(), "cp1252");
190/// ```
191#[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/// [`from_bytes_with`] for a payload that is already shared: the matches keep
201/// a reference to it instead of a copy.
202///
203/// ```
204/// use std::sync::Arc;
205/// use charset_norm::{DetectionOptions, NoLogger};
206///
207/// let payload: Arc<[u8]> = Arc::from("Shared, not copied.".as_bytes());
208/// let results = charset_norm::detect(&payload, &DetectionOptions::default(), &NoLogger);
209/// assert!(Arc::ptr_eq(results.best().unwrap().payload(), &payload));
210/// ```
211#[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
235/// Encodings that never carry a trustworthy signal of multi-byte usage.
236fn 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/// Every distinct chunk text seen during one detection, stored once (in a
252/// single arena) with its mess ratio. Chunks are identified by their index.
253#[derive(Default)]
254struct ChunkStore {
255    arena: String,
256    /// (start, end) of each chunk in `arena`.
257    spans: Vec<(usize, usize)>,
258    mess: Vec<f64>,
259    /// Text hash -> most recent chunk with that hash.
260    heads: FxHashMap<u64, u32>,
261    /// Chunk -> previous chunk with the same hash (`u32::MAX` ends the chain).
262    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    /// The id of `text`, storing it (and computing its mess) on first sight.
289    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
313/// Span in `Buffers::coherence_values` of the results per (inclusion list,
314/// chunk).
315type CoherenceCache = FxHashMap<(&'static [&'static str], u32), (usize, usize)>;
316
317/// Working memory of a detection, kept per thread between detections.
318#[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    /// Chunks sampled for the current candidate.
327    chunk_ids: Vec<u32>,
328    /// Decoded payload of the current candidate.
329    text: String,
330    chunk: String,
331    tested: FxHashSet<&'static str>,
332    soft_skip: FxHashSet<&'static str>,
333}
334
335impl Buffers {
336    /// Largest buffer kept for the next detection.
337    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    /// Clear and return to the pool, dropping oversized allocations.
352    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
369/// Mess analysis of one candidate encoding (sampled chunks are left in
370/// `Detector::chunk_ids`).
371struct Sample {
372    mean: f64,
373    early_stop: usize,
374    max_give_up: usize,
375}
376
377/// Facts about one candidate encoding for the current payload.
378struct Candidate {
379    encoding: &'static str,
380    bom: bool,
381    strip_bom: bool,
382    multibyte: bool,
383    languages: &'static [&'static str],
384}
385
386/// State of one detection run (the reference algorithm's local variables),
387/// plus buffers reused from one candidate to the next.
388struct 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    /// Languages of the first coherent single-byte match, once found.
407    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    /// Candidates in trial order: signature, declared encoding, ASCII, UTF-8,
483    /// then every supported encoding (multi-byte first).
484    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    /// Whether `encoding` should be tried at all, recording it as tested.
521    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    /// ASCII, UTF-8 and the declared encoding: their matches end detection
558    /// early and keep their decoded text.
559    fn is_preferred(&self, encoding: &str) -> bool {
560        self.specified == Some(encoding) || matches!(encoding, "ascii" | "utf_8")
561    }
562
563    /// Payload without a stripped signature.
564    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    /// Decode up front when the reference does (multi-byte codecs, or a
574    /// prefix of large payloads). Returns whether `text` now holds the whole
575    /// decoded payload; `Err` rejects the candidate.
576    #[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    /// Measure the mess of sampled chunks, recording them in `chunk_ids`;
601    /// `None` when a chunk does not decode.
602    #[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    /// Keep a rejected ASCII/UTF-8/declared candidate as a last resort.
653    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    /// Languages coherent with the sampled chunks, merged across chunks,
683    /// left in `Buffers::merged`.
684    #[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                // Inclusion lists only name profiled languages.
696                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    /// Try one encoding; `Break` ends detection with a final answer.
726    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    /// Evaluate a candidate, using `text` for its decoded payload.
737    #[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        // Character count of the up-front decode (multi-byte candidates).
748        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            // The whole payload must decode. Only the text of a preferred
776            // encoding is kept up front (as the reference does for large
777            // payloads); other code pages are checked against their table
778            // and decode lazily, as most matches are never read.
779            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    /// Record a plausible candidate and apply the reference's early exits.
793    #[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        // Multi-byte candidates were decoded up front.
849        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}