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]
69 fn is_line_ending(c: char) -> bool {
70 c == '\n' || c == '\r'
71 }
72
73 #[inline]
77 fn draws_no_glyph(c: char) -> bool {
78 unicode::is_invisible_char(c) || Self::is_annotation_delimiter(c)
79 }
80
81 fn markup_finding(c: char) -> Option<(String, Option<String>)> {
84 let codepoint = unicode::format_codepoint(c);
85 if unicode::is_deprecated_char(c) {
86 return Some((format!("Deprecated Unicode code point {codepoint} detected"), None));
87 }
88 if !unicode::is_unsuitable_for_markup_char(c) {
89 return None;
90 }
91 let replacement = match c as u32 {
94 0x0340 => Some("\u{0300}".to_string()), 0x0341 => Some("\u{0301}".to_string()), _ => None,
97 };
98 Some((
99 format!("Unicode code point {codepoint} is not suitable for use with markup"),
100 replacement,
101 ))
102 }
103
104 fn is_variation_selector(c: char) -> bool {
108 matches!(
109 c as u32,
110 0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
114 }
115
116 const ZWJ: char = '\u{200D}';
118
119 fn is_visible_base(chars: &[char], index: usize) -> bool {
123 chars
124 .get(index)
125 .is_some_and(|&c| !c.is_whitespace() && !Self::draws_no_glyph(c))
126 }
127
128 fn follows_visible_base(chars: &[char], index: usize) -> bool {
132 let Some(prev) = index.checked_sub(1) else {
133 return false;
134 };
135
136 Self::is_visible_base(chars, prev)
137 || (Self::is_variation_selector(chars[prev])
138 && prev
139 .checked_sub(1)
140 .is_some_and(|base| Self::is_visible_base(chars, base)))
141 }
142
143 fn is_presentation(chars: &[char], index: usize) -> bool {
151 let c = chars[index];
152
153 if Self::is_variation_selector(c) {
154 return index
157 .checked_sub(1)
158 .is_some_and(|prev| Self::is_visible_base(chars, prev));
159 }
160
161 c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
162 }
163
164 fn cluster_message(len: usize, first: char) -> String {
168 let codepoint = unicode::format_codepoint(first);
169 if len >= 2 {
170 format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
171 } else {
172 format!("Invisible character {codepoint} detected next to another invisible character")
173 }
174 }
175
176 #[inline]
179 fn build_warning(
180 &self,
181 ctx: &LintContext,
182 line: usize,
183 start_col: usize,
184 len_chars: usize,
185 message: String,
186 replacement: Option<String>,
187 ) -> LintWarning {
188 let fix = replacement.map(|replacement| {
189 Fix::new(
190 ctx.line_index
191 .line_col_to_byte_range_with_length(line, start_col, len_chars),
192 replacement,
193 )
194 });
195
196 LintWarning {
197 rule_name: Some(self.name().to_string()),
198 line,
199 column: start_col,
200 end_line: line,
201 end_column: start_col + len_chars,
202 severity: Severity::Warning,
203 message,
204 fix,
205 }
206 }
207}
208
209impl Rule for MD084InvisibleCharacters {
210 fn name(&self) -> &'static str {
211 "MD084"
212 }
213
214 fn description(&self) -> &'static str {
215 "Invisible or discouraged Unicode characters should be intentional"
216 }
217
218 fn category(&self) -> RuleCategory {
219 RuleCategory::Whitespace
220 }
221
222 fn fix_capability(&self) -> FixCapability {
223 FixCapability::ConditionallyFixable
224 }
225
226 fn should_skip(&self, ctx: &LintContext) -> bool {
227 ctx.content.is_empty()
228 || !ctx.content.chars().any(|c| {
229 (unicode::is_invisible_char(c) || Self::is_markup_char(c))
230 && !Self::is_line_ending(c)
231 && !self.is_allowed(c)
232 })
233 }
234
235 fn check(&self, ctx: &LintContext) -> LintResult {
236 let mut warnings = Vec::new();
237
238 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
239 let line_num = line_idx + 1;
240 let chars: Vec<char> = line.chars().collect();
241
242 if chars.is_empty() {
243 continue;
244 }
245
246 if self.config.strict {
251 warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
252 if self.is_allowed(c) || Self::is_line_ending(c) {
253 None
254 } else if unicode::is_invisible_char(c) {
255 Some(self.build_warning(
256 ctx,
257 line_num,
258 i + 1,
259 1,
260 format!(
261 "Invisible character {} detected (strict mode)",
262 unicode::format_codepoint(c)
263 ),
264 Some(String::new()),
265 ))
266 } else {
267 Self::markup_finding(c).map(|(message, replacement)| {
268 self.build_warning(ctx, line_num, i + 1, 1, message, replacement)
269 })
270 }
271 }));
272 continue;
273 }
274
275 let mut flagged = vec![false; chars.len()];
282 let flaggable: Vec<bool> = chars
283 .iter()
284 .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
285 .collect();
286 let exempt: Vec<bool> = (0..chars.len())
287 .map(|i| {
288 Self::is_annotation_delimiter(chars[i])
289 || Self::is_line_ending(chars[i])
290 || Self::is_presentation(&chars, i)
291 })
292 .collect();
293 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
294
295 let mut offset = 0;
299 for group in flaggable.chunk_by(|a, b| a == b) {
300 let len = group.len();
301 if group[0] && len >= 2 {
302 let mut start = offset;
303 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
304 let stretch_len = stretch.len();
305 if !stretch[0] {
306 flagged[start..start + stretch_len].fill(true);
307 warnings.push(self.build_warning(
308 ctx,
309 line_num,
310 start + 1,
311 stretch_len,
312 Self::cluster_message(stretch_len, chars[start]),
313 Some(String::new()),
314 ));
315 }
316 start += stretch_len;
317 }
318 }
319 offset += len;
320 }
321
322 for (i, &c) in chars.iter().enumerate() {
324 if !is_target[i] || flagged[i] {
325 continue;
326 }
327
328 if i == 0 || i == chars.len() - 1 {
330 flagged[i] = true;
331 warnings.push(self.build_warning(
332 ctx,
333 line_num,
334 i + 1,
335 1,
336 format!(
337 "Invisible character {} detected at line boundary",
338 unicode::format_codepoint(c)
339 ),
340 Some(String::new()),
341 ));
342 continue;
343 }
344
345 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
349 flagged[i] = true;
350 warnings.push(self.build_warning(
351 ctx,
352 line_num,
353 i + 1,
354 1,
355 format!(
356 "Invisible character {} detected adjacent to visible whitespace",
357 unicode::format_codepoint(c)
358 ),
359 Some(String::new()),
360 ));
361 }
362 }
363
364 for (i, &c) in chars.iter().enumerate() {
370 if flagged[i] || self.is_allowed(c) {
371 continue;
372 }
373 let Some((message, replacement)) = Self::markup_finding(c) else {
374 continue;
375 };
376 flagged[i] = true;
377 warnings.push(self.build_warning(ctx, line_num, i + 1, 1, message, replacement));
378 }
379 }
380
381 Ok(warnings)
382 }
383
384 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
385 if self.should_skip(ctx) {
386 return Ok(ctx.content.to_string());
387 }
388
389 let warnings = self.check(ctx)?;
390 if warnings.is_empty() {
391 return Ok(ctx.content.to_string());
392 }
393
394 let warnings =
395 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
396 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
397 .map_err(crate::rule::LintError::InvalidInput)
398 }
399
400 fn as_any(&self) -> &dyn std::any::Any {
401 self
402 }
403
404 crate::impl_rule_config_methods!(MD084Config);
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::config::MarkdownFlavor;
411
412 fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
413 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
414 let config = MD084Config {
415 strict,
416 allow: allow.chars().collect(),
417 };
418 MD084InvisibleCharacters::from_config_struct(config)
419 .check(&ctx)
420 .unwrap()
421 }
422
423 fn check(content: &str) -> Vec<LintWarning> {
424 check_with_config(content, false, "")
425 }
426
427 fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
428 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
429 let config = MD084Config {
430 strict,
431 allow: allow.chars().collect(),
432 };
433 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
434 }
435
436 fn fix(content: &str) -> String {
437 fix_with_config(content, false, "")
438 }
439
440 #[test]
441 fn test_default_no_findings_on_plain_text() {
442 let findings = check("plain text\nsecond line\n");
443 assert!(findings.is_empty());
444 }
445
446 #[test]
447 fn test_default_flags_multiple_consecutive_invisibles() {
448 let findings = check("a\u{200B}\u{200C}b");
449 assert_eq!(findings.len(), 1);
450 assert!(
451 findings[0]
452 .message
453 .contains("2 multiple consecutive invisible characters detected")
454 );
455 assert_eq!(findings[0].column, 2);
456 assert_eq!(findings[0].end_column, 4);
457 assert!(findings[0].fix.is_some());
458 }
459
460 #[test]
461 fn test_default_flags_invisible_chars_at_line_boundaries() {
462 let findings = check("\u{2060}start\nend\u{200B}");
463 assert_eq!(findings.len(), 2);
464 assert!(
465 findings[0]
466 .message
467 .contains("Invisible character U+2060 detected at line boundary")
468 );
469 assert!(
470 findings[1]
471 .message
472 .contains("Invisible character U+200B detected at line boundary")
473 );
474 }
475
476 #[test]
477 fn test_default_flags_invisible_adjacent_to_whitespace() {
478 let findings = check("a \u{2060}b");
479 assert_eq!(findings.len(), 1);
480 assert!(
481 findings[0]
482 .message
483 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
484 );
485 }
486
487 #[test]
488 fn test_default_fix_removes_triggered_characters() {
489 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
490 }
491
492 #[test]
493 fn test_strict_flags_any_invisible_character() {
494 let findings = check_with_config("ca\u{200C}t", true, "");
495 assert_eq!(findings.len(), 1);
496 assert!(findings[0].message.contains("strict mode"));
497 assert!(findings[0].fix.is_some());
498
499 assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
500 }
501
502 #[test]
503 fn test_allow_list_suppresses_findings() {
504 assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
505 }
506
507 #[test]
508 fn test_md084_default_triggers_are_targeted() {
509 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
510 assert_eq!(findings.len(), 4);
511
512 assert!(findings.iter().all(|w| w.fix.is_some()));
514 }
515
516 #[test]
517 fn test_md084_strict_mode_flags_any_invisible() {
518 let findings = check_with_config("in\u{200C}word", true, "");
519 assert_eq!(findings.len(), 1);
520 assert!(findings[0].fix.is_some());
521 }
522
523 #[test]
524 fn test_md084_allow_list_by_codepoint() {
525 let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
526 assert!(findings.is_empty());
527 }
528
529 #[test]
530 fn test_tab_characters() {
531 let findings = check("text\n\tindented\n");
532 assert!(findings.is_empty());
533 }
534
535 #[test]
536 fn test_carriage_returns_are_line_endings_not_hidden_content() {
537 for content in [
544 "# Title\rSome text\rMore text\r",
545 "# Title\r\rSome text\r",
546 "# Title \rSome text\r",
547 "a\rb\n",
548 ] {
549 let findings = check(content);
550 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
551 assert_eq!(fix(content), content, "fixing {content:?}");
552 }
553 }
554
555 #[test]
556 fn test_strict_mode_keeps_carriage_returns() {
557 for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
561 let findings = check_with_config(content, true, "");
562 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
563 assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
564 }
565 }
566
567 #[test]
568 fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
569 for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
574 let findings = check_with_config(content, strict, "");
575 assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
576 assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
577 }
578 }
579
580 #[test]
581 fn test_line_feeds_do_not_defeat_the_skip_guard() {
582 let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
587 assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
588
589 let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
590 assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
591 }
592
593 #[test]
594 fn test_default_ignores_variation_selector_attached_to_base() {
595 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
598 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
599
600 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
601 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
602 }
603
604 #[test]
605 fn test_default_fix_preserves_emoji_presentation() {
606 let content = "> \u{26A0}\u{FE0F} Note: important\n";
607 assert_eq!(fix(content), content);
608 }
609
610 #[test]
611 fn test_default_flags_orphaned_variation_selector() {
612 let findings = check("\u{FE0F}starts with a selector");
614 assert_eq!(findings.len(), 1);
615 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
616
617 let findings = check("a \u{FE0F}b");
618 assert_eq!(findings.len(), 1);
619 assert!(
620 findings[0]
621 .message
622 .contains("U+FE0F detected adjacent to visible whitespace")
623 );
624
625 let findings = check("a\u{200B}\u{FE0F}b");
627 assert_eq!(findings.len(), 1);
628 assert!(
629 findings[0]
630 .message
631 .contains("2 multiple consecutive invisible characters")
632 );
633 }
634
635 #[test]
636 fn test_default_flags_redundant_variation_selector() {
637 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
642 let findings = check(content);
643 assert_eq!(findings.len(), 1, "content {content:?}");
644 assert_eq!(findings[0].column, 3, "content {content:?}");
645 assert_eq!(findings[0].end_column, 4, "content {content:?}");
646 assert!(
647 findings[0]
648 .message
649 .contains("U+FE0F detected next to another invisible character"),
650 "content {content:?}: {}",
651 findings[0].message
652 );
653 }
654 }
655
656 #[test]
657 fn test_default_ignores_emoji_zwj_sequences() {
658 let sequences = [
661 "\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}", ];
667
668 for sequence in sequences {
669 let content = format!("look: {sequence} here");
670 let findings = check(&content);
671 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
672
673 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
674 }
675 }
676
677 #[test]
678 fn test_default_flags_orphaned_joiner() {
679 let findings = check("joins nothing\u{200D}");
681 assert_eq!(findings.len(), 1);
682 assert!(findings[0].message.contains("U+200D detected at line boundary"));
683
684 let findings = check("a \u{200D}b");
685 assert_eq!(findings.len(), 1);
686 assert!(
687 findings[0]
688 .message
689 .contains("U+200D detected adjacent to visible whitespace")
690 );
691
692 let findings = check("a\u{200D}\u{200B}b");
694 assert_eq!(findings.len(), 1);
695 assert!(
696 findings[0]
697 .message
698 .contains("2 multiple consecutive invisible characters")
699 );
700 }
701
702 #[test]
703 fn test_default_flags_invisible_hiding_behind_an_emoji() {
704 let content = "\u{26A0}\u{FE0F}\u{200B}x";
708 let findings = check(content);
709 assert_eq!(findings.len(), 1);
710 assert_eq!(findings[0].column, 3);
711 assert!(
712 findings[0]
713 .message
714 .contains("U+200B detected next to another invisible character")
715 );
716
717 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
719 }
720
721 #[test]
722 fn test_strict_still_flags_attached_variation_selector() {
723 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
726 assert_eq!(findings.len(), 1);
727 assert!(findings[0].message.contains("strict mode"));
728 }
729
730 #[test]
731 fn test_default_markup_unsuitable_characters_are_flagged() {
732 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
735 assert_eq!(findings.len(), 3, "Got {findings:?}");
736 assert!(
737 findings[0]
738 .message
739 .contains("U+0340 is not suitable for use with markup")
740 );
741 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
742 assert!(
743 findings[1]
744 .message
745 .contains("U+0341 is not suitable for use with markup")
746 );
747 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
748 assert!(
749 findings[2]
750 .message
751 .contains("U+FFFC is not suitable for use with markup")
752 );
753 assert!(findings[2].fix.is_none());
754 }
755
756 #[test]
757 fn test_strict_markup_unsuitable_characters_are_flagged() {
758 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
759 assert_eq!(findings.len(), 3, "Got {findings:?}");
760 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
761 assert!(findings[1].message.contains("U+0341"));
762 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
763 assert!(findings[2].message.contains("U+FFFC"));
764 assert!(findings[2].fix.is_none());
765 }
766
767 #[test]
768 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
769 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
770 assert!(findings.is_empty());
771 }
772
773 #[test]
774 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
775 let findings = check("Cote d\u{0149}Ivoire");
777 assert_eq!(findings.len(), 1, "Got {findings:?}");
778 assert!(
779 findings[0]
780 .message
781 .contains("Deprecated Unicode code point U+0149 detected")
782 );
783 assert!(findings[0].fix.is_none());
784 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
785 }
786
787 #[test]
788 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
789 for (content, expected_fix) in [
792 ("\u{206A}x", "x"),
793 ("x\u{206A}", "x"),
794 ("x \u{206A}y", "x y"),
795 ("x\u{206A}\u{206B}y", "xy"),
796 ] {
797 let findings = check(content);
798 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
799 assert!(
800 findings[0].message.starts_with("Invisible character")
801 || findings[0].message.contains("consecutive invisible characters"),
802 "{content:?} gave {:?}",
803 findings[0].message
804 );
805 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
806 }
807 }
808
809 #[test]
810 fn test_deprecated_and_invisible_is_reported_once() {
811 let findings = check("x\u{206A}y");
814 assert_eq!(findings.len(), 1, "Got {findings:?}");
815 assert!(
816 findings[0]
817 .message
818 .contains("Deprecated Unicode code point U+206A detected")
819 );
820 assert!(findings[0].fix.is_none());
821 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
822 }
823
824 #[test]
825 fn test_interlinear_annotation_is_reported_but_never_stripped() {
826 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
829 let findings = check(content);
830 assert_eq!(findings.len(), 3, "Got {findings:?}");
831 for finding in &findings {
832 assert!(finding.message.contains("is not suitable for use with markup"));
833 assert!(finding.fix.is_none());
834 }
835 assert_eq!(fix(content), content);
836 }
837
838 #[test]
839 fn test_annotation_delimiter_is_not_a_presentation_base() {
840 for (content, expected_fix) in [
845 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
846 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
847 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
848 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
849 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
850 ] {
851 let findings = check(content);
852 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
853 assert!(
854 findings.iter().any(|f| f.message.contains("Invisible character")
855 || f.message.contains("consecutive invisible characters")),
856 "{content:?} gave {findings:?}"
857 );
858 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
859 }
860 }
861
862 #[test]
863 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
864 let findings = check("\u{FFF8}x");
866 assert_eq!(findings.len(), 1, "Got {findings:?}");
867 assert!(
868 findings[0]
869 .message
870 .contains("Invisible character U+FFF8 detected at line boundary")
871 );
872 assert_eq!(fix("\u{FFF8}x"), "x");
873 }
874}