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 fix(&self, ctx: &LintContext) -> Result<String, LintError> {
384 if self.should_skip(ctx) {
385 return Ok(ctx.content.to_string());
386 }
387
388 let warnings = self.check(ctx)?;
389 if warnings.is_empty() {
390 return Ok(ctx.content.to_string());
391 }
392
393 let warnings =
394 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
395 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
396 .map_err(crate::rule::LintError::InvalidInput)
397 }
398
399 fn as_any(&self) -> &dyn std::any::Any {
400 self
401 }
402
403 crate::impl_rule_config_methods!(MD084Config);
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::config::MarkdownFlavor;
410
411 fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
412 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
413 let config = MD084Config {
414 strict,
415 allow: allow.chars().collect(),
416 };
417 MD084InvisibleCharacters::from_config_struct(config)
418 .check(&ctx)
419 .unwrap()
420 }
421
422 fn check(content: &str) -> Vec<LintWarning> {
423 check_with_config(content, false, "")
424 }
425
426 fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
427 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
428 let config = MD084Config {
429 strict,
430 allow: allow.chars().collect(),
431 };
432 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
433 }
434
435 fn fix(content: &str) -> String {
436 fix_with_config(content, false, "")
437 }
438
439 #[test]
440 fn test_default_no_findings_on_plain_text() {
441 let findings = check("plain text\nsecond line\n");
442 assert!(findings.is_empty());
443 }
444
445 #[test]
446 fn test_default_flags_multiple_consecutive_invisibles() {
447 let findings = check("a\u{200B}\u{200C}b");
448 assert_eq!(findings.len(), 1);
449 assert!(
450 findings[0]
451 .message
452 .contains("2 multiple consecutive invisible characters detected")
453 );
454 assert_eq!(findings[0].column, 2);
455 assert_eq!(findings[0].end_column, 4);
456 assert!(findings[0].fix.is_some());
457 }
458
459 #[test]
460 fn test_default_flags_invisible_chars_at_line_boundaries() {
461 let findings = check("\u{2060}start\nend\u{200B}");
462 assert_eq!(findings.len(), 2);
463 assert!(
464 findings[0]
465 .message
466 .contains("Invisible character U+2060 detected at line boundary")
467 );
468 assert!(
469 findings[1]
470 .message
471 .contains("Invisible character U+200B detected at line boundary")
472 );
473 }
474
475 #[test]
476 fn test_default_flags_invisible_adjacent_to_whitespace() {
477 let findings = check("a \u{2060}b");
478 assert_eq!(findings.len(), 1);
479 assert!(
480 findings[0]
481 .message
482 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
483 );
484 }
485
486 #[test]
487 fn test_default_fix_removes_triggered_characters() {
488 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
489 }
490
491 #[test]
492 fn test_strict_flags_any_invisible_character() {
493 let findings = check_with_config("ca\u{200C}t", true, "");
494 assert_eq!(findings.len(), 1);
495 assert!(findings[0].message.contains("strict mode"));
496 assert!(findings[0].fix.is_some());
497
498 assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
499 }
500
501 #[test]
502 fn test_allow_list_suppresses_findings() {
503 assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
504 }
505
506 #[test]
507 fn test_md084_default_triggers_are_targeted() {
508 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
509 assert_eq!(findings.len(), 4);
510
511 assert!(findings.iter().all(|w| w.fix.is_some()));
513 }
514
515 #[test]
516 fn test_md084_strict_mode_flags_any_invisible() {
517 let findings = check_with_config("in\u{200C}word", true, "");
518 assert_eq!(findings.len(), 1);
519 assert!(findings[0].fix.is_some());
520 }
521
522 #[test]
523 fn test_md084_allow_list_by_codepoint() {
524 let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
525 assert!(findings.is_empty());
526 }
527
528 #[test]
529 fn test_tab_characters() {
530 let findings = check("text\n\tindented\n");
531 assert!(findings.is_empty());
532 }
533
534 #[test]
535 fn test_carriage_returns_are_line_endings_not_hidden_content() {
536 for content in [
543 "# Title\rSome text\rMore text\r",
544 "# Title\r\rSome text\r",
545 "# Title \rSome text\r",
546 "a\rb\n",
547 ] {
548 let findings = check(content);
549 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
550 assert_eq!(fix(content), content, "fixing {content:?}");
551 }
552 }
553
554 #[test]
555 fn test_strict_mode_keeps_carriage_returns() {
556 for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
560 let findings = check_with_config(content, true, "");
561 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
562 assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
563 }
564 }
565
566 #[test]
567 fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
568 for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
573 let findings = check_with_config(content, strict, "");
574 assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
575 assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
576 }
577 }
578
579 #[test]
580 fn test_line_feeds_do_not_defeat_the_skip_guard() {
581 let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
586 assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
587
588 let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
589 assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
590 }
591
592 #[test]
593 fn test_default_ignores_variation_selector_attached_to_base() {
594 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
597 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
598
599 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
600 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
601 }
602
603 #[test]
604 fn test_default_fix_preserves_emoji_presentation() {
605 let content = "> \u{26A0}\u{FE0F} Note: important\n";
606 assert_eq!(fix(content), content);
607 }
608
609 #[test]
610 fn test_default_flags_orphaned_variation_selector() {
611 let findings = check("\u{FE0F}starts with a selector");
613 assert_eq!(findings.len(), 1);
614 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
615
616 let findings = check("a \u{FE0F}b");
617 assert_eq!(findings.len(), 1);
618 assert!(
619 findings[0]
620 .message
621 .contains("U+FE0F detected adjacent to visible whitespace")
622 );
623
624 let findings = check("a\u{200B}\u{FE0F}b");
626 assert_eq!(findings.len(), 1);
627 assert!(
628 findings[0]
629 .message
630 .contains("2 multiple consecutive invisible characters")
631 );
632 }
633
634 #[test]
635 fn test_default_flags_redundant_variation_selector() {
636 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
641 let findings = check(content);
642 assert_eq!(findings.len(), 1, "content {content:?}");
643 assert_eq!(findings[0].column, 3, "content {content:?}");
644 assert_eq!(findings[0].end_column, 4, "content {content:?}");
645 assert!(
646 findings[0]
647 .message
648 .contains("U+FE0F detected next to another invisible character"),
649 "content {content:?}: {}",
650 findings[0].message
651 );
652 }
653 }
654
655 #[test]
656 fn test_default_ignores_emoji_zwj_sequences() {
657 let sequences = [
660 "\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}", ];
666
667 for sequence in sequences {
668 let content = format!("look: {sequence} here");
669 let findings = check(&content);
670 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
671
672 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
673 }
674 }
675
676 #[test]
677 fn test_default_flags_orphaned_joiner() {
678 let findings = check("joins nothing\u{200D}");
680 assert_eq!(findings.len(), 1);
681 assert!(findings[0].message.contains("U+200D detected at line boundary"));
682
683 let findings = check("a \u{200D}b");
684 assert_eq!(findings.len(), 1);
685 assert!(
686 findings[0]
687 .message
688 .contains("U+200D detected adjacent to visible whitespace")
689 );
690
691 let findings = check("a\u{200D}\u{200B}b");
693 assert_eq!(findings.len(), 1);
694 assert!(
695 findings[0]
696 .message
697 .contains("2 multiple consecutive invisible characters")
698 );
699 }
700
701 #[test]
702 fn test_default_flags_invisible_hiding_behind_an_emoji() {
703 let content = "\u{26A0}\u{FE0F}\u{200B}x";
707 let findings = check(content);
708 assert_eq!(findings.len(), 1);
709 assert_eq!(findings[0].column, 3);
710 assert!(
711 findings[0]
712 .message
713 .contains("U+200B detected next to another invisible character")
714 );
715
716 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
718 }
719
720 #[test]
721 fn test_strict_still_flags_attached_variation_selector() {
722 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
725 assert_eq!(findings.len(), 1);
726 assert!(findings[0].message.contains("strict mode"));
727 }
728
729 #[test]
730 fn test_default_markup_unsuitable_characters_are_flagged() {
731 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
734 assert_eq!(findings.len(), 3, "Got {findings:?}");
735 assert!(
736 findings[0]
737 .message
738 .contains("U+0340 is not suitable for use with markup")
739 );
740 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
741 assert!(
742 findings[1]
743 .message
744 .contains("U+0341 is not suitable for use with markup")
745 );
746 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
747 assert!(
748 findings[2]
749 .message
750 .contains("U+FFFC is not suitable for use with markup")
751 );
752 assert!(findings[2].fix.is_none());
753 }
754
755 #[test]
756 fn test_strict_markup_unsuitable_characters_are_flagged() {
757 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
758 assert_eq!(findings.len(), 3, "Got {findings:?}");
759 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
760 assert!(findings[1].message.contains("U+0341"));
761 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
762 assert!(findings[2].message.contains("U+FFFC"));
763 assert!(findings[2].fix.is_none());
764 }
765
766 #[test]
767 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
768 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
769 assert!(findings.is_empty());
770 }
771
772 #[test]
773 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
774 let findings = check("Cote d\u{0149}Ivoire");
776 assert_eq!(findings.len(), 1, "Got {findings:?}");
777 assert!(
778 findings[0]
779 .message
780 .contains("Deprecated Unicode code point U+0149 detected")
781 );
782 assert!(findings[0].fix.is_none());
783 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
784 }
785
786 #[test]
787 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
788 for (content, expected_fix) in [
791 ("\u{206A}x", "x"),
792 ("x\u{206A}", "x"),
793 ("x \u{206A}y", "x y"),
794 ("x\u{206A}\u{206B}y", "xy"),
795 ] {
796 let findings = check(content);
797 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
798 assert!(
799 findings[0].message.starts_with("Invisible character")
800 || findings[0].message.contains("consecutive invisible characters"),
801 "{content:?} gave {:?}",
802 findings[0].message
803 );
804 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
805 }
806 }
807
808 #[test]
809 fn test_deprecated_and_invisible_is_reported_once() {
810 let findings = check("x\u{206A}y");
813 assert_eq!(findings.len(), 1, "Got {findings:?}");
814 assert!(
815 findings[0]
816 .message
817 .contains("Deprecated Unicode code point U+206A detected")
818 );
819 assert!(findings[0].fix.is_none());
820 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
821 }
822
823 #[test]
824 fn test_interlinear_annotation_is_reported_but_never_stripped() {
825 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
828 let findings = check(content);
829 assert_eq!(findings.len(), 3, "Got {findings:?}");
830 for finding in &findings {
831 assert!(finding.message.contains("is not suitable for use with markup"));
832 assert!(finding.fix.is_none());
833 }
834 assert_eq!(fix(content), content);
835 }
836
837 #[test]
838 fn test_annotation_delimiter_is_not_a_presentation_base() {
839 for (content, expected_fix) in [
844 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
845 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
846 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
847 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
848 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
849 ] {
850 let findings = check(content);
851 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
852 assert!(
853 findings.iter().any(|f| f.message.contains("Invisible character")
854 || f.message.contains("consecutive invisible characters")),
855 "{content:?} gave {findings:?}"
856 );
857 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
858 }
859 }
860
861 #[test]
862 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
863 let findings = check("\u{FFF8}x");
865 assert_eq!(findings.len(), 1, "Got {findings:?}");
866 assert!(
867 findings[0]
868 .message
869 .contains("Invisible character U+FFF8 detected at line boundary")
870 );
871 assert_eq!(fix("\u{FFF8}x"), "x");
872 }
873}