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