Skip to main content

rumdl_lib/rules/
md083_mojibake.rs

1//! Rule MD083: Detect mojibake due to encoding issues.
2//!
3//! Mojibake is the result of text being decoded using an unintended character encoding.
4//! It is often caused by a mismatch between the encoding used to create a file and the encoding used to read it.
5//! This rule detects common mojibake sequences, which are typically caused by UTF-8 text being interpreted as Windows-1252 or ISO-8859-1.
6//!
7//! The Mojibake detection regex is based on the work of `ftfy` by Robyn Speer, at https://github.com/rspeer/python-ftfy, under Apache 2.0 License.
8//! The test cases are based on https://github.com/kevinhu/plsfix/blob/main/core/src/badness.rs by Kevin Hu, under Apache 2.0 License.
9
10mod md083_config;
11
12use crate::filtered_lines::FilteredLinesExt;
13use crate::lint_context::LintContext;
14use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use crate::utils::range_utils::byte_to_char_count;
16use md083_config::MD083Config;
17use std::collections::HashSet;
18
19fn build_mojibake_regex() -> regex::Regex {
20    regex::Regex::new(
21        &format!(
22r#"[{c1}]
23|
24[{bad}{lower_accented}{upper_accented}{box}{start_punctuation}{end_punctuation}{currency}{numeric}] [{bad}]
25|
26[a-zA-Z] [{lower_common}{upper_common}] [{bad}]
27|
28[{bad}] [{lower_accented}{upper_accented}{box}{start_punctuation}{end_punctuation}{currency}{numeric}]
29|
30[{lower_accented}{lower_common}{box}{end_punctuation}{currency}{numeric}] [{upper_accented}]
31|
32[{box}{end_punctuation}{currency}{numeric}] [{lower_accented}]
33|
34[{lower_accented}{box}{end_punctuation}] [{currency}]
35|
36\s [{upper_accented}] [{currency}]
37|
38[{upper_accented}{box}] [{numeric}]
39|
40[{lower_accented}{upper_accented}{box}{currency}{end_punctuation}] [{start_punctuation}] [{numeric}]
41|
42[{lower_accented}{upper_accented}{currency}{numeric}{box}] [{end_punctuation}] [{start_punctuation}]
43|
44[{currency}{numeric}{box}] [{start_punctuation}]
45|
46[a-z] [{upper_accented}] [{start_punctuation}{currency}]
47|
48[{box}] [{kaomoji}]
49|
50[{lower_accented}{upper_accented}{currency}{numeric}{start_punctuation}{end_punctuation}] [{box}]
51|
52[{box}] [{end_punctuation}]
53|
54[{lower_accented}{upper_accented}] [{end_punctuation}] \w
55|
56[ÂÃÎÐ][€Šš¢£Ÿž{nbsp}{soft_hyphen}®©°·»{start_punctuation}{end_punctuation}–—´]
57|
58× [²³]
59|
60[ØÙ] [{common}{currency}{bad}{numeric}{start_punctuation}ŸŠ®°µ»]
61[ØÙ] [{common}{currency}{bad}{numeric}{start_punctuation}ŸŠ®°µ»]
62|
63à[²µ¹¼½¾]
64|
65√[±∂†≠®™´≤≥¥µø]
66|
67≈[°¢]
68|
69‚Ä[ìîïòôúùû†°¢π]
70|
71‚[âó][àä°ê]
72|
73вЂ
74|
75[ВГРС][{c1}{bad}{start_punctuation}{end_punctuation}{currency}°µ][ВГРС]
76|
77ГўВЂВ.[A-Za-z]
78|
79Ã[{nbsp}¡]
80|
81^[ÃÂ][\s]
82|
83[a-z.,?!{end_punctuation}] Â [ {start_punctuation}{end_punctuation}]
84|
85β€[™{nbsp}Ά{soft_hyphen}®°]
86|
87[ΒΓΞΟ][{c1}{bad}{start_punctuation}{end_punctuation}{currency}°][ΒΓΞΟ]"#,
88        c1 = "\u{80}\u{81}\u{82}\u{83}\u{84}\u{85}\u{86}\u{87}\u{88}\u{89}\u{8a}\u{8b}\u{8c}\u{8d}\u{8e}\u{8f}\u{90}\u{91}\u{92}\u{93}\u{94}\u{95}\u{96}\u{97}\u{98}\u{99}\u{9a}\u{9b}\u{9c}\u{9d}\u{9e}\u{9f}",
89        bad = "¦¤¨¬¯¶§¸ƒˆˇ˘˛˜†‡‰⌐◊�ªº",
90        lower_accented = "ßà-ñăąćčďđęěğĺľłœŕśşšťüźżžґfifl",
91        upper_accented = "À-ÑØÜÝĂĄĆČĎĐĘĚĞİĹĽŁŃŇŒŘŚŞŠŢŤŮŰŸŹŻŽҐ",
92        box = "│┌┐┘├┤┬┼═-╬▀▄█▌▐░▒▓",
93        start_punctuation = "¡«¿©΄΅‘‚“„•‹\u{f8ff}",
94        end_punctuation = "®»˝”›™",
95        currency = "¢£¥₧€",
96        numeric = "²³¹±¼½¾×µ÷⁄∂∆∏∑√∞∩∫≈≠≡≤≥№",
97        kaomoji = "Ò-ÖÙ-Üò-öø-üŐ°",
98        lower_common = "α-ωάέήίΰа-џ",
99        upper_common = "ÞΑ-ΩΆΈΉΊΌΎΏΪΫЁ-Я",
100        common = "\u{a0}\u{ad}\u{b7}\u{b4}\u{2013}\u{2014}\u{2015}\u{2026}\u{2019}",
101        nbsp = "`\u{a0}",
102        soft_hyphen = "\u{ad}",
103    ).replace('\n', "").replace(' ', "")
104    ).unwrap()
105}
106
107#[derive(Debug, Clone)]
108pub struct MD083DetectMojibake {
109    badness_re: regex::Regex,
110    config: MD083Config,
111    ignored_sequences: HashSet<String>,
112}
113
114impl Default for MD083DetectMojibake {
115    fn default() -> Self {
116        Self::from_config_struct(MD083Config::default())
117    }
118}
119
120impl MD083DetectMojibake {
121    fn from_config_struct(config: MD083Config) -> Self {
122        let ignored_sequences = config.ignore.iter().cloned().collect();
123        Self {
124            badness_re: build_mojibake_regex(),
125            config,
126            ignored_sequences,
127        }
128    }
129
130    fn is_ignored_match(&self, line: &str, start: usize, matched: &str) -> bool {
131        if self.ignored_sequences.is_empty() {
132            return false;
133        }
134
135        let Some(suffix) = line.get(start..) else {
136            return false;
137        };
138
139        self.ignored_sequences
140            .iter()
141            .any(|ignored| ignored == matched || (ignored.starts_with(matched) && suffix.starts_with(ignored)))
142    }
143}
144
145impl Rule for MD083DetectMojibake {
146    fn name(&self) -> &'static str {
147        "MD083"
148    }
149
150    fn description(&self) -> &'static str {
151        "Detect mojibake due to encoding issues"
152    }
153
154    fn category(&self) -> RuleCategory {
155        RuleCategory::Other
156    }
157
158    fn check(&self, ctx: &LintContext) -> LintResult {
159        let mut warnings = Vec::new();
160        let mut filtered = ctx.filtered_lines();
161        if self.config.ignore_code_blocks {
162            filtered = filtered.skip_code_blocks();
163        }
164
165        for line in filtered {
166            for mat in self.badness_re.find_iter(line.content) {
167                let (start, end) = (mat.start(), mat.end());
168                // `ignore_code_blocks` also covers inline code spans: a backtick
169                // span holds a literal or already-encoded snippet, so mojibake
170                // there is intentional. `filtered_lines()` drops fenced and
171                // indented blocks above; this handles the inline case using the
172                // document-absolute byte offset of the match.
173                if self.config.ignore_code_blocks && ctx.is_byte_offset_in_code_span(line.line_info.byte_offset + start)
174                {
175                    continue;
176                }
177                if self.is_ignored_match(line.content, start, mat.as_str()) {
178                    continue;
179                }
180                let line_num = line.line_num;
181                let column = byte_to_char_count(line.content, start);
182                let end_column = byte_to_char_count(line.content, end);
183                warnings.push(LintWarning {
184                    rule_name: Some(self.name().to_string()),
185                    line: line_num,
186                    column,
187                    end_line: line_num,
188                    end_column,
189                    severity: Severity::Warning,
190                    message: "Mojibake detected; text may be mis-encoded".to_string(),
191                    fix: None,
192                });
193            }
194        }
195        Ok(warnings)
196    }
197
198    fn fix_capability(&self) -> FixCapability {
199        FixCapability::Unfixable
200    }
201
202    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
203        // Detection only: use external tools to fix encoding issues
204        Ok(ctx.content.to_string())
205    }
206
207    fn as_any(&self) -> &dyn std::any::Any {
208        self
209    }
210
211    crate::impl_rule_config_methods!(MD083Config);
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::config::{Config, MarkdownFlavor};
218    use crate::rule::LintWarning;
219
220    fn check(content: &str) -> Vec<LintWarning> {
221        let rule = MD083DetectMojibake::default();
222        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
223        rule.check(&ctx).unwrap()
224    }
225
226    fn check_with_rule(rule: &MD083DetectMojibake, content: &str) -> Vec<LintWarning> {
227        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
228        rule.check(&ctx).unwrap()
229    }
230
231    fn assert_mojibake_at(warning: &LintWarning, line: usize, column: usize, end_line: usize, end_column: usize) {
232        assert!(warning.rule_name.as_deref() == Some("MD083"));
233        assert!(warning.message.contains("Mojibake detected"));
234        assert_eq!(warning.line, line);
235        assert_eq!(warning.column, column);
236        assert_eq!(warning.end_line, end_line);
237        assert_eq!(warning.end_column, end_column);
238    }
239
240    #[test]
241    fn test_mojibake() {
242        let results = check("This is a test with mojibake: –\n");
243        assert_eq!(results.len(), 1);
244        assert_mojibake_at(&results[0], 1, 31, 1, 33);
245    }
246
247    #[test]
248    fn test_normal_text() {
249        let results = check("This is a normal line of text.\n");
250        assert!(results.is_empty());
251    }
252
253    #[test]
254    fn test_special_char_1() {
255        let results = check("\u{80}\n");
256        assert_eq!(results.len(), 1);
257        assert_mojibake_at(&results[0], 1, 1, 1, 2);
258    }
259
260    #[test]
261    fn test_special_2() {
262        let results = check("á.");
263        assert_eq!(results.len(), 1);
264        assert_mojibake_at(&results[0], 1, 1, 1, 3);
265    }
266
267    #[test]
268    fn test_empty() {
269        let results = check("");
270        assert!(results.is_empty());
271    }
272
273    // Test checks badness count of a simple sentence with mixed character categories
274    #[test]
275    fn test_mixed_chars() {
276        let results = check("À-Ñ this is some text \u{a0}\u{ad} to test on \u{80}");
277        assert_eq!(results.len(), 1);
278        assert_mojibake_at(&results[0], 1, 37, 1, 38);
279    }
280
281    // Test checks badness count of different capital char sequence
282    #[test]
283    fn test_upper_accented_chars() {
284        let results = check("ÀÑØÜÝĂĄĆČĎĐĘ");
285        assert_eq!(results.len(), 0);
286    }
287
288    // Checks if basic alphanumeric are not considered as bad
289    #[test]
290    fn test_alphanumeric() {
291        let results = check("abc123XYZ");
292        assert_eq!(results.len(), 0);
293    }
294
295    // Checks a text with known badness, should return true
296    #[test]
297    fn test_known_badness() {
298        let results = check("á.");
299        assert_eq!(results.len(), 1);
300        assert_mojibake_at(&results[0], 1, 1, 1, 3);
301    }
302
303    #[test]
304    fn test_numeric_char() {
305        assert!(check("²³¹±¼½¾×µ÷⁄∂∆").is_empty());
306    }
307
308    #[test]
309    fn test_kaomoji_char() {
310        assert!(check("Ò-ÖÙ-Üò-öø-üŐ°").is_empty());
311    }
312
313    #[test]
314    fn test_upper_common_chars() {
315        assert!(check("ÞΑ-ΩΆΈΉΊΌΎΏΪΫЁ-Я").is_empty());
316    }
317
318    #[test]
319    fn test_lower_common_chars() {
320        assert!(check("α-ωάέήίΰа-џ").is_empty());
321    }
322
323    #[test]
324    fn test_currency_chars() {
325        assert!(check("¢£¥₧€").is_empty());
326    }
327
328    #[test]
329    fn test_punctuation_chars() {
330        assert!(check("¡«¿©΄΅‘‚“„•‹\u{f8ff}").is_empty());
331        assert!(check("®»˝”›™").is_empty());
332    }
333
334    #[test]
335    fn test_full_text_with_boundaries() {
336        let results = check("¦¤");
337        assert!(results.len() == 1);
338        assert_mojibake_at(&results[0], 1, 1, 1, 3);
339    }
340
341    #[test]
342    fn test_box_drawing_chars() {
343        assert!(check("│┌┐┘├┤┬┼═-╬▀▄█▌▐░▒▓").is_empty());
344    }
345
346    #[test]
347    fn test_known_badness_emoji() {
348        assert!(check("😀").is_empty());
349    }
350
351    #[test]
352    fn test_spaced_bad_char() {
353        let results = check("   \u{80}   ");
354        assert_eq!(results.len(), 1);
355        assert!(results[0].message.contains("Mojibake detected"));
356        assert_mojibake_at(&results[0], 1, 4, 1, 5);
357    }
358
359    // Test checks badness count of a simple sentence with all bad characters
360    #[test]
361    fn test_all_bad_chars() {
362        let results = check("¦¤¨¬¯¶§¸ƒˆˇ˘˛˜†‡‰⌐◊�ªº");
363        assert_eq!(results.len(), 11);
364        assert!(results[0].message.contains("Mojibake detected"));
365        assert_mojibake_at(&results[0], 1, 1, 1, 3);
366        assert_mojibake_at(&results[1], 1, 3, 1, 5);
367        assert_mojibake_at(&results[2], 1, 5, 1, 7);
368        assert_mojibake_at(&results[3], 1, 7, 1, 9);
369        assert_mojibake_at(&results[4], 1, 9, 1, 11);
370        assert_mojibake_at(&results[5], 1, 11, 1, 13);
371        assert_mojibake_at(&results[6], 1, 13, 1, 15);
372        assert_mojibake_at(&results[7], 1, 15, 1, 17);
373        assert_mojibake_at(&results[8], 1, 17, 1, 19);
374        assert_mojibake_at(&results[9], 1, 19, 1, 21);
375        assert_mojibake_at(&results[10], 1, 21, 1, 23);
376    }
377
378    // Checks if punctuation character are not considered as bad
379    #[test]
380    fn test_punctuation() {
381        assert!(check("!@#$%^&*()_-+={}|[]\\:\";'<>,.?/").is_empty());
382    }
383
384    // Checks a sentence including lower common characters and numbers, should return false
385    #[test]
386    fn test_lower_common_chars_and_numbers() {
387        assert!(check("Один два απο ένα δύο α-ωάέήίΰа-џ 123 £$%").is_empty());
388    }
389
390    // Test checks if non-breaking space and soft hyphen are not considered as bad
391    #[test]
392    fn test_control_chars() {
393        assert_eq!(check("\u{a0}\u{ad}").len(), 0);
394    }
395
396    // Test checks badness of complex sentence with multiple categories
397    #[test]
398    fn test_complex_sentence() {
399        let results = check("Hello, this sentence will have a badness score of 1, because of this \u{80} char.");
400        assert_eq!(results.len(), 1);
401        assert!(results[0].message.contains("Mojibake detected"));
402        assert_mojibake_at(&results[0], 1, 70, 1, 71);
403    }
404
405    #[test]
406    fn test_multiline_mixed_content() {
407        let results = check(
408            "Alpha clean line.\n\
409Broken dash – here.\n\
410Normal text again.\n\
411Another bad pair: á.\n\
412Numbers 12345.\n\
413Trailing bad \u{80}\n\
414Русский текст.\n\
415Symbols are fine: £$%\n\
416Mojibake word – end.\n\
417Last clean line.\n",
418        );
419
420        assert_eq!(results.len(), 4);
421        assert_mojibake_at(&results[0], 2, 13, 2, 15);
422        assert_mojibake_at(&results[1], 4, 19, 4, 21);
423        assert_mojibake_at(&results[2], 6, 14, 6, 15);
424        assert_mojibake_at(&results[3], 9, 15, 9, 17);
425    }
426
427    #[test]
428    fn test_ignore_sequences_config() {
429        let rule = MD083DetectMojibake::from_config_struct(MD083Config {
430            ignore: vec!["–".to_string()],
431            ..Default::default()
432        });
433
434        let results = check_with_rule(&rule, "Keep – ignored but still flag á.\n");
435
436        assert_eq!(results.len(), 1);
437        assert_mojibake_at(&results[0], 1, 33, 1, 35);
438    }
439
440    #[test]
441    fn test_ignore_requires_full_mojibake_sequence() {
442        let rule = MD083DetectMojibake::from_config_struct(MD083Config {
443            ignore: vec!["â".to_string()],
444            ..Default::default()
445        });
446
447        let results = check_with_rule(&rule, "Keep – still flagged.\n");
448
449        assert_eq!(results.len(), 1);
450        assert_mojibake_at(&results[0], 1, 6, 1, 8);
451    }
452
453    #[test]
454    fn test_code_blocks_ignored_by_default() {
455        let results = check(
456            "Paragraph with – outside.\n\
457\n\
458```rust\n\
459let broken = \"á\";\n\
460```\n",
461        );
462
463        assert_eq!(results.len(), 1);
464        assert_mojibake_at(&results[0], 1, 16, 1, 18);
465    }
466
467    #[test]
468    fn test_code_blocks_checked_when_enabled() {
469        let config: Config = toml::from_str(
470            r#"
471            [MD083]
472            ignore-code-blocks = false
473            "#,
474        )
475        .unwrap();
476        let rule = MD083DetectMojibake::from_config(&config);
477        let rule = rule.as_any().downcast_ref::<MD083DetectMojibake>().unwrap();
478
479        let results = check_with_rule(
480            rule,
481            "Paragraph with – outside.\n\
482\n\
483```rust\n\
484let broken = \"á\";\n\
485```\n",
486        );
487
488        assert_eq!(results.len(), 2);
489        assert_mojibake_at(&results[0], 1, 16, 1, 18);
490        assert_mojibake_at(&results[1], 4, 15, 4, 17);
491    }
492
493    #[test]
494    fn test_inline_code_span_ignored_by_default() {
495        // A backtick span holds a literal or already-encoded snippet, so mojibake
496        // inside it is intentional and ignored when ignore_code_blocks is true.
497        let results = check("Broken dash `–` inside inline code.\n");
498        assert!(results.is_empty());
499    }
500
501    #[test]
502    fn test_inline_code_span_checked_when_disabled() {
503        let rule = MD083DetectMojibake::from_config_struct(MD083Config {
504            ignore_code_blocks: false,
505            ..Default::default()
506        });
507        let results = check_with_rule(&rule, "Broken dash `–` inside inline code.\n");
508        assert_eq!(results.len(), 1);
509    }
510
511    #[test]
512    fn test_inline_code_span_prose_still_flagged() {
513        // Mojibake in prose is still flagged even when an inline code span on the
514        // same line is exempted.
515        let results = check("Prose – and code `á` together.\n");
516        assert_eq!(results.len(), 1);
517        assert_eq!(results[0].line, 1);
518    }
519
520    #[test]
521    fn test_multiline_inline_code_span_ignored() {
522        // A code span that spans a line break is exempted across both lines.
523        let results = check("Start `code spanning –\nsecond line á` end.\n");
524        assert!(results.is_empty());
525    }
526
527    // Check that a simple English sentence is not considered "bad"
528    #[test]
529    fn test_simple_sentence() {
530        assert_eq!(check("The quick brown fox jumps over the lazy dog.").len(), 0);
531    }
532
533    // Test checks badness count of an emoji
534    #[test]
535    fn test_emoji() {
536        assert_eq!(check("😀").len(), 0);
537    }
538
539    // Checks a text with single space, should return false
540    #[test]
541    fn test_single_space() {
542        assert_eq!(check(" ").len(), 0);
543    }
544
545    // Test checks badness count of one specific bad character
546    #[test]
547    fn test_single_bad_char() {
548        assert_eq!(check("¦").len(), 0);
549    }
550
551    // Check a text with a non-breaking space, should return false
552    #[test]
553    fn test_non_breaking_space() {
554        assert_eq!(check("Hello, World!\u{a0}").len(), 0);
555    }
556
557    // Check badness calculation with all character categories
558    #[test]
559    fn test_all_categories() {
560        let results =
561            check("¢£¥₧€¡«¿©΄΅‘‚“„•‹\u{f8ff}®»˝”›™²³¹±¼½¾×µ÷⁄∂∆ÞΑ-ΩΆΈΉΊΌΎΏΪΫЁ-Яα-ωάέήίΰа-џ│┌┐┘├┤┬┼═-╬▀▄█▌▐░▒▓");
562        assert_eq!(results.len(), 1);
563        assert!(results[0].message.contains("Mojibake detected"));
564        assert_mojibake_at(&results[0], 1, 5, 1, 7);
565    }
566
567    // Check a text with full-width white space, should return false
568    #[test]
569    fn test_full_width_space() {
570        assert_eq!(check("Hello, World!\u{3000}").len(), 0);
571    }
572
573    // Check badness calculation with a range of special characters
574    #[test]
575    fn test_special() {
576        assert_eq!(check("&quot;ًٌٍََُِّْٕٖٜٟٓٔٗ٘ٙٚٛٝٞ").len(), 0);
577    }
578
579    // Test checks a sentence including upper common characters and numbers, should return false
580    #[test]
581    fn test_upper_common_chars_and_numbers() {
582        assert_eq!(check("One two Α-Ω Ί Ώ Ύ Ό РУС 123 £$%").len(), 0);
583    }
584
585    // Test checks if a simple Japanese sentence is not considered as bad
586    #[test]
587    fn test_japanese() {
588        assert_eq!(check("こんにちは、世界!").len(), 0);
589    }
590
591    // Checks a text fully composed of badness, should return true
592    #[test]
593    fn test_full_badness() {
594        let results = check("Ã\u{80}\u{82}€‚");
595        assert_eq!(results.len(), 3);
596        assert!(results[0].message.contains("Mojibake detected"));
597        assert_mojibake_at(&results[0], 1, 2, 1, 3);
598        assert_mojibake_at(&results[1], 1, 3, 1, 4);
599        assert_mojibake_at(&results[2], 1, 4, 1, 6);
600    }
601
602    // Test checks badness count of a simple sentence with various special characters
603    #[test]
604    fn test_special_chars() {
605        let results = check("This sentence contains these \u{a0}\u{ad}\u{80} special characters.");
606        assert_eq!(results.len(), 1);
607        assert!(results[0].message.contains("Mojibake detected"));
608        assert_mojibake_at(&results[0], 1, 32, 1, 33);
609    }
610
611    // Test checks if a simple Chinese sentence is not considered as bad
612    #[test]
613    fn test_chinese() {
614        assert_eq!(check("你好,世界!").len(), 0);
615    }
616
617    // Test checks badness of sentence with mixed languages with bad character
618    #[test]
619    fn test_mixed_languages() {
620        let results = check("This is English and これは日本語です and dies ist Deutsch \u{80}");
621        assert_eq!(results.len(), 1);
622        assert_mojibake_at(&results[0], 1, 51, 1, 52);
623    }
624
625    // Test checks if a simple Arabic sentence is not considered as bad
626    #[test]
627    fn test_arabic() {
628        assert_eq!(check("مرحبا بك في النص باللغة العربية!").len(), 0);
629    }
630
631    // Test checks badness of a sentence with kaomoji
632    #[test]
633    fn test_kaomoji_sentence() {
634        assert_eq!(check("This is a sentence with kaomoji (ˆ_ˆ)").len(), 0);
635    }
636
637    // Test checks if a simple Russian sentence is not considered as bad
638    #[test]
639    fn test_russian() {
640        assert_eq!(check("Всем привет, мир!").len(), 0);
641    }
642
643    // Test checks badness of sentence with various punctuation and numeric characters
644    #[test]
645    fn test_punctuation_numeric() {
646        assert_eq!(check("This (®»˝”›™²³¹±¼½¾×µ÷⁄∂∆) is text.").len(), 0);
647    }
648
649    // Checks if a sentence that contains all common upper chars is not considered bad
650    #[test]
651    fn test_all_upper_common() {
652        assert_eq!(check("ÞΑ-ΩΆΈΉΊΌΎΏΪΫЁ-Я").len(), 0);
653    }
654
655    // Checks a sentence with consecutive bad ```rust characters
656    #[test]
657    fn test_consecutive_bad() {
658        assert_eq!(
659            check("This sentence has consecutive bad characters \u{80}\u{80}\u{80}\u{80}").len(),
660            4
661        );
662
663        let results = check("This sentence has consecutive bad characters \u{80}\u{80}\u{80}\u{80}");
664        assert_mojibake_at(&results[0], 1, 46, 1, 47);
665        assert_mojibake_at(&results[1], 1, 47, 1, 48);
666        assert_mojibake_at(&results[2], 1, 48, 1, 49);
667        assert_mojibake_at(&results[3], 1, 49, 1, 50);
668    }
669
670    // Checks that valid french sentences with a œ ligature are not considered as bad
671    #[test]
672    fn test_french_oe_ligature() {
673        assert_eq!(check("Œuvre d'art").len(), 0);
674        assert_eq!(check("Cœur de l'œuvre").len(), 0);
675        assert_eq!(check("L'œuvre est magnifique").len(), 0);
676        assert_eq!(
677            check("Ce remarquable Œuf Fabergé est une œuvre d'art en forme d'œuf.").len(),
678            0
679        );
680        assert_eq!(
681            check("La ligature œ est formée de la contraction des caractères o et e.").len(),
682            0
683        );
684        assert_eq!(check("La ligature \"œ\" s'écrit en majuscules \"Œ\".").len(), 0);
685    }
686
687    // Test single letters enumerations
688    #[test]
689    fn test_single_letters() {
690        assert_eq!(check("a b c d e f g h i j k l m n o p q r s t u v w x y z").len(), 0);
691        assert_eq!(check("A B C D E F G H I J K L M N O P Q R S T U V W X Y Z").len(), 0);
692        assert_eq!(check("sunt cuvinte cu  și Î").len(), 0);
693        assert_eq!(check("Vietnamese nguyên âm có dấu mũ: a  e Ê o Ô").len(), 0);
694    }
695}