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_column_byte_range_with_length(line, start_col, len_chars),
191 replacement,
192 )
193 });
194
195 LintWarning {
196 rule_name: Some(self.name().to_string()),
197 line,
198 column: start_col,
199 end_line: line,
200 end_column: start_col + len_chars,
201 severity: Severity::Warning,
202 message,
203 fix,
204 }
205 }
206}
207
208impl Rule for MD084InvisibleCharacters {
209 fn name(&self) -> &'static str {
210 "MD084"
211 }
212
213 fn description(&self) -> &'static str {
214 "Invisible or discouraged Unicode characters should be intentional"
215 }
216
217 fn category(&self) -> RuleCategory {
218 RuleCategory::Whitespace
219 }
220
221 fn fix_capability(&self) -> FixCapability {
222 FixCapability::ConditionallyFixable
223 }
224
225 fn should_skip(&self, ctx: &LintContext) -> bool {
226 ctx.content.is_empty()
227 || !ctx.content.chars().any(|c| {
228 (unicode::is_invisible_char(c) || Self::is_markup_char(c))
229 && !Self::is_line_ending(c)
230 && !self.is_allowed(c)
231 })
232 }
233
234 fn check(&self, ctx: &LintContext) -> LintResult {
235 let mut warnings = Vec::new();
236
237 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
238 let line_num = line_idx + 1;
239 let chars: Vec<char> = line.chars().collect();
240
241 if chars.is_empty() {
242 continue;
243 }
244
245 if self.config.strict {
250 warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
251 if self.is_allowed(c) || Self::is_line_ending(c) {
252 None
253 } else if unicode::is_invisible_char(c) {
254 Some(self.build_warning(
255 ctx,
256 line_num,
257 i + 1,
258 1,
259 format!(
260 "Invisible character {} detected (strict mode)",
261 unicode::format_codepoint(c)
262 ),
263 Some(String::new()),
264 ))
265 } else {
266 Self::markup_finding(c).map(|(message, replacement)| {
267 self.build_warning(ctx, line_num, i + 1, 1, message, replacement)
268 })
269 }
270 }));
271 continue;
272 }
273
274 let mut flagged = vec![false; chars.len()];
281 let flaggable: Vec<bool> = chars
282 .iter()
283 .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
284 .collect();
285 let exempt: Vec<bool> = (0..chars.len())
286 .map(|i| {
287 Self::is_annotation_delimiter(chars[i])
288 || Self::is_line_ending(chars[i])
289 || Self::is_presentation(&chars, i)
290 })
291 .collect();
292 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
293
294 let mut offset = 0;
298 for group in flaggable.chunk_by(|a, b| a == b) {
299 let len = group.len();
300 if group[0] && len >= 2 {
301 let mut start = offset;
302 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
303 let stretch_len = stretch.len();
304 if !stretch[0] {
305 flagged[start..start + stretch_len].fill(true);
306 warnings.push(self.build_warning(
307 ctx,
308 line_num,
309 start + 1,
310 stretch_len,
311 Self::cluster_message(stretch_len, chars[start]),
312 Some(String::new()),
313 ));
314 }
315 start += stretch_len;
316 }
317 }
318 offset += len;
319 }
320
321 for (i, &c) in chars.iter().enumerate() {
323 if !is_target[i] || flagged[i] {
324 continue;
325 }
326
327 if i == 0 || i == chars.len() - 1 {
329 flagged[i] = true;
330 warnings.push(self.build_warning(
331 ctx,
332 line_num,
333 i + 1,
334 1,
335 format!(
336 "Invisible character {} detected at line boundary",
337 unicode::format_codepoint(c)
338 ),
339 Some(String::new()),
340 ));
341 continue;
342 }
343
344 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
348 flagged[i] = true;
349 warnings.push(self.build_warning(
350 ctx,
351 line_num,
352 i + 1,
353 1,
354 format!(
355 "Invisible character {} detected adjacent to visible whitespace",
356 unicode::format_codepoint(c)
357 ),
358 Some(String::new()),
359 ));
360 }
361 }
362
363 for (i, &c) in chars.iter().enumerate() {
369 if flagged[i] || self.is_allowed(c) {
370 continue;
371 }
372 let Some((message, replacement)) = Self::markup_finding(c) else {
373 continue;
374 };
375 flagged[i] = true;
376 warnings.push(self.build_warning(ctx, line_num, i + 1, 1, message, replacement));
377 }
378 }
379
380 Ok(warnings)
381 }
382
383 fn fixes_from_diagnostics(&self) -> bool {
384 true
385 }
386
387 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
388 if self.should_skip(ctx) {
389 return Ok(ctx.content.to_string());
390 }
391
392 let warnings = self.check(ctx)?;
393 if warnings.is_empty() {
394 return Ok(ctx.content.to_string());
395 }
396
397 let warnings =
398 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
399 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
400 .map_err(crate::rule::LintError::InvalidInput)
401 }
402
403 fn as_any(&self) -> &dyn std::any::Any {
404 self
405 }
406
407 crate::impl_rule_config_methods!(MD084Config);
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::config::MarkdownFlavor;
414
415 fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
416 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
417 let config = MD084Config {
418 strict,
419 allow: allow.chars().collect(),
420 };
421 MD084InvisibleCharacters::from_config_struct(config)
422 .check(&ctx)
423 .unwrap()
424 }
425
426 fn check(content: &str) -> Vec<LintWarning> {
427 check_with_config(content, false, "")
428 }
429
430 fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
431 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
432 let config = MD084Config {
433 strict,
434 allow: allow.chars().collect(),
435 };
436 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
437 }
438
439 fn fix(content: &str) -> String {
440 fix_with_config(content, false, "")
441 }
442
443 #[test]
444 fn test_default_no_findings_on_plain_text() {
445 let findings = check("plain text\nsecond line\n");
446 assert!(findings.is_empty());
447 }
448
449 #[test]
450 fn test_default_flags_multiple_consecutive_invisibles() {
451 let findings = check("a\u{200B}\u{200C}b");
452 assert_eq!(findings.len(), 1);
453 assert!(
454 findings[0]
455 .message
456 .contains("2 multiple consecutive invisible characters detected")
457 );
458 assert_eq!(findings[0].column, 2);
459 assert_eq!(findings[0].end_column, 4);
460 assert!(findings[0].fix.is_some());
461 }
462
463 #[test]
464 fn test_default_flags_invisible_chars_at_line_boundaries() {
465 let findings = check("\u{2060}start\nend\u{200B}");
466 assert_eq!(findings.len(), 2);
467 assert!(
468 findings[0]
469 .message
470 .contains("Invisible character U+2060 detected at line boundary")
471 );
472 assert!(
473 findings[1]
474 .message
475 .contains("Invisible character U+200B detected at line boundary")
476 );
477 }
478
479 #[test]
480 fn test_default_flags_invisible_adjacent_to_whitespace() {
481 let findings = check("a \u{2060}b");
482 assert_eq!(findings.len(), 1);
483 assert!(
484 findings[0]
485 .message
486 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
487 );
488 }
489
490 #[test]
491 fn test_default_fix_removes_triggered_characters() {
492 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
493 }
494
495 #[test]
496 fn test_strict_flags_any_invisible_character() {
497 let findings = check_with_config("ca\u{200C}t", true, "");
498 assert_eq!(findings.len(), 1);
499 assert!(findings[0].message.contains("strict mode"));
500 assert!(findings[0].fix.is_some());
501
502 assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
503 }
504
505 #[test]
506 fn test_allow_list_suppresses_findings() {
507 assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
508 }
509
510 #[test]
511 fn test_md084_default_triggers_are_targeted() {
512 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
513 assert_eq!(findings.len(), 4);
514
515 assert!(findings.iter().all(|w| w.fix.is_some()));
517 }
518
519 #[test]
520 fn test_md084_strict_mode_flags_any_invisible() {
521 let findings = check_with_config("in\u{200C}word", true, "");
522 assert_eq!(findings.len(), 1);
523 assert!(findings[0].fix.is_some());
524 }
525
526 #[test]
527 fn test_md084_allow_list_by_codepoint() {
528 let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
529 assert!(findings.is_empty());
530 }
531
532 #[test]
533 fn test_tab_characters() {
534 let findings = check("text\n\tindented\n");
535 assert!(findings.is_empty());
536 }
537
538 #[test]
539 fn test_carriage_returns_are_line_endings_not_hidden_content() {
540 for content in [
547 "# Title\rSome text\rMore text\r",
548 "# Title\r\rSome text\r",
549 "# Title \rSome text\r",
550 "a\rb\n",
551 ] {
552 let findings = check(content);
553 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
554 assert_eq!(fix(content), content, "fixing {content:?}");
555 }
556 }
557
558 #[test]
559 fn test_strict_mode_keeps_carriage_returns() {
560 for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
564 let findings = check_with_config(content, true, "");
565 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
566 assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
567 }
568 }
569
570 #[test]
571 fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
572 for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
577 let findings = check_with_config(content, strict, "");
578 assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
579 assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
580 }
581 }
582
583 #[test]
584 fn test_line_feeds_do_not_defeat_the_skip_guard() {
585 let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
590 assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
591
592 let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
593 assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
594 }
595
596 #[test]
597 fn test_default_ignores_variation_selector_attached_to_base() {
598 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
601 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
602
603 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
604 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
605 }
606
607 #[test]
608 fn test_default_fix_preserves_emoji_presentation() {
609 let content = "> \u{26A0}\u{FE0F} Note: important\n";
610 assert_eq!(fix(content), content);
611 }
612
613 #[test]
614 fn test_default_flags_orphaned_variation_selector() {
615 let findings = check("\u{FE0F}starts with a selector");
617 assert_eq!(findings.len(), 1);
618 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
619
620 let findings = check("a \u{FE0F}b");
621 assert_eq!(findings.len(), 1);
622 assert!(
623 findings[0]
624 .message
625 .contains("U+FE0F detected adjacent to visible whitespace")
626 );
627
628 let findings = check("a\u{200B}\u{FE0F}b");
630 assert_eq!(findings.len(), 1);
631 assert!(
632 findings[0]
633 .message
634 .contains("2 multiple consecutive invisible characters")
635 );
636 }
637
638 #[test]
639 fn test_default_flags_redundant_variation_selector() {
640 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
645 let findings = check(content);
646 assert_eq!(findings.len(), 1, "content {content:?}");
647 assert_eq!(findings[0].column, 3, "content {content:?}");
648 assert_eq!(findings[0].end_column, 4, "content {content:?}");
649 assert!(
650 findings[0]
651 .message
652 .contains("U+FE0F detected next to another invisible character"),
653 "content {content:?}: {}",
654 findings[0].message
655 );
656 }
657 }
658
659 #[test]
660 fn test_default_ignores_emoji_zwj_sequences() {
661 let sequences = [
664 "\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}", ];
670
671 for sequence in sequences {
672 let content = format!("look: {sequence} here");
673 let findings = check(&content);
674 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
675
676 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
677 }
678 }
679
680 #[test]
681 fn test_default_flags_orphaned_joiner() {
682 let findings = check("joins nothing\u{200D}");
684 assert_eq!(findings.len(), 1);
685 assert!(findings[0].message.contains("U+200D detected at line boundary"));
686
687 let findings = check("a \u{200D}b");
688 assert_eq!(findings.len(), 1);
689 assert!(
690 findings[0]
691 .message
692 .contains("U+200D detected adjacent to visible whitespace")
693 );
694
695 let findings = check("a\u{200D}\u{200B}b");
697 assert_eq!(findings.len(), 1);
698 assert!(
699 findings[0]
700 .message
701 .contains("2 multiple consecutive invisible characters")
702 );
703 }
704
705 #[test]
706 fn test_default_flags_invisible_hiding_behind_an_emoji() {
707 let content = "\u{26A0}\u{FE0F}\u{200B}x";
711 let findings = check(content);
712 assert_eq!(findings.len(), 1);
713 assert_eq!(findings[0].column, 3);
714 assert!(
715 findings[0]
716 .message
717 .contains("U+200B detected next to another invisible character")
718 );
719
720 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
722 }
723
724 #[test]
725 fn test_strict_still_flags_attached_variation_selector() {
726 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
729 assert_eq!(findings.len(), 1);
730 assert!(findings[0].message.contains("strict mode"));
731 }
732
733 #[test]
734 fn test_default_markup_unsuitable_characters_are_flagged() {
735 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
738 assert_eq!(findings.len(), 3, "Got {findings:?}");
739 assert!(
740 findings[0]
741 .message
742 .contains("U+0340 is not suitable for use with markup")
743 );
744 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
745 assert!(
746 findings[1]
747 .message
748 .contains("U+0341 is not suitable for use with markup")
749 );
750 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
751 assert!(
752 findings[2]
753 .message
754 .contains("U+FFFC is not suitable for use with markup")
755 );
756 assert!(findings[2].fix.is_none());
757 }
758
759 #[test]
760 fn test_strict_markup_unsuitable_characters_are_flagged() {
761 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
762 assert_eq!(findings.len(), 3, "Got {findings:?}");
763 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
764 assert!(findings[1].message.contains("U+0341"));
765 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
766 assert!(findings[2].message.contains("U+FFFC"));
767 assert!(findings[2].fix.is_none());
768 }
769
770 #[test]
771 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
772 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
773 assert!(findings.is_empty());
774 }
775
776 #[test]
777 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
778 let findings = check("Cote d\u{0149}Ivoire");
780 assert_eq!(findings.len(), 1, "Got {findings:?}");
781 assert!(
782 findings[0]
783 .message
784 .contains("Deprecated Unicode code point U+0149 detected")
785 );
786 assert!(findings[0].fix.is_none());
787 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
788 }
789
790 #[test]
791 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
792 for (content, expected_fix) in [
795 ("\u{206A}x", "x"),
796 ("x\u{206A}", "x"),
797 ("x \u{206A}y", "x y"),
798 ("x\u{206A}\u{206B}y", "xy"),
799 ] {
800 let findings = check(content);
801 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
802 assert!(
803 findings[0].message.starts_with("Invisible character")
804 || findings[0].message.contains("consecutive invisible characters"),
805 "{content:?} gave {:?}",
806 findings[0].message
807 );
808 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
809 }
810 }
811
812 #[test]
813 fn test_deprecated_and_invisible_is_reported_once() {
814 let findings = check("x\u{206A}y");
817 assert_eq!(findings.len(), 1, "Got {findings:?}");
818 assert!(
819 findings[0]
820 .message
821 .contains("Deprecated Unicode code point U+206A detected")
822 );
823 assert!(findings[0].fix.is_none());
824 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
825 }
826
827 #[test]
828 fn test_interlinear_annotation_is_reported_but_never_stripped() {
829 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
832 let findings = check(content);
833 assert_eq!(findings.len(), 3, "Got {findings:?}");
834 for finding in &findings {
835 assert!(finding.message.contains("is not suitable for use with markup"));
836 assert!(finding.fix.is_none());
837 }
838 assert_eq!(fix(content), content);
839 }
840
841 #[test]
842 fn test_annotation_delimiter_is_not_a_presentation_base() {
843 for (content, expected_fix) in [
848 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
849 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
850 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
851 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
852 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
853 ] {
854 let findings = check(content);
855 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
856 assert!(
857 findings.iter().any(|f| f.message.contains("Invisible character")
858 || f.message.contains("consecutive invisible characters")),
859 "{content:?} gave {findings:?}"
860 );
861 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
862 }
863 }
864
865 #[test]
866 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
867 let findings = check("\u{FFF8}x");
869 assert_eq!(findings.len(), 1, "Got {findings:?}");
870 assert!(
871 findings[0]
872 .message
873 .contains("Invisible character U+FFF8 detected at line boundary")
874 );
875 assert_eq!(fix("\u{FFF8}x"), "x");
876 }
877}