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;
23use std::cell::OnceCell;
24use std::ops::Range;
25
26const FIX_CHECKPOINT_CHARS: usize = 256;
27
28struct LineFixSource<'a> {
31 line: usize,
32 byte_start: usize,
33 chars: &'a [char],
34 byte_offsets: OnceCell<Vec<usize>>,
35}
36
37impl LineFixSource<'_> {
38 fn byte_range(&self, column: usize, length: usize) -> Range<usize> {
40 let offsets = self.byte_offsets.get_or_init(|| {
41 let mut byte = self.byte_start;
42 let mut checkpoints = Vec::with_capacity(self.chars.len() / FIX_CHECKPOINT_CHARS + 2);
43 checkpoints.push(byte);
44 for chunk in self.chars.chunks(FIX_CHECKPOINT_CHARS) {
45 byte += chunk.iter().map(|c| c.len_utf8()).sum::<usize>();
46 checkpoints.push(byte);
47 }
48 checkpoints
49 });
50 let byte_at = |index: usize| {
51 let checkpoint = index / FIX_CHECKPOINT_CHARS;
52 offsets[checkpoint]
53 + self.chars[checkpoint * FIX_CHECKPOINT_CHARS..index]
54 .iter()
55 .map(|c| c.len_utf8())
56 .sum::<usize>()
57 };
58 let start = column - 1;
59 byte_at(start)..byte_at(start + length)
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct MD084InvisibleCharacters {
65 config: MD084Config,
66}
67
68impl Default for MD084InvisibleCharacters {
69 fn default() -> Self {
70 Self::from_config_struct(MD084Config::default())
71 }
72}
73
74impl MD084InvisibleCharacters {
75 fn from_config_struct(config: MD084Config) -> Self {
76 Self { config }
77 }
78
79 #[inline]
80 fn is_allowed(&self, c: char) -> bool {
81 self.config.allow.contains(&c)
82 }
83
84 #[inline]
86 fn is_markup_char(c: char) -> bool {
87 unicode::is_deprecated_char(c) || unicode::is_unsuitable_for_markup_char(c)
88 }
89
90 #[inline]
94 fn is_annotation_delimiter(c: char) -> bool {
95 matches!(c as u32, 0xFFF9..=0xFFFB)
96 }
97
98 #[inline]
108 fn is_line_ending(c: char) -> bool {
109 c == '\n' || c == '\r'
110 }
111
112 #[inline]
116 fn draws_no_glyph(c: char) -> bool {
117 unicode::is_invisible_char(c) || Self::is_annotation_delimiter(c)
118 }
119
120 fn markup_finding(c: char) -> Option<(String, Option<String>)> {
123 let deprecated = unicode::is_deprecated_char(c);
124 if !deprecated && !unicode::is_unsuitable_for_markup_char(c) {
125 return None;
126 }
127 let codepoint = unicode::format_codepoint(c);
128 if deprecated {
129 return Some((format!("Deprecated Unicode code point {codepoint} detected"), None));
130 }
131 let replacement = match c as u32 {
134 0x0340 => Some("\u{0300}".to_string()), 0x0341 => Some("\u{0301}".to_string()), _ => None,
137 };
138 Some((
139 format!("Unicode code point {codepoint} is not suitable for use with markup"),
140 replacement,
141 ))
142 }
143
144 fn is_variation_selector(c: char) -> bool {
148 matches!(
149 c as u32,
150 0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
154 }
155
156 const ZWJ: char = '\u{200D}';
158
159 fn is_visible_base(chars: &[char], index: usize) -> bool {
163 chars
164 .get(index)
165 .is_some_and(|&c| !c.is_whitespace() && !Self::draws_no_glyph(c))
166 }
167
168 fn follows_visible_base(chars: &[char], index: usize) -> bool {
172 let Some(prev) = index.checked_sub(1) else {
173 return false;
174 };
175
176 Self::is_visible_base(chars, prev)
177 || (Self::is_variation_selector(chars[prev])
178 && prev
179 .checked_sub(1)
180 .is_some_and(|base| Self::is_visible_base(chars, base)))
181 }
182
183 fn is_presentation(chars: &[char], index: usize) -> bool {
191 let c = chars[index];
192
193 if Self::is_variation_selector(c) {
194 return index
197 .checked_sub(1)
198 .is_some_and(|prev| Self::is_visible_base(chars, prev));
199 }
200
201 c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
202 }
203
204 fn cluster_message(len: usize, first: char) -> String {
208 let codepoint = unicode::format_codepoint(first);
209 if len >= 2 {
210 format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
211 } else {
212 format!("Invisible character {codepoint} detected next to another invisible character")
213 }
214 }
215
216 #[inline]
219 fn build_warning(
220 &self,
221 source: &LineFixSource,
222 start_col: usize,
223 len_chars: usize,
224 message: String,
225 replacement: Option<String>,
226 ) -> LintWarning {
227 let fix = replacement.map(|replacement| Fix::new(source.byte_range(start_col, len_chars), replacement));
228
229 LintWarning {
230 rule_name: Some(self.name().to_string()),
231 line: source.line,
232 column: start_col,
233 end_line: source.line,
234 end_column: start_col + len_chars,
235 severity: Severity::Warning,
236 message,
237 fix,
238 }
239 }
240}
241
242impl Rule for MD084InvisibleCharacters {
243 fn name(&self) -> &'static str {
244 "MD084"
245 }
246
247 fn description(&self) -> &'static str {
248 "Invisible or discouraged Unicode characters should be intentional"
249 }
250
251 fn category(&self) -> RuleCategory {
252 RuleCategory::Whitespace
253 }
254
255 fn fix_capability(&self) -> FixCapability {
256 FixCapability::ConditionallyFixable
257 }
258
259 fn should_skip(&self, ctx: &LintContext) -> bool {
260 ctx.content.is_empty()
261 || !ctx.content.chars().any(|c| {
262 (unicode::is_invisible_char(c) || Self::is_markup_char(c))
263 && !Self::is_line_ending(c)
264 && !self.is_allowed(c)
265 })
266 }
267
268 fn check(&self, ctx: &LintContext) -> LintResult {
269 let mut warnings = Vec::new();
270
271 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
272 let line_num = line_idx + 1;
273 let chars: Vec<char> = line.chars().collect();
274
275 if chars.is_empty() {
276 continue;
277 }
278
279 let source = LineFixSource {
280 line: line_num,
281 byte_start: ctx.line_offsets[line_idx],
282 chars: &chars,
283 byte_offsets: OnceCell::new(),
284 };
285
286 if self.config.strict {
291 warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
292 if self.is_allowed(c) || Self::is_line_ending(c) {
293 None
294 } else if unicode::is_invisible_char(c) {
295 Some(self.build_warning(
296 &source,
297 i + 1,
298 1,
299 format!(
300 "Invisible character {} detected (strict mode)",
301 unicode::format_codepoint(c)
302 ),
303 Some(String::new()),
304 ))
305 } else {
306 Self::markup_finding(c)
307 .map(|(message, replacement)| self.build_warning(&source, i + 1, 1, message, replacement))
308 }
309 }));
310 continue;
311 }
312
313 let mut flagged = vec![false; chars.len()];
320 let flaggable: Vec<bool> = chars
321 .iter()
322 .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
323 .collect();
324 let exempt: Vec<bool> = (0..chars.len())
325 .map(|i| {
326 Self::is_annotation_delimiter(chars[i])
327 || Self::is_line_ending(chars[i])
328 || Self::is_presentation(&chars, i)
329 })
330 .collect();
331 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
332
333 let mut offset = 0;
337 for group in flaggable.chunk_by(|a, b| a == b) {
338 let len = group.len();
339 if group[0] && len >= 2 {
340 let mut start = offset;
341 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
342 let stretch_len = stretch.len();
343 if !stretch[0] {
344 flagged[start..start + stretch_len].fill(true);
345 warnings.push(self.build_warning(
346 &source,
347 start + 1,
348 stretch_len,
349 Self::cluster_message(stretch_len, chars[start]),
350 Some(String::new()),
351 ));
352 }
353 start += stretch_len;
354 }
355 }
356 offset += len;
357 }
358
359 for (i, &c) in chars.iter().enumerate() {
361 if !is_target[i] || flagged[i] {
362 continue;
363 }
364
365 if i == 0 || i == chars.len() - 1 {
367 flagged[i] = true;
368 warnings.push(self.build_warning(
369 &source,
370 i + 1,
371 1,
372 format!(
373 "Invisible character {} detected at line boundary",
374 unicode::format_codepoint(c)
375 ),
376 Some(String::new()),
377 ));
378 continue;
379 }
380
381 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
385 flagged[i] = true;
386 warnings.push(self.build_warning(
387 &source,
388 i + 1,
389 1,
390 format!(
391 "Invisible character {} detected adjacent to visible whitespace",
392 unicode::format_codepoint(c)
393 ),
394 Some(String::new()),
395 ));
396 }
397 }
398
399 for (i, &c) in chars.iter().enumerate() {
405 if flagged[i] || self.is_allowed(c) {
406 continue;
407 }
408 let Some((message, replacement)) = Self::markup_finding(c) else {
409 continue;
410 };
411 flagged[i] = true;
412 warnings.push(self.build_warning(&source, i + 1, 1, message, replacement));
413 }
414 }
415
416 Ok(warnings)
417 }
418
419 fn fixes_from_diagnostics(&self) -> bool {
420 true
421 }
422
423 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
424 if self.should_skip(ctx) {
425 return Ok(ctx.content.to_string());
426 }
427
428 let warnings = self.check(ctx)?;
429 if warnings.is_empty() {
430 return Ok(ctx.content.to_string());
431 }
432
433 let warnings =
434 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
435 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
436 .map_err(crate::rule::LintError::InvalidInput)
437 }
438
439 fn as_any(&self) -> &dyn std::any::Any {
440 self
441 }
442
443 crate::impl_rule_config_methods!(MD084Config);
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::config::MarkdownFlavor;
450
451 fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
452 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
453 let config = MD084Config {
454 strict,
455 allow: allow.chars().collect(),
456 };
457 MD084InvisibleCharacters::from_config_struct(config)
458 .check(&ctx)
459 .unwrap()
460 }
461
462 fn check(content: &str) -> Vec<LintWarning> {
463 check_with_config(content, false, "")
464 }
465
466 fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
467 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
468 let config = MD084Config {
469 strict,
470 allow: allow.chars().collect(),
471 };
472 MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
473 }
474
475 fn fix(content: &str) -> String {
476 fix_with_config(content, false, "")
477 }
478
479 #[test]
480 fn cached_byte_boundaries_match_scalar_ranges() {
481 for content in ["", "\n", "é日本🙂\r\n\nlast\r", "a\u{200b}\u{200d}⚠️\n日本\u{0340}"] {
482 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
483 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
484 let chars: Vec<_> = line.chars().collect();
485 let source = LineFixSource {
486 line: line_idx + 1,
487 byte_start: ctx.line_offsets[line_idx],
488 chars: &chars,
489 byte_offsets: OnceCell::new(),
490 };
491 for start in (0..=chars.len()).rev() {
492 for len in 0..=chars.len() - start {
493 assert_eq!(
494 source.byte_range(start + 1, len),
495 ctx.line_column_byte_range_with_length(line_idx + 1, start + 1, len)
496 );
497 }
498 }
499 }
500 }
501 }
502
503 #[test]
504 fn sparse_fix_checkpoints_match_scalar_ranges_in_any_order() {
505 for repeats in [64, 160] {
506 let content = format!("first\r\n{}", "aé日🙂".repeat(repeats));
507 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
508 let chars: Vec<_> = ctx.raw_lines()[1].chars().collect();
509 let source = LineFixSource {
510 line: 2,
511 byte_start: ctx.line_offsets[1],
512 chars: &chars,
513 byte_offsets: OnceCell::new(),
514 };
515 for start in (0..=chars.len()).rev() {
516 for len in [0, 1.min(chars.len() - start), chars.len() - start] {
517 assert_eq!(
518 source.byte_range(start + 1, len),
519 ctx.line_column_byte_range_with_length(2, start + 1, len)
520 );
521 }
522 }
523 }
524 }
525
526 #[test]
527 fn cached_fix_ranges_preserve_unicode_exceptions_and_line_endings() {
528 let unit = "\u{200b} é日本🙂 x\u{200b}\u{200b} y\u{0340} z\u{206a} ⚠️ 🏳️🌈 ";
529 for ending in ["\n", "\r\n", "\r"] {
530 let content = format!("{}{ending}{}\u{200b}", unit.repeat(48), unit.repeat(48));
531 for flavor in crate::format_proof::ALL_FLAVORS {
532 let ctx = LintContext::new(&content, flavor, None);
533 for strict in [false, true] {
534 for allow in ["", "\u{200b}\u{0340}"] {
535 let rule = MD084InvisibleCharacters::from_config_struct(MD084Config {
536 strict,
537 allow: allow.chars().collect(),
538 });
539 let warnings = rule.check(&ctx).unwrap();
540 assert!(!warnings.is_empty());
541 assert!(warnings.iter().any(|warning| warning.fix.is_some()));
542 for warning in warnings {
543 if let Some(fix) = warning.fix {
544 assert_eq!(
545 fix.range,
546 ctx.line_column_byte_range_with_length(
547 warning.line,
548 warning.column,
549 warning.end_column - warning.column
550 ),
551 "{flavor:?}, strict={strict}, allow={allow:?}"
552 );
553 }
554 }
555 }
556 }
557 }
558 }
559 }
560
561 #[test]
562 fn test_default_no_findings_on_plain_text() {
563 let findings = check("plain text\nsecond line\n");
564 assert!(findings.is_empty());
565 }
566
567 #[test]
568 fn test_default_flags_multiple_consecutive_invisibles() {
569 let findings = check("a\u{200B}\u{200C}b");
570 assert_eq!(findings.len(), 1);
571 assert!(
572 findings[0]
573 .message
574 .contains("2 multiple consecutive invisible characters detected")
575 );
576 assert_eq!(findings[0].column, 2);
577 assert_eq!(findings[0].end_column, 4);
578 assert!(findings[0].fix.is_some());
579 }
580
581 #[test]
582 fn test_default_flags_invisible_chars_at_line_boundaries() {
583 let findings = check("\u{2060}start\nend\u{200B}");
584 assert_eq!(findings.len(), 2);
585 assert!(
586 findings[0]
587 .message
588 .contains("Invisible character U+2060 detected at line boundary")
589 );
590 assert!(
591 findings[1]
592 .message
593 .contains("Invisible character U+200B detected at line boundary")
594 );
595 }
596
597 #[test]
598 fn test_default_flags_invisible_adjacent_to_whitespace() {
599 let findings = check("a \u{2060}b");
600 assert_eq!(findings.len(), 1);
601 assert!(
602 findings[0]
603 .message
604 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
605 );
606 }
607
608 #[test]
609 fn test_default_fix_removes_triggered_characters() {
610 assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft right");
611 }
612
613 #[test]
614 fn test_strict_flags_any_invisible_character() {
615 let findings = check_with_config("ca\u{200C}t", true, "");
616 assert_eq!(findings.len(), 1);
617 assert!(findings[0].message.contains("strict mode"));
618 assert!(findings[0].fix.is_some());
619
620 assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
621 }
622
623 #[test]
624 fn test_allow_list_suppresses_findings() {
625 assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
626 }
627
628 #[test]
629 fn test_md084_default_triggers_are_targeted() {
630 let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
631 assert_eq!(findings.len(), 4);
632
633 assert!(findings.iter().all(|w| w.fix.is_some()));
635 }
636
637 #[test]
638 fn test_md084_strict_mode_flags_any_invisible() {
639 let findings = check_with_config("in\u{200C}word", true, "");
640 assert_eq!(findings.len(), 1);
641 assert!(findings[0].fix.is_some());
642 }
643
644 #[test]
645 fn test_md084_allow_list_by_codepoint() {
646 let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
647 assert!(findings.is_empty());
648 }
649
650 #[test]
651 fn test_tab_characters() {
652 let findings = check("text\n\tindented\n");
653 assert!(findings.is_empty());
654 }
655
656 #[test]
657 fn test_carriage_returns_are_line_endings_not_hidden_content() {
658 for content in [
665 "# Title\rSome text\rMore text\r",
666 "# Title\r\rSome text\r",
667 "# Title \rSome text\r",
668 "a\rb\n",
669 ] {
670 let findings = check(content);
671 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
672 assert_eq!(fix(content), content, "fixing {content:?}");
673 }
674 }
675
676 #[test]
677 fn test_strict_mode_keeps_carriage_returns() {
678 for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
682 let findings = check_with_config(content, true, "");
683 assert!(findings.is_empty(), "{content:?} gave {findings:?}");
684 assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
685 }
686 }
687
688 #[test]
689 fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
690 for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
695 let findings = check_with_config(content, strict, "");
696 assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
697 assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
698 }
699 }
700
701 #[test]
702 fn test_line_feeds_do_not_defeat_the_skip_guard() {
703 let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
708 assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
709
710 let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
711 assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
712 }
713
714 #[test]
715 fn test_default_ignores_variation_selector_attached_to_base() {
716 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
719 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
720
721 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
722 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
723 }
724
725 #[test]
726 fn test_default_fix_preserves_emoji_presentation() {
727 let content = "> \u{26A0}\u{FE0F} Note: important\n";
728 assert_eq!(fix(content), content);
729 }
730
731 #[test]
732 fn test_default_flags_orphaned_variation_selector() {
733 let findings = check("\u{FE0F}starts with a selector");
735 assert_eq!(findings.len(), 1);
736 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
737
738 let findings = check("a \u{FE0F}b");
739 assert_eq!(findings.len(), 1);
740 assert!(
741 findings[0]
742 .message
743 .contains("U+FE0F detected adjacent to visible whitespace")
744 );
745
746 let findings = check("a\u{200B}\u{FE0F}b");
748 assert_eq!(findings.len(), 1);
749 assert!(
750 findings[0]
751 .message
752 .contains("2 multiple consecutive invisible characters")
753 );
754 }
755
756 #[test]
757 fn test_default_flags_redundant_variation_selector() {
758 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
763 let findings = check(content);
764 assert_eq!(findings.len(), 1, "content {content:?}");
765 assert_eq!(findings[0].column, 3, "content {content:?}");
766 assert_eq!(findings[0].end_column, 4, "content {content:?}");
767 assert!(
768 findings[0]
769 .message
770 .contains("U+FE0F detected next to another invisible character"),
771 "content {content:?}: {}",
772 findings[0].message
773 );
774 }
775 }
776
777 #[test]
778 fn test_default_ignores_emoji_zwj_sequences() {
779 let sequences = [
782 "\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}", ];
788
789 for sequence in sequences {
790 let content = format!("look: {sequence} here");
791 let findings = check(&content);
792 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
793
794 assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
795 }
796 }
797
798 #[test]
799 fn test_default_flags_orphaned_joiner() {
800 let findings = check("joins nothing\u{200D}");
802 assert_eq!(findings.len(), 1);
803 assert!(findings[0].message.contains("U+200D detected at line boundary"));
804
805 let findings = check("a \u{200D}b");
806 assert_eq!(findings.len(), 1);
807 assert!(
808 findings[0]
809 .message
810 .contains("U+200D detected adjacent to visible whitespace")
811 );
812
813 let findings = check("a\u{200D}\u{200B}b");
815 assert_eq!(findings.len(), 1);
816 assert!(
817 findings[0]
818 .message
819 .contains("2 multiple consecutive invisible characters")
820 );
821 }
822
823 #[test]
824 fn test_default_flags_invisible_hiding_behind_an_emoji() {
825 let content = "\u{26A0}\u{FE0F}\u{200B}x";
829 let findings = check(content);
830 assert_eq!(findings.len(), 1);
831 assert_eq!(findings[0].column, 3);
832 assert!(
833 findings[0]
834 .message
835 .contains("U+200B detected next to another invisible character")
836 );
837
838 assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
840 }
841
842 #[test]
843 fn test_strict_still_flags_attached_variation_selector() {
844 let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
847 assert_eq!(findings.len(), 1);
848 assert!(findings[0].message.contains("strict mode"));
849 }
850
851 #[test]
852 fn test_default_markup_unsuitable_characters_are_flagged() {
853 let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
856 assert_eq!(findings.len(), 3, "Got {findings:?}");
857 assert!(
858 findings[0]
859 .message
860 .contains("U+0340 is not suitable for use with markup")
861 );
862 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
863 assert!(
864 findings[1]
865 .message
866 .contains("U+0341 is not suitable for use with markup")
867 );
868 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
869 assert!(
870 findings[2]
871 .message
872 .contains("U+FFFC is not suitable for use with markup")
873 );
874 assert!(findings[2].fix.is_none());
875 }
876
877 #[test]
878 fn test_strict_markup_unsuitable_characters_are_flagged() {
879 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
880 assert_eq!(findings.len(), 3, "Got {findings:?}");
881 assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
882 assert!(findings[1].message.contains("U+0341"));
883 assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
884 assert!(findings[2].message.contains("U+FFFC"));
885 assert!(findings[2].fix.is_none());
886 }
887
888 #[test]
889 fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
890 let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
891 assert!(findings.is_empty());
892 }
893
894 #[test]
895 fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
896 let findings = check("Cote d\u{0149}Ivoire");
898 assert_eq!(findings.len(), 1, "Got {findings:?}");
899 assert!(
900 findings[0]
901 .message
902 .contains("Deprecated Unicode code point U+0149 detected")
903 );
904 assert!(findings[0].fix.is_none());
905 assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
906 }
907
908 #[test]
909 fn test_deprecated_and_invisible_keeps_the_removal_fix() {
910 for (content, expected_fix) in [
913 ("\u{206A}x", "x"),
914 ("x\u{206A}", "x"),
915 ("x \u{206A}y", "x y"),
916 ("x\u{206A}\u{206B}y", "xy"),
917 ] {
918 let findings = check(content);
919 assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
920 assert!(
921 findings[0].message.starts_with("Invisible character")
922 || findings[0].message.contains("consecutive invisible characters"),
923 "{content:?} gave {:?}",
924 findings[0].message
925 );
926 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
927 }
928 }
929
930 #[test]
931 fn test_deprecated_and_invisible_is_reported_once() {
932 let findings = check("x\u{206A}y");
935 assert_eq!(findings.len(), 1, "Got {findings:?}");
936 assert!(
937 findings[0]
938 .message
939 .contains("Deprecated Unicode code point U+206A detected")
940 );
941 assert!(findings[0].fix.is_none());
942 assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
943 }
944
945 #[test]
946 fn test_interlinear_annotation_is_reported_but_never_stripped() {
947 let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
950 let findings = check(content);
951 assert_eq!(findings.len(), 3, "Got {findings:?}");
952 for finding in &findings {
953 assert!(finding.message.contains("is not suitable for use with markup"));
954 assert!(finding.fix.is_none());
955 }
956 assert_eq!(fix(content), content);
957 }
958
959 #[test]
960 fn test_annotation_delimiter_is_not_a_presentation_base() {
961 for (content, expected_fix) in [
966 ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
967 ("\u{FFF9}\u{200D}", "\u{FFF9}"),
968 ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
969 ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
970 ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
971 ] {
972 let findings = check(content);
973 assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
974 assert!(
975 findings.iter().any(|f| f.message.contains("Invisible character")
976 || f.message.contains("consecutive invisible characters")),
977 "{content:?} gave {findings:?}"
978 );
979 assert_eq!(fix(content), expected_fix, "fixing {content:?}");
980 }
981 }
982
983 #[test]
984 fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
985 let findings = check("\u{FFF8}x");
987 assert_eq!(findings.len(), 1, "Got {findings:?}");
988 assert!(
989 findings[0]
990 .message
991 .contains("Invisible character U+FFF8 detected at line boundary")
992 );
993 assert_eq!(fix("\u{FFF8}x"), "x");
994 }
995}