Skip to main content

charset_norm/
matches.rs

1//! Detection results: [`CharsetMatches`] and [`CharsetMatch`], re-exported at
2//! the crate root.
3
4use std::borrow::Cow;
5use std::hash::{DefaultHasher, Hasher};
6use std::sync::{Arc, OnceLock};
7
8use crate::codecs::{self, DecodeError, Errors};
9use crate::encoding::{self, encoding_indication};
10use crate::{Error, TOO_BIG_SEQUENCE, pyfloat, unicode};
11
12/// One plausible encoding for a payload.
13///
14/// A match holds the analysed payload, the encoding, and the two scores the
15/// verdict rests on:
16///
17/// - [`chaos`](Self::chaos): how noisy the decoded text looks, from `0.0`
18///   (clean) upward. Lower is better.
19/// - [`coherence`](Self::coherence): how well the text matches the letter
20///   frequencies of its best language, in `[0, 1]`. Higher is better.
21///
22/// The decoded text is computed lazily and cached; cloning a match never
23/// copies the payload or the text.
24///
25/// ```
26/// use charset_norm::codecs;
27///
28/// let text = "Größere Änderungen an der Straßenführung sind für nächste Woche geplant.";
29/// let payload = codecs::encode(text, "cp1252").unwrap();
30///
31/// let results = charset_norm::from_bytes(&payload);
32/// let best = results.best().unwrap();
33///
34/// assert_eq!(best.decoded().unwrap(), text);
35/// assert!(best.chaos() < 0.1);
36/// assert!(best.coherence() > 0.0);
37/// assert!(!best.has_sig_or_bom());
38///
39/// // Code pages that decode these bytes to the very same text are
40/// // reported alongside the best match rather than as separate results.
41/// assert!(best.could_be_from_charset().contains(&"cp1252"));
42/// ```
43#[derive(Clone, Debug)]
44pub struct CharsetMatch {
45    payload: Arc<[u8]>,
46    encoding: Cow<'static, str>,
47    chaos: f64,
48    has_sig_or_bom: bool,
49    languages: Arc<[(Cow<'static, str>, f64)]>,
50    decoded: OnceLock<Arc<String>>,
51    preemptive_declaration: Option<Cow<'static, str>>,
52    submatches: Vec<CharsetMatch>,
53    fingerprint: OnceLock<u64>,
54    char_count: OnceLock<usize>,
55    alphabets: OnceLock<Vec<&'static str>>,
56}
57
58impl CharsetMatch {
59    /// Build a match. `languages` holds `(language, coherence)` pairs, best
60    /// first; `decoded` may carry the already decoded text.
61    ///
62    /// Names are borrowed when static (as produced by detection) and owned
63    /// otherwise; cloning a match never copies its payload or text.
64    pub fn new(
65        payload: impl Into<Arc<[u8]>>,
66        encoding: impl Into<Cow<'static, str>>,
67        chaos: f64,
68        has_sig_or_bom: bool,
69        languages: impl Into<Arc<[(Cow<'static, str>, f64)]>>,
70        decoded: Option<String>,
71        preemptive_declaration: Option<Cow<'static, str>>,
72    ) -> Self {
73        let decoded_cell = OnceLock::new();
74        if let Some(text) = decoded {
75            let _ = decoded_cell.set(Arc::new(text));
76        }
77        Self {
78            payload: payload.into(),
79            encoding: encoding.into(),
80            chaos,
81            has_sig_or_bom,
82            languages: languages.into(),
83            decoded: decoded_cell,
84            preemptive_declaration,
85            submatches: Vec::new(),
86            fingerprint: OnceLock::new(),
87            char_count: OnceLock::new(),
88            alphabets: OnceLock::new(),
89        }
90    }
91
92    /// Canonical (`CPython`) name of the encoding, e.g. `"cp1252"`.
93    pub fn encoding(&self) -> &str {
94        &self.encoding
95    }
96
97    /// Other names of [`encoding`](Self::encoding).
98    pub fn encoding_aliases(&self) -> Vec<&'static str> {
99        encoding::aliases(&self.encoding)
100    }
101
102    /// Mess ratio of the decoded text; lower is better.
103    pub fn chaos(&self) -> f64 {
104        self.chaos
105    }
106
107    /// [`chaos`](Self::chaos) as a percentage, rounded to 3 decimals.
108    pub fn percent_chaos(&self) -> f64 {
109        pyfloat::round(self.chaos * 100.0, 3)
110    }
111
112    /// Coherence of the best detected language, in `[0, 1]`.
113    pub fn coherence(&self) -> f64 {
114        self.languages.first().map_or(0.0, |value| value.1)
115    }
116
117    /// [`coherence`](Self::coherence) as a percentage, rounded to 3 decimals.
118    pub fn percent_coherence(&self) -> f64 {
119        pyfloat::round(self.coherence() * 100.0, 3)
120    }
121
122    /// Whether the payload starts with this encoding's signature or BOM.
123    pub fn has_sig_or_bom(&self) -> bool {
124        self.has_sig_or_bom
125    }
126
127    /// Detected languages with their coherence, best first.
128    pub fn language_ratios(&self) -> &[(Cow<'static, str>, f64)] {
129        &self.languages
130    }
131
132    /// Detected languages, best first.
133    pub fn languages(&self) -> Vec<&str> {
134        self.languages.iter().map(|item| &*item.0).collect()
135    }
136
137    /// Most probable language, falling back on what the encoding suggests.
138    pub fn language(&self) -> &str {
139        if let Some((language, _)) = self.languages.first() {
140            return language;
141        }
142        if self.could_be_from("ascii") {
143            return "English";
144        }
145        let languages = encoding::target_languages(&self.encoding);
146        match languages.first() {
147            Some(first) if !languages.contains(&"Latin Based") => first,
148            _ => "Unknown",
149        }
150    }
151
152    /// The analysed payload.
153    pub fn raw(&self) -> &[u8] {
154        &self.payload
155    }
156
157    /// The analysed payload, shared.
158    pub fn payload(&self) -> &Arc<[u8]> {
159        &self.payload
160    }
161
162    /// Encoding declared inside the payload, if any was found.
163    pub fn preemptive_declaration(&self) -> Option<&str> {
164        self.preemptive_declaration.as_deref()
165    }
166
167    /// The payload decoded with [`encoding`](Self::encoding).
168    ///
169    /// # Errors
170    ///
171    /// [`DecodeError::Invalid`] when the payload is not valid for the encoding,
172    /// [`DecodeError::Unknown`] when the encoding has no native codec.
173    pub fn decoded(&self) -> Result<&str, DecodeError> {
174        if let Some(text) = self.decoded.get() {
175            return Ok(text);
176        }
177        let mut text = codecs::decode(&self.payload, &self.encoding, Errors::Strict)?;
178        if self.has_sig_or_bom && self.encoding == "utf_7" && text.starts_with('\u{feff}') {
179            text.remove(0);
180        }
181        Ok(self.decoded.get_or_init(|| Arc::new(text)))
182    }
183
184    /// The decoded text if it is already available, without decoding.
185    pub fn cached_decoded(&self) -> Option<&str> {
186        self.decoded.get().map(|text| text.as_str())
187    }
188
189    /// Replace (or drop) the cached decoded text.
190    pub fn set_decoded(&mut self, text: Option<String>) {
191        self.decoded = OnceLock::new();
192        if let Some(text) = text {
193            let _ = self.decoded.set(Arc::new(text));
194        }
195    }
196
197    /// Stable 64-bit hash of the decoded text.
198    ///
199    /// # Errors
200    ///
201    /// A [`DecodeError`] when the payload cannot be decoded.
202    pub fn fingerprint(&self) -> Result<u64, DecodeError> {
203        if let Some(value) = self.fingerprint.get() {
204            return Ok(*value);
205        }
206        let mut hasher = DefaultHasher::new();
207        hasher.write(self.decoded()?.as_bytes());
208        Ok(*self.fingerprint.get_or_init(|| hasher.finish()))
209    }
210
211    /// Share of the payload's bytes spent on multi-byte sequences.
212    ///
213    /// # Errors
214    ///
215    /// A [`DecodeError`] when the payload cannot be decoded.
216    pub fn multi_byte_usage(&self) -> Result<f64, DecodeError> {
217        if self.payload.is_empty() {
218            return Ok(0.0);
219        }
220        let count = if let Some(count) = self.char_count.get() {
221            *count
222        } else {
223            let text = self.decoded()?;
224            *self.char_count.get_or_init(|| text.chars().count())
225        };
226        Ok(1.0 - count as f64 / self.payload.len() as f64)
227    }
228
229    /// Unicode ranges present in the decoded text, sorted by name.
230    ///
231    /// # Errors
232    ///
233    /// A [`DecodeError`] when the payload cannot be decoded.
234    pub fn alphabets(&self) -> Result<&[&'static str], DecodeError> {
235        if let Some(ranges) = self.alphabets.get() {
236            return Ok(ranges);
237        }
238        let mut seen = vec![false; unicode::ranges().len()];
239        for character in self.decoded()?.chars() {
240            let index = unicode::props(character).range;
241            if index != unicode::NO_RANGE {
242                seen[index as usize] = true;
243            }
244        }
245        let mut ranges: Vec<&'static str> = unicode::ranges()
246            .iter()
247            .zip(seen)
248            .filter_map(|(range, seen)| seen.then_some(range.name))
249            .collect();
250        ranges.sort_unstable();
251        Ok(self.alphabets.get_or_init(|| ranges))
252    }
253
254    /// Other matches that decode to the very same text with the same chaos.
255    ///
256    /// Many code pages differ only in a few byte values, so a payload that
257    /// avoids those bytes is equally valid in all of them. Such matches are
258    /// grouped under the best-ranked one instead of being listed separately
259    /// in [`CharsetMatches`]; [`could_be_from_charset`](Self::could_be_from_charset)
260    /// lists every encoding of the group.
261    pub fn submatches(&self) -> &[CharsetMatch] {
262        &self.submatches
263    }
264
265    /// Detach and return the submatches.
266    pub fn take_submatches(&mut self) -> Vec<CharsetMatch> {
267        std::mem::take(&mut self.submatches)
268    }
269
270    /// Whether other encodings decode to the same text.
271    pub fn has_submatch(&self) -> bool {
272        !self.submatches.is_empty()
273    }
274
275    /// Whether this match or one of its submatches uses `encoding`.
276    fn could_be_from(&self, encoding: &str) -> bool {
277        self.encoding == encoding || self.submatches.iter().any(|item| item.encoding == encoding)
278    }
279
280    /// This match's encoding followed by those of its submatches.
281    pub fn could_be_from_charset(&self) -> Vec<&str> {
282        std::iter::once(&*self.encoding)
283            .chain(self.submatches.iter().map(|item| &*item.encoding))
284            .collect()
285    }
286
287    /// Whether `other` has the same encoding and decoded text.
288    ///
289    /// # Errors
290    ///
291    /// A [`DecodeError`] when either payload cannot be decoded.
292    pub fn same_as(&self, other: &CharsetMatch) -> Result<bool, DecodeError> {
293        Ok(self.encoding == other.encoding && self.fingerprint()? == other.fingerprint()?)
294    }
295
296    /// Attach `other` as a submatch. Returns `false` (and drops it) when it
297    /// is the same match as `self`.
298    ///
299    /// # Errors
300    ///
301    /// A [`DecodeError`] when either payload cannot be decoded.
302    pub fn add_submatch(&mut self, mut other: CharsetMatch) -> Result<bool, DecodeError> {
303        if self.same_as(&other)? {
304            return Ok(false);
305        }
306        other.set_decoded(None);
307        self.submatches.push(other);
308        Ok(true)
309    }
310
311    /// Whether this match, if added to a result list holding `existing`,
312    /// would be folded into it as a submatch (same text, same chaos).
313    ///
314    /// # Errors
315    ///
316    /// A [`DecodeError`] when either payload cannot be decoded.
317    #[expect(
318        clippy::float_cmp,
319        reason = "only identical chaos values denote the same result"
320    )]
321    pub fn is_duplicate_of(&self, existing: &CharsetMatch) -> Result<bool, DecodeError> {
322        if self.payload.len() >= TOO_BIG_SEQUENCE || existing.chaos != self.chaos {
323            return Ok(false);
324        }
325        // Comparing the texts is exact, and cheaper than hashing both.
326        Ok(existing.decoded()? == self.decoded()?)
327    }
328
329    /// Whether this match ranks before `other` (lower chaos, then higher
330    /// coherence, then heavier multi-byte usage).
331    pub fn ranks_before(&self, other: &CharsetMatch) -> bool {
332        let chaos_difference = (self.chaos - other.chaos).abs();
333        let coherence_difference = (self.coherence() - other.coherence()).abs();
334        if chaos_difference < 0.005 && coherence_difference > 0.02 {
335            return self.coherence() > other.coherence();
336        }
337        if chaos_difference < 0.005 && coherence_difference <= 0.02 {
338            if self.payload.len() >= TOO_BIG_SEQUENCE {
339                return self.chaos < other.chaos;
340            }
341            return self.multi_byte_usage().unwrap_or(0.0)
342                > other.multi_byte_usage().unwrap_or(0.0);
343        }
344        self.chaos < other.chaos
345    }
346
347    /// Decoded text with any in-document encoding declaration rewritten to
348    /// `encoding`, ready to be re-encoded.
349    ///
350    /// # Errors
351    ///
352    /// [`OutputError`] when the payload does not decode or, if a declaration
353    /// must be rewritten, the target encoding is unknown.
354    pub fn output_text(&self, encoding: &str) -> Result<String, OutputError> {
355        let mut decoded = self.decoded().map_err(OutputError::Decode)?.to_owned();
356        let declared_non_utf8 = self.preemptive_declaration.as_ref().is_some_and(|value| {
357            !matches!(value.to_lowercase().as_str(), "utf-8" | "utf8" | "utf_8")
358        });
359        if declared_non_utf8 {
360            let prefix_end = decoded
361                .char_indices()
362                .nth(8192)
363                .map_or(decoded.len(), |(offset, _)| offset);
364            let found = encoding_indication()
365                .captures(&decoded[..prefix_end])
366                .and_then(|captures| Some((captures.get(0)?.range(), captures.get(1)?.range())));
367            if let Some((full, group)) = found {
368                let replacement = encoding::iana_name(encoding, true)
369                    .map_err(OutputError::Encoding)?
370                    .replace('_', "-");
371                let patched = decoded[full.clone()].replace(&decoded[group], &replacement);
372                decoded = format!(
373                    "{}{}{}",
374                    &decoded[..full.start],
375                    patched,
376                    &decoded[full.end..]
377                );
378            }
379        }
380        Ok(decoded)
381    }
382
383    /// The payload transcoded to `encoding` (unencodable characters become
384    /// `?`), with in-document declarations updated.
385    ///
386    /// ```
387    /// let results = charset_norm::from_bytes("Ça va très bien".as_bytes());
388    /// let best = results.best().unwrap();
389    /// assert_eq!(best.output("utf_8").unwrap(), "Ça va très bien".as_bytes());
390    /// ```
391    ///
392    /// # Errors
393    ///
394    /// [`OutputError`] when the payload does not decode, the target encoding is
395    /// unknown, or it has no native encoder.
396    pub fn output(&self, encoding: &str) -> Result<Vec<u8>, OutputError> {
397        let text = self.output_text(encoding)?;
398        codecs::encode(&text, encoding).ok_or_else(|| OutputError::Unsupported(encoding.to_owned()))
399    }
400}
401
402/// Why [`CharsetMatch::output`] or [`CharsetMatch::output_text`] failed.
403///
404/// ```
405/// use charset_norm::OutputError;
406///
407/// let results = charset_norm::from_bytes("plain text".as_bytes());
408/// let best = results.best().unwrap();
409/// assert!(matches!(best.output("no-such-codec"), Err(OutputError::Unsupported(_))));
410/// ```
411#[derive(Clone, Debug, PartialEq, Eq)]
412#[non_exhaustive]
413pub enum OutputError {
414    /// The payload could not be decoded.
415    Decode(DecodeError),
416    /// The target encoding name is unknown.
417    Encoding(Error),
418    /// No native encoder exists for the target encoding.
419    Unsupported(String),
420}
421
422impl std::fmt::Display for OutputError {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        match self {
425            OutputError::Decode(error) => error.fmt(f),
426            OutputError::Encoding(error) => error.fmt(f),
427            OutputError::Unsupported(name) => write!(f, "no native encoder for '{name}'"),
428        }
429    }
430}
431
432impl std::error::Error for OutputError {}
433
434/// Sort `items` by a "ranks before" predicate, reproducing `CPython`'s
435/// `list.sort()` order for fewer than 64 items (the order is observable
436/// because the predicate is not transitive). Larger inputs use a stable
437/// binary insertion sort.
438pub fn sort_by_rank<T>(items: &mut [T], mut lt: impl FnMut(&T, &T) -> bool) {
439    let n = items.len();
440    if n < 2 {
441        return;
442    }
443    // CPython's count_run, only for short lists (a single run + binarysort).
444    let mut run = 1;
445    if n < 64 {
446        run = 2;
447        if lt(&items[1], &items[0]) {
448            while run < n && lt(&items[run], &items[run - 1]) {
449                run += 1;
450            }
451            items[..run].reverse();
452        } else {
453            while run < n && !lt(&items[run], &items[run - 1]) {
454                run += 1;
455            }
456        }
457    }
458    for start in run..n {
459        let mut left = 0;
460        let mut right = start;
461        while left < right {
462            let middle = left + ((right - left) >> 1);
463            if lt(&items[start], &items[middle]) {
464                right = middle;
465            } else {
466                left = middle + 1;
467            }
468        }
469        items[left..=start].rotate_right(1);
470    }
471}
472
473/// Plausible encodings for a payload, best first.
474///
475/// Returned by [`from_bytes`](crate::from_bytes) and the other detection
476/// functions. [`best`](Self::best) is the verdict; the rest are less likely
477/// alternatives. Empty when no encoding fits, which usually means the payload
478/// is binary.
479///
480/// ```
481/// use charset_norm::codecs;
482///
483/// let payload = codecs::encode("Меня зовут Анна, я живу в Москве и работаю учителем.", "koi8_r").unwrap();
484/// let results = charset_norm::from_bytes(&payload);
485///
486/// assert_eq!(results.best().unwrap().encoding(), "koi8_r");
487///
488/// for candidate in &results {
489///     println!("{}: chaos {:.1}%", candidate.encoding(), candidate.percent_chaos());
490/// }
491///
492/// // Look a match up by any alias of its encoding or of one of its submatches.
493/// assert!(results.get_by_encoding("KOI8-R").is_some());
494/// ```
495#[derive(Clone, Debug, Default)]
496pub struct CharsetMatches {
497    results: Vec<CharsetMatch>,
498}
499
500impl CharsetMatches {
501    /// Rank `results`, best first.
502    pub fn new(mut results: Vec<CharsetMatch>) -> Self {
503        sort_by_rank(&mut results, CharsetMatch::ranks_before);
504        Self { results }
505    }
506
507    /// Wrap results that are already in rank order.
508    pub(crate) fn from_sorted(results: Vec<CharsetMatch>) -> Self {
509        Self { results }
510    }
511
512    /// Add a match. One decoding to the same text with the same chaos as an
513    /// existing match becomes its submatch instead.
514    pub fn push(&mut self, item: CharsetMatch) {
515        for existing in &mut self.results {
516            if item.is_duplicate_of(existing).unwrap_or(false) {
517                let mut item = item;
518                item.set_decoded(None);
519                existing.submatches.push(item);
520                return;
521            }
522        }
523        self.results.push(item);
524        sort_by_rank(&mut self.results, CharsetMatch::ranks_before);
525    }
526
527    /// The most probable match.
528    #[must_use]
529    pub fn best(&self) -> Option<&CharsetMatch> {
530        self.results.first()
531    }
532
533    /// Number of distinct matches (submatches excluded).
534    #[must_use]
535    pub fn len(&self) -> usize {
536        self.results.len()
537    }
538
539    /// Whether no encoding fits the payload (it is likely binary).
540    #[must_use]
541    pub fn is_empty(&self) -> bool {
542        self.results.is_empty()
543    }
544
545    /// Match at `index` in rank order.
546    #[must_use]
547    pub fn get(&self, index: usize) -> Option<&CharsetMatch> {
548        self.results.get(index)
549    }
550
551    /// The match that could come from `encoding` (any alias), if any.
552    #[must_use]
553    pub fn get_by_encoding(&self, encoding: &str) -> Option<&CharsetMatch> {
554        let name = encoding::iana_name(encoding, false).ok()?;
555        self.results.iter().find(|item| item.could_be_from(&name))
556    }
557
558    /// Matches in rank order.
559    pub fn iter(&self) -> std::slice::Iter<'_, CharsetMatch> {
560        self.results.iter()
561    }
562
563    /// Matches in rank order, as a vector.
564    #[must_use]
565    pub fn into_vec(self) -> Vec<CharsetMatch> {
566        self.results
567    }
568}
569
570impl IntoIterator for CharsetMatches {
571    type Item = CharsetMatch;
572    type IntoIter = std::vec::IntoIter<CharsetMatch>;
573
574    fn into_iter(self) -> Self::IntoIter {
575        self.results.into_iter()
576    }
577}
578
579impl<'a> IntoIterator for &'a CharsetMatches {
580    type Item = &'a CharsetMatch;
581    type IntoIter = std::slice::Iter<'a, CharsetMatch>;
582
583    fn into_iter(self) -> Self::IntoIter {
584        self.results.iter()
585    }
586}
587
588/// Result list with the lazy sorting of the reference implementation: items
589/// are ranked only when the best one is requested.
590pub(crate) struct PendingMatches {
591    results: Vec<CharsetMatch>,
592    sorted: bool,
593}
594
595impl PendingMatches {
596    pub(crate) fn new() -> Self {
597        Self {
598            results: Vec::new(),
599            sorted: true,
600        }
601    }
602
603    pub(crate) fn len(&self) -> usize {
604        self.results.len()
605    }
606
607    #[cfg_attr(feature = "hotpath", hotpath::measure)]
608    pub(crate) fn push(&mut self, item: CharsetMatch) {
609        for existing in &mut self.results {
610            if item.is_duplicate_of(existing).unwrap_or(false) {
611                let mut item = item;
612                item.set_decoded(None);
613                existing.submatches.push(item);
614                return;
615            }
616        }
617        self.results.push(item);
618        self.sorted = false;
619    }
620
621    fn sort(&mut self) {
622        if !self.sorted {
623            sort_by_rank(&mut self.results, CharsetMatch::ranks_before);
624            self.sorted = true;
625        }
626    }
627
628    pub(crate) fn best(&mut self) -> Option<&CharsetMatch> {
629        self.sort();
630        self.results.first()
631    }
632
633    pub(crate) fn take_best(mut self) -> Option<CharsetMatch> {
634        self.sort();
635        self.results.into_iter().next()
636    }
637
638    pub(crate) fn into_matches(mut self) -> CharsetMatches {
639        self.sort();
640        CharsetMatches::from_sorted(self.results)
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    fn sample(encoding: &'static str, chaos: f64, text: &str) -> CharsetMatch {
649        CharsetMatch::new(
650            text.as_bytes().to_vec(),
651            encoding,
652            chaos,
653            false,
654            Vec::new(),
655            None,
656            None,
657        )
658    }
659
660    #[test]
661    fn ranking_and_merging() {
662        let mut matches = CharsetMatches::default();
663        matches.push(sample("utf_8", 0.3, "hello"));
664        matches.push(sample("ascii", 0.1, "hello"));
665        matches.push(sample("latin_1", 0.1, "hello"));
666        assert_eq!(matches.len(), 2);
667        let best = matches.best().unwrap();
668        assert_eq!(best.encoding(), "ascii");
669        assert_eq!(best.could_be_from_charset(), ["ascii", "latin_1"]);
670        assert_eq!(best.language(), "English");
671        assert!(matches.get_by_encoding("latin-1").is_some());
672    }
673
674    #[test]
675    fn short_sort_matches_cpython() {
676        let mut values = vec![3, 1, 2, 5, 4];
677        sort_by_rank(&mut values, |a, b| a < b);
678        assert_eq!(values, [1, 2, 3, 4, 5]);
679        let mut long: Vec<u32> = (0..100).rev().collect();
680        sort_by_rank(&mut long, |a, b| a < b);
681        assert!(long.windows(2).all(|pair| pair[0] <= pair[1]));
682    }
683}