1mod md084_config;
18
19use crate::lint_context::LintContext;
20use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
21use crate::utils::unicode;
22use md084_config::MD084Config;
23
24#[derive(Debug, Clone)]
25pub struct MD084InvisibleCharacters {
26 config: MD084Config,
27}
28
29impl Default for MD084InvisibleCharacters {
30 fn default() -> Self {
31 Self::from_config_struct(MD084Config::default())
32 }
33}
34
35impl MD084InvisibleCharacters {
36 fn from_config_struct(config: MD084Config) -> Self {
37 Self { config }
38 }
39
40 #[inline]
41 fn is_allowed(&self, c: char) -> bool {
42 self.config.allow.contains(&c)
43 }
44
45 #[inline]
47 fn is_markup_char(c: char) -> bool {
48 unicode::is_deprecated_char(c) || unicode::is_unsuitable_for_markup_char(c)
49 }
50
51 #[inline]
55 fn is_annotation_delimiter(c: char) -> bool {
56 matches!(c as u32, 0xFFF9..=0xFFFB)
57 }
58
59 #[inline]
63 fn draws_no_glyph(c: char) -> bool {
64 unicode::is_invisible_char(c) || Self::is_annotation_delimiter(c)
65 }
66
67 fn markup_finding(c: char) -> Option<(String, Option<String>)> {
70 let codepoint = unicode::format_codepoint(c);
71 if unicode::is_deprecated_char(c) {
72 return Some((format!("Deprecated Unicode code point {codepoint} detected"), None));
73 }
74 if !unicode::is_unsuitable_for_markup_char(c) {
75 return None;
76 }
77 let replacement = match c as u32 {
80 0x0340 => Some("\u{0300}".to_string()), 0x0341 => Some("\u{0301}".to_string()), _ => None,
83 };
84 Some((
85 format!("Unicode code point {codepoint} is not suitable for use with markup"),
86 replacement,
87 ))
88 }
89
90 fn is_variation_selector(c: char) -> bool {
94 matches!(
95 c as u32,
96 0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
100 }
101
102 const ZWJ: char = '\u{200D}';
104
105 fn is_visible_base(chars: &[char], index: usize) -> bool {
109 chars
110 .get(index)
111 .is_some_and(|&c| !c.is_whitespace() && !Self::draws_no_glyph(c))
112 }
113
114 fn follows_visible_base(chars: &[char], index: usize) -> bool {
118 let Some(prev) = index.checked_sub(1) else {
119 return false;
120 };
121
122 Self::is_visible_base(chars, prev)
123 || (Self::is_variation_selector(chars[prev])
124 && prev
125 .checked_sub(1)
126 .is_some_and(|base| Self::is_visible_base(chars, base)))
127 }
128
129 fn is_presentation(chars: &[char], index: usize) -> bool {
137 let c = chars[index];
138
139 if Self::is_variation_selector(c) {
140 return index
143 .checked_sub(1)
144 .is_some_and(|prev| Self::is_visible_base(chars, prev));
145 }
146
147 c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
148 }
149
150 fn cluster_message(len: usize, first: char) -> String {
154 let codepoint = unicode::format_codepoint(first);
155 if len >= 2 {
156 format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
157 } else {
158 format!("Invisible character {codepoint} detected next to another invisible character")
159 }
160 }
161
162 #[inline]
165 fn build_warning(
166 &self,
167 ctx: &LintContext,
168 line: usize,
169 start_col: usize,
170 len_chars: usize,
171 message: String,
172 replacement: Option<String>,
173 ) -> LintWarning {
174 let fix = replacement.map(|replacement| {
175 Fix::new(
176 ctx.line_index
177 .line_col_to_byte_range_with_length(line, start_col, len_chars),
178 replacement,
179 )
180 });
181
182 LintWarning {
183 rule_name: Some(self.name().to_string()),
184 line,
185 column: start_col,
186 end_line: line,
187 end_column: start_col + len_chars,
188 severity: Severity::Warning,
189 message,
190 fix,
191 }
192 }
193}
194
195impl Rule for MD084InvisibleCharacters {
196 fn name(&self) -> &'static str {
197 "MD084"
198 }
199
200 fn description(&self) -> &'static str {
201 "Invisible or discouraged Unicode characters should be intentional"
202 }
203
204 fn category(&self) -> RuleCategory {
205 RuleCategory::Whitespace
206 }
207
208 fn fix_capability(&self) -> FixCapability {
209 FixCapability::ConditionallyFixable
210 }
211
212 fn should_skip(&self, ctx: &LintContext) -> bool {
213 ctx.content.is_empty()
214 || !ctx
215 .content
216 .chars()
217 .any(|c| (unicode::is_invisible_char(c) || Self::is_markup_char(c)) && !self.is_allowed(c))
218 }
219
220 fn check(&self, ctx: &LintContext) -> LintResult {
221 let mut warnings = Vec::new();
222
223 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
224 let line_num = line_idx + 1;
225 let chars: Vec<char> = line.chars().collect();
226
227 if chars.is_empty() {
228 continue;
229 }
230
231 if self.config.strict {
233 warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
234 if self.is_allowed(c) {
235 None
236 } else if unicode::is_invisible_char(c) {
237 Some(self.build_warning(
238 ctx,
239 line_num,
240 i + 1,
241 1,
242 format!(
243 "Invisible character {} detected (strict mode)",
244 unicode::format_codepoint(c)
245 ),
246 Some(String::new()),
247 ))
248 } else {
249 Self::markup_finding(c).map(|(message, replacement)| {
250 self.build_warning(ctx, line_num, i + 1, 1, message, replacement)
251 })
252 }
253 }));
254 continue;
255 }
256
257 let mut flagged = vec![false; chars.len()];
262 let flaggable: Vec<bool> = chars
263 .iter()
264 .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
265 .collect();
266 let exempt: Vec<bool> = (0..chars.len())
267 .map(|i| Self::is_annotation_delimiter(chars[i]) || Self::is_presentation(&chars, i))
268 .collect();
269 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
270
271 let mut offset = 0;
275 for group in flaggable.chunk_by(|a, b| a == b) {
276 let len = group.len();
277 if group[0] && len >= 2 {
278 let mut start = offset;
279 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
280 let stretch_len = stretch.len();
281 if !stretch[0] {
282 flagged[start..start + stretch_len].fill(true);
283 warnings.push(self.build_warning(
284 ctx,
285 line_num,
286 start + 1,
287 stretch_len,
288 Self::cluster_message(stretch_len, chars[start]),
289 Some(String::new()),
290 ));
291 }
292 start += stretch_len;
293 }
294 }
295 offset += len;
296 }
297
298 for (i, &c) in chars.iter().enumerate() {
300 if !is_target[i] || flagged[i] {
301 continue;
302 }
303
304 if i == 0 || i == chars.len() - 1 {
306 flagged[i] = true;
307 warnings.push(self.build_warning(
308 ctx,
309 line_num,
310 i + 1,
311 1,
312 format!(
313 "Invisible character {} detected at line boundary",
314 unicode::format_codepoint(c)
315 ),
316 Some(String::new()),
317 ));
318 continue;
319 }
320
321 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
325 flagged[i] = true;
326 warnings.push(self.build_warning(
327 ctx,
328 line_num,
329 i + 1,
330 1,
331 format!(
332 "Invisible character {} detected adjacent to visible whitespace",
333 unicode::format_codepoint(c)
334 ),
335 Some(String::new()),
336 ));
337 }
338 }
339
340 for (i, &c) in chars.iter().enumerate() {
346 if flagged[i] || self.is_allowed(c) {
347 continue;
348 }
349 let Some((message, replacement)) = Self::markup_finding(c) else {
350 continue;
351 };
352 flagged[i] = true;
353 warnings.push(self.build_warning(ctx, line_num, i + 1, 1, message, replacement));
354 }
355 }
356
357 Ok(warnings)
358 }
359
360 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
361 if self.should_skip(ctx) {
362 return Ok(ctx.content.to_string());
363 }
364
365 let warnings = self.check(ctx)?;
366 if warnings.is_empty() {
367 return Ok(ctx.content.to_string());
368 }
369
370 let warnings =
371 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
373 .map_err(crate::rule::LintError::InvalidInput)
374 }
375
376 fn as_any(&self) -> &dyn std::any::Any {
377 self
378 }
379
380 crate::impl_rule_config_methods!(MD084Config);
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::config::MarkdownFlavor;
387
388 fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
389 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
390 let config = MD084Config {
391 strict,
392 allow: allow.chars().collect(),
393 };
394 MD084InvisibleCharacters::from_config_struct(config)
395 .check(&ctx)
396 .unwrap()
397 }
398
399 fn check(content: &str) -> Vec<LintWarning> {
400 check_with_config(content, false, "")
401 }
402
403 fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
404 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
405 let config = MD084Config {
406 strict,
407 allow: allow.chars().collect(),
408 };
409 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
410 }
411
412 fn fix(content: &str) -> String {
413 fix_with_config(content, false, "")
414 }
415
416 #[test]
417 fn test_default_no_findings_on_plain_text() {
418 let findings = check("plain text\nsecond line\n");
419 assert!(findings.is_empty());
420 }
421
422 #[test]
423 fn test_default_flags_multiple_consecutive_invisibles() {
424 let findings = check("a\u{200B}\u{200C}b");
425 assert_eq!(findings.len(), 1);
426 assert!(
427 findings[0]
428 .message
429 .contains("2 multiple consecutive invisible characters detected")
430 );
431 assert_eq!(findings[0].column, 2);
432 assert_eq!(findings[0].end_column, 4);
433 assert!(findings[0].fix.is_some());
434 }
435
436 #[test]
437 fn test_default_flags_invisible_chars_at_line_boundaries() {
438 let findings = check("\u{2060}start\nend\u{200B}");
439 assert_eq!(findings.len(), 2);
440 assert!(
441 findings[0]
442 .message
443 .contains("Invisible character U+2060 detected at line boundary")
444 );
445 assert!(
446 findings[1]
447 .message
448 .contains("Invisible character U+200B detected at line boundary")
449 );
450 }
451
452 #[test]
453 fn test_default_flags_invisible_adjacent_to_whitespace() {
454 let findings = check("a \u{2060}b");
455 assert_eq!(findings.len(), 1);
456 assert!(
457 findings[0]
458 .message
459 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
460 );
461 }
462
463 #[test]
464 fn test_default_fix_removes_triggered_characters() {
465 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
466 }
467
468 #[test]
469 fn test_strict_flags_any_invisible_character() {
470 let findings = check_with_config("ca\u{200C}t", true, "");
471 assert_eq!(findings.len(), 1);
472 assert!(findings[0].message.contains("strict mode"));
473 assert!(findings[0].fix.is_some());
474
475 assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
476 }
477
478 #[test]
479 fn test_allow_list_suppresses_findings() {
480 assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
481 }
482
483 #[test]
484 fn test_md084_default_triggers_are_targeted() {
485 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
486 assert_eq!(findings.len(), 4);
487
488 assert!(findings.iter().all(|w| w.fix.is_some()));
490 }
491
492 #[test]
493 fn test_md084_strict_mode_flags_any_invisible() {
494 let findings = check_with_config("in\u{200C}word", true, "");
495 assert_eq!(findings.len(), 1);
496 assert!(findings[0].fix.is_some());
497 }
498
499 #[test]
500 fn test_md084_allow_list_by_codepoint() {
501 let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
502 assert!(findings.is_empty());
503 }
504
505 #[test]
506 fn test_tab_characters() {
507 let findings = check("text\n\tindented\n");
508 assert!(findings.is_empty());
509 }
510
511 #[test]
512 fn test_default_ignores_variation_selector_attached_to_base() {
513 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
516 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
517
518 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
519 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
520 }
521
522 #[test]
523 fn test_default_fix_preserves_emoji_presentation() {
524 let content = "> \u{26A0}\u{FE0F} Note: important\n";
525 assert_eq!(fix(content), content);
526 }
527
528 #[test]
529 fn test_default_flags_orphaned_variation_selector() {
530 let findings = check("\u{FE0F}starts with a selector");
532 assert_eq!(findings.len(), 1);
533 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
534
535 let findings = check("a \u{FE0F}b");
536 assert_eq!(findings.len(), 1);
537 assert!(
538 findings[0]
539 .message
540 .contains("U+FE0F detected adjacent to visible whitespace")
541 );
542
543 let findings = check("a\u{200B}\u{FE0F}b");
545 assert_eq!(findings.len(), 1);
546 assert!(
547 findings[0]
548 .message
549 .contains("2 multiple consecutive invisible characters")
550 );
551 }
552
553 #[test]
554 fn test_default_flags_redundant_variation_selector() {
555 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
560 let findings = check(content);
561 assert_eq!(findings.len(), 1, "content {content:?}");
562 assert_eq!(findings[0].column, 3, "content {content:?}");
563 assert_eq!(findings[0].end_column, 4, "content {content:?}");
564 assert!(
565 findings[0]
566 .message
567 .contains("U+FE0F detected next to another invisible character"),
568 "content {content:?}: {}",
569 findings[0].message
570 );
571 }
572 }
573
574 #[test]
575 fn test_default_ignores_emoji_zwj_sequences() {
576 let sequences = [
579 "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}", "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}", "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}", "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", ];
585
586 for sequence in sequences {
587 let content = format!("look: {sequence} here");
588 let findings = check(&content);
589 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
590
591 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
592 }
593 }
594
595 #[test]
596 fn test_default_flags_orphaned_joiner() {
597 let findings = check("joins nothing\u{200D}");
599 assert_eq!(findings.len(), 1);
600 assert!(findings[0].message.contains("U+200D detected at line boundary"));
601
602 let findings = check("a \u{200D}b");
603 assert_eq!(findings.len(), 1);
604 assert!(
605 findings[0]
606 .message
607 .contains("U+200D detected adjacent to visible whitespace")
608 );
609
610 let findings = check("a\u{200D}\u{200B}b");
612 assert_eq!(findings.len(), 1);
613 assert!(
614 findings[0]
615 .message
616 .contains("2 multiple consecutive invisible characters")
617 );
618 }
619
620 #[test]
621 fn test_default_flags_invisible_hiding_behind_an_emoji() {
622 let content = "\u{26A0}\u{FE0F}\u{200B}x";
626 let findings = check(content);
627 assert_eq!(findings.len(), 1);
628 assert_eq!(findings[0].column, 3);
629 assert!(
630 findings[0]
631 .message
632 .contains("U+200B detected next to another invisible character")
633 );
634
635 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
637 }
638
639 #[test]
640 fn test_strict_still_flags_attached_variation_selector() {
641 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
644 assert_eq!(findings.len(), 1);
645 assert!(findings[0].message.contains("strict mode"));
646 }
647
648 #[test]
649 fn test_default_markup_unsuitable_characters_are_flagged() {
650 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
653 assert_eq!(findings.len(), 3, "Got {findings:?}");
654 assert!(
655 findings[0]
656 .message
657 .contains("U+0340 is not suitable for use with markup")
658 );
659 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
660 assert!(
661 findings[1]
662 .message
663 .contains("U+0341 is not suitable for use with markup")
664 );
665 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
666 assert!(
667 findings[2]
668 .message
669 .contains("U+FFFC is not suitable for use with markup")
670 );
671 assert!(findings[2].fix.is_none());
672 }
673
674 #[test]
675 fn test_strict_markup_unsuitable_characters_are_flagged() {
676 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
677 assert_eq!(findings.len(), 3, "Got {findings:?}");
678 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
679 assert!(findings[1].message.contains("U+0341"));
680 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
681 assert!(findings[2].message.contains("U+FFFC"));
682 assert!(findings[2].fix.is_none());
683 }
684
685 #[test]
686 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
687 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
688 assert!(findings.is_empty());
689 }
690
691 #[test]
692 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
693 let findings = check("Cote d\u{0149}Ivoire");
695 assert_eq!(findings.len(), 1, "Got {findings:?}");
696 assert!(
697 findings[0]
698 .message
699 .contains("Deprecated Unicode code point U+0149 detected")
700 );
701 assert!(findings[0].fix.is_none());
702 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
703 }
704
705 #[test]
706 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
707 for (content, expected_fix) in [
710 ("\u{206A}x", "x"),
711 ("x\u{206A}", "x"),
712 ("x \u{206A}y", "x y"),
713 ("x\u{206A}\u{206B}y", "xy"),
714 ] {
715 let findings = check(content);
716 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
717 assert!(
718 findings[0].message.starts_with("Invisible character")
719 || findings[0].message.contains("consecutive invisible characters"),
720 "{content:?} gave {:?}",
721 findings[0].message
722 );
723 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
724 }
725 }
726
727 #[test]
728 fn test_deprecated_and_invisible_is_reported_once() {
729 let findings = check("x\u{206A}y");
732 assert_eq!(findings.len(), 1, "Got {findings:?}");
733 assert!(
734 findings[0]
735 .message
736 .contains("Deprecated Unicode code point U+206A detected")
737 );
738 assert!(findings[0].fix.is_none());
739 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
740 }
741
742 #[test]
743 fn test_interlinear_annotation_is_reported_but_never_stripped() {
744 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
747 let findings = check(content);
748 assert_eq!(findings.len(), 3, "Got {findings:?}");
749 for finding in &findings {
750 assert!(finding.message.contains("is not suitable for use with markup"));
751 assert!(finding.fix.is_none());
752 }
753 assert_eq!(fix(content), content);
754 }
755
756 #[test]
757 fn test_annotation_delimiter_is_not_a_presentation_base() {
758 for (content, expected_fix) in [
763 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
764 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
765 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
766 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
767 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
768 ] {
769 let findings = check(content);
770 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
771 assert!(
772 findings.iter().any(|f| f.message.contains("Invisible character")
773 || f.message.contains("consecutive invisible characters")),
774 "{content:?} gave {findings:?}"
775 );
776 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
777 }
778 }
779
780 #[test]
781 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
782 let findings = check("\u{FFF8}x");
784 assert_eq!(findings.len(), 1, "Got {findings:?}");
785 assert!(
786 findings[0]
787 .message
788 .contains("Invisible character U+FFF8 detected at line boundary")
789 );
790 assert_eq!(fix("\u{FFF8}x"), "x");
791 }
792}