1mod 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 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 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]
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]
283 fn test_upper_accented_chars() {
284 let results = check("ÀÑØÜÝĂĄĆČĎĐĘ");
285 assert_eq!(results.len(), 0);
286 }
287
288 #[test]
290 fn test_alphanumeric() {
291 let results = check("abc123XYZ");
292 assert_eq!(results.len(), 0);
293 }
294
295 #[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]
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 #[test]
380 fn test_punctuation() {
381 assert!(check("!@#$%^&*()_-+={}|[]\\:\";'<>,.?/").is_empty());
382 }
383
384 #[test]
386 fn test_lower_common_chars_and_numbers() {
387 assert!(check("Один два απο ένα δύο α-ωάέήίΰа-џ 123 £$%").is_empty());
388 }
389
390 #[test]
392 fn test_control_chars() {
393 assert_eq!(check("\u{a0}\u{ad}").len(), 0);
394 }
395
396 #[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 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 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 let results = check("Start `code spanning –\nsecond line á` end.\n");
524 assert!(results.is_empty());
525 }
526
527 #[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]
535 fn test_emoji() {
536 assert_eq!(check("😀").len(), 0);
537 }
538
539 #[test]
541 fn test_single_space() {
542 assert_eq!(check(" ").len(), 0);
543 }
544
545 #[test]
547 fn test_single_bad_char() {
548 assert_eq!(check("¦").len(), 0);
549 }
550
551 #[test]
553 fn test_non_breaking_space() {
554 assert_eq!(check("Hello, World!\u{a0}").len(), 0);
555 }
556
557 #[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 #[test]
569 fn test_full_width_space() {
570 assert_eq!(check("Hello, World!\u{3000}").len(), 0);
571 }
572
573 #[test]
575 fn test_special() {
576 assert_eq!(check(""ًٌٍََُِّْٕٖٜٟٓٔٗ٘ٙٚٛٝٞ").len(), 0);
577 }
578
579 #[test]
581 fn test_upper_common_chars_and_numbers() {
582 assert_eq!(check("One two Α-Ω Ί Ώ Ύ Ό РУС 123 £$%").len(), 0);
583 }
584
585 #[test]
587 fn test_japanese() {
588 assert_eq!(check("こんにちは、世界!").len(), 0);
589 }
590
591 #[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]
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]
613 fn test_chinese() {
614 assert_eq!(check("你好,世界!").len(), 0);
615 }
616
617 #[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]
627 fn test_arabic() {
628 assert_eq!(check("مرحبا بك في النص باللغة العربية!").len(), 0);
629 }
630
631 #[test]
633 fn test_kaomoji_sentence() {
634 assert_eq!(check("This is a sentence with kaomoji (ˆ_ˆ)").len(), 0);
635 }
636
637 #[test]
639 fn test_russian() {
640 assert_eq!(check("Всем привет, мир!").len(), 0);
641 }
642
643 #[test]
645 fn test_punctuation_numeric() {
646 assert_eq!(check("This (®»˝”›™²³¹±¼½¾×µ÷⁄∂∆) is text.").len(), 0);
647 }
648
649 #[test]
651 fn test_all_upper_common() {
652 assert_eq!(check("ÞΑ-ΩΆΈΉΊΌΎΏΪΫЁ-Я").len(), 0);
653 }
654
655 #[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 #[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]
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}