1mod md084_config;
18
19use crate::lint_context::LintContext;
20use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
21use md084_config::MD084Config;
22use std::collections::HashSet;
23
24#[derive(Debug, Clone)]
25pub struct MD084InvisibleCharacters {
26 config: MD084Config,
27 allowed_codepoints: HashSet<u32>,
28}
29
30impl Default for MD084InvisibleCharacters {
31 fn default() -> Self {
32 Self::from_config_struct(MD084Config::default())
33 }
34}
35
36impl MD084InvisibleCharacters {
37 fn from_config_struct(config: MD084Config) -> Self {
38 let allowed_codepoints = config
39 .allow
40 .iter()
41 .filter_map(|token| parse_codepoint_token(token))
42 .collect();
43
44 Self {
45 config,
46 allowed_codepoints,
47 }
48 }
49
50 #[inline]
51 fn is_allowed(&self, c: char) -> bool {
52 self.allowed_codepoints.contains(&(c as u32))
53 }
54
55 fn format_codepoint(c: char) -> String {
56 let cp = c as u32;
57 if cp <= 0xFFFF {
58 format!("U+{cp:04X}")
59 } else {
60 format!("U+{cp:06X}")
61 }
62 }
63
64 fn is_invisible_char(c: char) -> bool {
65 let cp = c as u32;
66 matches!(
67 cp,
68 0x0000..=0x0008
69 | 0x000A..=0x001F | 0x007F..=0x009F | 0x00AD | 0x034F | 0x061C | 0x115F | 0x1160 | 0x17B4 | 0x17B5 | 0x180B..=0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x206F | 0x3164 | 0xFE00..=0xFE0F | 0xFEFF | 0xFFA0 | 0xFFF0..=0xFFF8 | 0x1BCA0..=0x1BCA3 | 0x1D173..=0x1D17A | 0xE0000..=0xE0FFF )
91 }
92
93 #[inline]
97 fn is_deprecated_char(c: char) -> bool {
98 let cp = c as u32;
99 matches!(
100 cp,
101 0x0149 | 0x0673 | 0x0F77 | 0x0F79 | 0x17A3..=0x17A4 | 0x206A..=0x206F | 0x2329 | 0x232A | 0xE0001 )
111 }
112
113 #[inline]
119 fn is_unsuitable_for_markup_char(c: char) -> bool {
120 let cp = c as u32;
121 matches!(
122 cp,
123 0x0340 | 0x0341 | 0xFFF9..=0xFFFC )
127 }
128
129 #[inline]
131 fn is_markup_char(c: char) -> bool {
132 Self::is_deprecated_char(c) || Self::is_unsuitable_for_markup_char(c)
133 }
134
135 #[inline]
139 fn is_annotation_delimiter(c: char) -> bool {
140 matches!(c as u32, 0xFFF9..=0xFFFB)
141 }
142
143 #[inline]
147 fn draws_no_glyph(c: char) -> bool {
148 Self::is_invisible_char(c) || Self::is_annotation_delimiter(c)
149 }
150
151 fn markup_finding(c: char) -> Option<(String, Option<String>)> {
154 let codepoint = Self::format_codepoint(c);
155 if Self::is_deprecated_char(c) {
156 return Some((format!("Deprecated Unicode code point {codepoint} detected"), None));
157 }
158 if !Self::is_unsuitable_for_markup_char(c) {
159 return None;
160 }
161 let replacement = match c as u32 {
164 0x0340 => Some("\u{0300}".to_string()), 0x0341 => Some("\u{0301}".to_string()), _ => None,
167 };
168 Some((
169 format!("Unicode code point {codepoint} is not suitable for use with markup"),
170 replacement,
171 ))
172 }
173
174 fn is_variation_selector(c: char) -> bool {
178 matches!(
179 c as u32,
180 0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
184 }
185
186 const ZWJ: char = '\u{200D}';
188
189 fn is_visible_base(chars: &[char], index: usize) -> bool {
193 chars
194 .get(index)
195 .is_some_and(|&c| !c.is_whitespace() && !Self::draws_no_glyph(c))
196 }
197
198 fn follows_visible_base(chars: &[char], index: usize) -> bool {
202 let Some(prev) = index.checked_sub(1) else {
203 return false;
204 };
205
206 Self::is_visible_base(chars, prev)
207 || (Self::is_variation_selector(chars[prev])
208 && prev
209 .checked_sub(1)
210 .is_some_and(|base| Self::is_visible_base(chars, base)))
211 }
212
213 fn is_presentation(chars: &[char], index: usize) -> bool {
221 let c = chars[index];
222
223 if Self::is_variation_selector(c) {
224 return index
227 .checked_sub(1)
228 .is_some_and(|prev| Self::is_visible_base(chars, prev));
229 }
230
231 c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
232 }
233
234 fn cluster_message(len: usize, first: char) -> String {
238 let codepoint = Self::format_codepoint(first);
239 if len >= 2 {
240 format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
241 } else {
242 format!("Invisible character {codepoint} detected next to another invisible character")
243 }
244 }
245
246 #[inline]
249 fn build_warning(
250 &self,
251 ctx: &LintContext,
252 line: usize,
253 start_col: usize,
254 len_chars: usize,
255 message: String,
256 replacement: Option<String>,
257 ) -> LintWarning {
258 let fix = replacement.map(|replacement| {
259 Fix::new(
260 ctx.line_index
261 .line_col_to_byte_range_with_length(line, start_col, len_chars),
262 replacement,
263 )
264 });
265
266 LintWarning {
267 rule_name: Some(self.name().to_string()),
268 line,
269 column: start_col,
270 end_line: line,
271 end_column: start_col + len_chars,
272 severity: Severity::Warning,
273 message,
274 fix,
275 }
276 }
277}
278
279impl Rule for MD084InvisibleCharacters {
280 fn name(&self) -> &'static str {
281 "MD084"
282 }
283
284 fn description(&self) -> &'static str {
285 "Invisible or discouraged Unicode characters should be intentional"
286 }
287
288 fn category(&self) -> RuleCategory {
289 RuleCategory::Whitespace
290 }
291
292 fn fix_capability(&self) -> FixCapability {
293 FixCapability::ConditionallyFixable
294 }
295
296 fn should_skip(&self, ctx: &LintContext) -> bool {
297 ctx.content.is_empty()
298 || !ctx
299 .content
300 .chars()
301 .any(|c| (Self::is_invisible_char(c) || Self::is_markup_char(c)) && !self.is_allowed(c))
302 }
303
304 fn check(&self, ctx: &LintContext) -> LintResult {
305 let mut warnings = Vec::new();
306
307 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
308 let line_num = line_idx + 1;
309 let chars: Vec<char> = line.chars().collect();
310
311 if chars.is_empty() {
312 continue;
313 }
314
315 if self.config.strict {
317 warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
318 if self.is_allowed(c) {
319 None
320 } else if Self::is_invisible_char(c) {
321 Some(self.build_warning(
322 ctx,
323 line_num,
324 i + 1,
325 1,
326 format!(
327 "Invisible character {} detected (strict mode)",
328 Self::format_codepoint(c)
329 ),
330 Some(String::new()),
331 ))
332 } else {
333 Self::markup_finding(c).map(|(message, replacement)| {
334 self.build_warning(ctx, line_num, i + 1, 1, message, replacement)
335 })
336 }
337 }));
338 continue;
339 }
340
341 let mut flagged = vec![false; chars.len()];
346 let flaggable: Vec<bool> = chars
347 .iter()
348 .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
349 .collect();
350 let exempt: Vec<bool> = (0..chars.len())
351 .map(|i| Self::is_annotation_delimiter(chars[i]) || Self::is_presentation(&chars, i))
352 .collect();
353 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
354
355 let mut offset = 0;
359 for group in flaggable.chunk_by(|a, b| a == b) {
360 let len = group.len();
361 if group[0] && len >= 2 {
362 let mut start = offset;
363 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
364 let stretch_len = stretch.len();
365 if !stretch[0] {
366 flagged[start..start + stretch_len].fill(true);
367 warnings.push(self.build_warning(
368 ctx,
369 line_num,
370 start + 1,
371 stretch_len,
372 Self::cluster_message(stretch_len, chars[start]),
373 Some(String::new()),
374 ));
375 }
376 start += stretch_len;
377 }
378 }
379 offset += len;
380 }
381
382 for (i, &c) in chars.iter().enumerate() {
384 if !is_target[i] || flagged[i] {
385 continue;
386 }
387
388 if i == 0 || i == chars.len() - 1 {
390 flagged[i] = true;
391 warnings.push(self.build_warning(
392 ctx,
393 line_num,
394 i + 1,
395 1,
396 format!(
397 "Invisible character {} detected at line boundary",
398 Self::format_codepoint(c)
399 ),
400 Some(String::new()),
401 ));
402 continue;
403 }
404
405 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
409 flagged[i] = true;
410 warnings.push(self.build_warning(
411 ctx,
412 line_num,
413 i + 1,
414 1,
415 format!(
416 "Invisible character {} detected adjacent to visible whitespace",
417 Self::format_codepoint(c)
418 ),
419 Some(String::new()),
420 ));
421 }
422 }
423
424 for (i, &c) in chars.iter().enumerate() {
430 if flagged[i] || self.is_allowed(c) {
431 continue;
432 }
433 let Some((message, replacement)) = Self::markup_finding(c) else {
434 continue;
435 };
436 flagged[i] = true;
437 warnings.push(self.build_warning(ctx, line_num, i + 1, 1, message, replacement));
438 }
439 }
440
441 Ok(warnings)
442 }
443
444 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
445 if self.should_skip(ctx) {
446 return Ok(ctx.content.to_string());
447 }
448
449 let warnings = self.check(ctx)?;
450 if warnings.is_empty() {
451 return Ok(ctx.content.to_string());
452 }
453
454 let warnings =
455 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
456 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
457 .map_err(crate::rule::LintError::InvalidInput)
458 }
459
460 fn as_any(&self) -> &dyn std::any::Any {
461 self
462 }
463
464 crate::impl_rule_config_methods!(MD084Config);
465}
466
467fn parse_codepoint_token(token: &str) -> Option<u32> {
468 let trimmed = token.trim();
469 let hex = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+"))?;
470 if !(4..=6).contains(&hex.len()) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
471 return None;
472 }
473
474 let value = u32::from_str_radix(hex, 16).ok()?;
475 if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
476 return None;
477 }
478 Some(value)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::config::MarkdownFlavor;
485
486 fn check_with_config(content: &str, strict: bool, allow: &Vec<&str>) -> Vec<LintWarning> {
487 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
488 let config = MD084Config {
489 strict,
490 allow: allow.iter().map(std::string::ToString::to_string).collect(),
491 };
492 MD084InvisibleCharacters::from_config_struct(config)
493 .check(&ctx)
494 .unwrap()
495 }
496
497 fn check(content: &str) -> Vec<LintWarning> {
498 check_with_config(content, false, &vec![])
499 }
500
501 fn fix_with_config(content: &str, strict: bool, allow: &Vec<&str>) -> String {
502 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
503 let config = MD084Config {
504 strict,
505 allow: allow.iter().map(std::string::ToString::to_string).collect(),
506 };
507 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
508 }
509
510 fn fix(content: &str) -> String {
511 fix_with_config(content, false, &vec![])
512 }
513
514 #[test]
515 fn test_default_no_findings_on_plain_text() {
516 let findings = check("plain text\nsecond line\n");
517 assert!(findings.is_empty());
518 }
519
520 #[test]
521 fn test_default_flags_multiple_consecutive_invisibles() {
522 let findings = check("a\u{200B}\u{200C}b");
523 assert_eq!(findings.len(), 1);
524 assert!(
525 findings[0]
526 .message
527 .contains("2 multiple consecutive invisible characters detected")
528 );
529 assert_eq!(findings[0].column, 2);
530 assert_eq!(findings[0].end_column, 4);
531 assert!(findings[0].fix.is_some());
532 }
533
534 #[test]
535 fn test_default_flags_invisible_chars_at_line_boundaries() {
536 let findings = check("\u{2060}start\nend\u{200B}");
537 assert_eq!(findings.len(), 2);
538 assert!(
539 findings[0]
540 .message
541 .contains("Invisible character U+2060 detected at line boundary")
542 );
543 assert!(
544 findings[1]
545 .message
546 .contains("Invisible character U+200B detected at line boundary")
547 );
548 }
549
550 #[test]
551 fn test_default_flags_invisible_adjacent_to_whitespace() {
552 let findings = check("a \u{2060}b");
553 assert_eq!(findings.len(), 1);
554 assert!(
555 findings[0]
556 .message
557 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
558 );
559 }
560
561 #[test]
562 fn test_default_fix_removes_triggered_characters() {
563 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
564 }
565
566 #[test]
567 fn test_strict_flags_any_invisible_character() {
568 let findings = check_with_config("ca\u{200C}t", true, &vec![]);
569 assert_eq!(findings.len(), 1);
570 assert!(findings[0].message.contains("strict mode"));
571 assert!(findings[0].fix.is_some());
572
573 assert_eq!(fix_with_config("ca\u{200C}t", true, &vec![]), "cat");
574 }
575
576 #[test]
577 fn test_allow_list_suppresses_findings() {
578 assert!(check_with_config("\u{200B}ok\u{200B}", false, &vec!["U+200B"]).is_empty());
579 }
580
581 #[test]
582 fn test_md084_default_triggers_are_targeted() {
583 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
584 assert_eq!(findings.len(), 4);
585
586 assert!(findings.iter().all(|w| w.fix.is_some()));
588 }
589
590 #[test]
591 fn test_md084_strict_mode_flags_any_invisible() {
592 let findings = check_with_config("in\u{200C}word", true, &vec![]);
593 assert_eq!(findings.len(), 1);
594 assert!(findings[0].fix.is_some());
595 }
596
597 #[test]
598 fn test_md084_allow_list_by_codepoint() {
599 let findings = check_with_config("\u{200B}safe\u{200B}", false, &vec!["U+200B"]);
600 assert!(findings.is_empty());
601 }
602
603 #[test]
604 fn test_tab_characters() {
605 let findings = check("text\n\tindented\n");
606 assert!(findings.is_empty());
607 }
608
609 #[test]
610 fn test_default_ignores_variation_selector_attached_to_base() {
611 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
614 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
615
616 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
617 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
618 }
619
620 #[test]
621 fn test_default_fix_preserves_emoji_presentation() {
622 let content = "> \u{26A0}\u{FE0F} Note: important\n";
623 assert_eq!(fix(content), content);
624 }
625
626 #[test]
627 fn test_default_flags_orphaned_variation_selector() {
628 let findings = check("\u{FE0F}starts with a selector");
630 assert_eq!(findings.len(), 1);
631 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
632
633 let findings = check("a \u{FE0F}b");
634 assert_eq!(findings.len(), 1);
635 assert!(
636 findings[0]
637 .message
638 .contains("U+FE0F detected adjacent to visible whitespace")
639 );
640
641 let findings = check("a\u{200B}\u{FE0F}b");
643 assert_eq!(findings.len(), 1);
644 assert!(
645 findings[0]
646 .message
647 .contains("2 multiple consecutive invisible characters")
648 );
649 }
650
651 #[test]
652 fn test_default_flags_redundant_variation_selector() {
653 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
658 let findings = check(content);
659 assert_eq!(findings.len(), 1, "content {content:?}");
660 assert_eq!(findings[0].column, 3, "content {content:?}");
661 assert_eq!(findings[0].end_column, 4, "content {content:?}");
662 assert!(
663 findings[0]
664 .message
665 .contains("U+FE0F detected next to another invisible character"),
666 "content {content:?}: {}",
667 findings[0].message
668 );
669 }
670 }
671
672 #[test]
673 fn test_default_ignores_emoji_zwj_sequences() {
674 let sequences = [
677 "\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}", ];
683
684 for sequence in sequences {
685 let content = format!("look: {sequence} here");
686 let findings = check(&content);
687 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
688
689 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
690 }
691 }
692
693 #[test]
694 fn test_default_flags_orphaned_joiner() {
695 let findings = check("joins nothing\u{200D}");
697 assert_eq!(findings.len(), 1);
698 assert!(findings[0].message.contains("U+200D detected at line boundary"));
699
700 let findings = check("a \u{200D}b");
701 assert_eq!(findings.len(), 1);
702 assert!(
703 findings[0]
704 .message
705 .contains("U+200D detected adjacent to visible whitespace")
706 );
707
708 let findings = check("a\u{200D}\u{200B}b");
710 assert_eq!(findings.len(), 1);
711 assert!(
712 findings[0]
713 .message
714 .contains("2 multiple consecutive invisible characters")
715 );
716 }
717
718 #[test]
719 fn test_default_flags_invisible_hiding_behind_an_emoji() {
720 let content = "\u{26A0}\u{FE0F}\u{200B}x";
724 let findings = check(content);
725 assert_eq!(findings.len(), 1);
726 assert_eq!(findings[0].column, 3);
727 assert!(
728 findings[0]
729 .message
730 .contains("U+200B detected next to another invisible character")
731 );
732
733 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
735 }
736
737 #[test]
738 fn test_strict_still_flags_attached_variation_selector() {
739 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, &vec![]);
742 assert_eq!(findings.len(), 1);
743 assert!(findings[0].message.contains("strict mode"));
744 }
745
746 #[test]
747 fn test_default_markup_unsuitable_characters_are_flagged() {
748 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
751 assert_eq!(findings.len(), 3, "Got {findings:?}");
752 assert!(
753 findings[0]
754 .message
755 .contains("U+0340 is not suitable for use with markup")
756 );
757 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
758 assert!(
759 findings[1]
760 .message
761 .contains("U+0341 is not suitable for use with markup")
762 );
763 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
764 assert!(
765 findings[2]
766 .message
767 .contains("U+FFFC is not suitable for use with markup")
768 );
769 assert!(findings[2].fix.is_none());
770 }
771
772 #[test]
773 fn test_strict_markup_unsuitable_characters_are_flagged() {
774 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, &vec![]);
775 assert_eq!(findings.len(), 3, "Got {findings:?}");
776 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
777 assert!(findings[1].message.contains("U+0341"));
778 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
779 assert!(findings[2].message.contains("U+FFFC"));
780 assert!(findings[2].fix.is_none());
781 }
782
783 #[test]
784 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
785 let allow = vec!["U+0340", "U+0341", "U+FFFC"];
786 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, &allow);
787 assert!(findings.is_empty());
788 }
789
790 #[test]
791 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
792 let findings = check("Cote d\u{0149}Ivoire");
794 assert_eq!(findings.len(), 1, "Got {findings:?}");
795 assert!(
796 findings[0]
797 .message
798 .contains("Deprecated Unicode code point U+0149 detected")
799 );
800 assert!(findings[0].fix.is_none());
801 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
802 }
803
804 #[test]
805 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
806 for (content, expected_fix) in [
809 ("\u{206A}x", "x"),
810 ("x\u{206A}", "x"),
811 ("x \u{206A}y", "x y"),
812 ("x\u{206A}\u{206B}y", "xy"),
813 ] {
814 let findings = check(content);
815 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
816 assert!(
817 findings[0].message.starts_with("Invisible character")
818 || findings[0].message.contains("consecutive invisible characters"),
819 "{content:?} gave {:?}",
820 findings[0].message
821 );
822 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
823 }
824 }
825
826 #[test]
827 fn test_deprecated_and_invisible_is_reported_once() {
828 let findings = check("x\u{206A}y");
831 assert_eq!(findings.len(), 1, "Got {findings:?}");
832 assert!(
833 findings[0]
834 .message
835 .contains("Deprecated Unicode code point U+206A detected")
836 );
837 assert!(findings[0].fix.is_none());
838 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
839 }
840
841 #[test]
842 fn test_interlinear_annotation_is_reported_but_never_stripped() {
843 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
846 let findings = check(content);
847 assert_eq!(findings.len(), 3, "Got {findings:?}");
848 for finding in &findings {
849 assert!(finding.message.contains("is not suitable for use with markup"));
850 assert!(finding.fix.is_none());
851 }
852 assert_eq!(fix(content), content);
853 }
854
855 #[test]
856 fn test_annotation_delimiter_is_not_a_presentation_base() {
857 for (content, expected_fix) in [
862 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
863 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
864 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
865 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
866 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
867 ] {
868 let findings = check(content);
869 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
870 assert!(
871 findings.iter().any(|f| f.message.contains("Invisible character")
872 || f.message.contains("consecutive invisible characters")),
873 "{content:?} gave {findings:?}"
874 );
875 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
876 }
877 }
878
879 #[test]
880 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
881 let findings = check("\u{FFF8}x");
883 assert_eq!(findings.len(), 1, "Got {findings:?}");
884 assert!(
885 findings[0]
886 .message
887 .contains("Invisible character U+FFF8 detected at line boundary")
888 );
889 assert_eq!(fix("\u{FFF8}x"), "x");
890 }
891}