Skip to main content

rumdl_lib/rules/
md084_invisible_characters.rs

1//! Rule MD084: Invisible and discouraged Unicode characters.
2//!
3//! This rule detects hidden Unicode code points that can create confusing text,
4//! copy/paste bugs, or rendering differences across tools. It also flags code points
5//! Unicode itself steers authors away from: those carrying the `Deprecated` property,
6//! and those UTR#20 lists as unsuitable for use with markup.
7//!
8//! By default, it tries to avoid false positives by only flagging:
9//! 1. Multiple consecutive invisible characters,
10//! 2. Invisible characters at the start or end of a line,
11//! 3. Invisible characters adjacent to any visible whitespace,
12//! 4. Deprecated and markup-unsuitable code points, fixable only where a substitution
13//!    preserves the text exactly.
14//!
15//! In strict mode, it flags any invisible character that is not explicitly allowed in the configuration.
16
17mod 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    /// Whether either set above claims this code point.
46    #[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    /// The interlinear annotation delimiters, which draw no glyph of their own but
52    /// bracket the text between them, so they are kept out of the deletable invisible
53    /// set: removing one would leave the annotation half-open.
54    #[inline]
55    fn is_annotation_delimiter(c: char) -> bool {
56        matches!(c as u32, 0xFFF9..=0xFFFB)
57    }
58
59    /// Whether this code point puts no glyph on the page, whether or not the rule is
60    /// willing to delete it. This is what a variation selector or joiner needs beside
61    /// it to be doing its job, and what makes a stretch of characters a cluster.
62    #[inline]
63    fn draws_no_glyph(c: char) -> bool {
64        unicode::is_invisible_char(c) || Self::is_annotation_delimiter(c)
65    }
66
67    /// A code point flagged by one of the two sets above, with the message that is
68    /// true of it and the replacement that preserves the text, if one exists.
69    fn markup_finding(c: char) -> Option<(String, Option<String>)> {
70        let codepoint = unicode::format_codepoint(c);
71        if unicode::is_deprecated_char(c) {
72            return Some((format!("Deprecated Unicode code point {codepoint} detected"), None));
73        }
74        if !unicode::is_unsuitable_for_markup_char(c) {
75            return None;
76        }
77        // The tone marks are canonical singletons: every normalization form already
78        // rewrites them this way, so the substitution cannot change what renders.
79        let replacement = match c as u32 {
80            0x0340 => Some("\u{0300}".to_string()), // COMBINING GRAVE ACCENT
81            0x0341 => Some("\u{0301}".to_string()), // COMBINING ACUTE ACCENT
82            _ => None,
83        };
84        Some((
85            format!("Unicode code point {codepoint} is not suitable for use with markup"),
86            replacement,
87        ))
88    }
89
90    /// Variation selectors modify the *preceding* base character: `U+26A0 U+FE0F`
91    /// is the emoji-presentation warning sign `⚠️`, where `U+26A0` alone is the
92    /// text-presentation `⚠`.
93    fn is_variation_selector(c: char) -> bool {
94        matches!(
95            c as u32,
96            0x180B..=0x180D // Mongolian free variation selectors FVS1..FVS3
97                | 0xFE00..=0xFE0F // Variation Selectors VS1..VS16
98                | 0xE0100..=0xE01EF // Variation Selectors Supplement VS17..VS256
99        )
100    }
101
102    /// ZERO WIDTH JOINER, which fuses adjacent characters into one glyph.
103    const ZWJ: char = '\u{200D}';
104
105    /// Whether the character at `index` renders a glyph a variation selector can pick
106    /// a form of, or a joiner can fuse: present, not whitespace, and not one of the
107    /// code points this rule knows draws nothing.
108    fn is_visible_base(chars: &[char], index: usize) -> bool {
109        chars
110            .get(index)
111            .is_some_and(|&c| !c.is_whitespace() && !Self::draws_no_glyph(c))
112    }
113
114    /// Whether the character before `index` resolves to visible content, looking past
115    /// a variation selector that is itself attached to a base. That is what lets the
116    /// joiner in `U+1F3F3 U+FE0F U+200D U+1F308` (the rainbow flag) see its base.
117    fn follows_visible_base(chars: &[char], index: usize) -> bool {
118        let Some(prev) = index.checked_sub(1) else {
119            return false;
120        };
121
122        Self::is_visible_base(chars, prev)
123            || (Self::is_variation_selector(chars[prev])
124                && prev
125                    .checked_sub(1)
126                    .is_some_and(|base| Self::is_visible_base(chars, base)))
127    }
128
129    /// Whether the character at `index` is presentation rather than hidden content.
130    /// Both forms below are part of the grapheme cluster a reader sees, so removing
131    /// one changes the rendered text: a variation selector picks the glyph form of the
132    /// character before it, and a joiner fuses the characters on either side of it.
133    /// Orphaned - at the start or end of a line, next to whitespace, or with a
134    /// character that draws no glyph where its base should be - neither is doing that
135    /// job, and stays reportable.
136    fn is_presentation(chars: &[char], index: usize) -> bool {
137        let c = chars[index];
138
139        if Self::is_variation_selector(c) {
140            // A selector modifies exactly the character before it, so a duplicated
141            // selector has nothing left of its own to modify.
142            return index
143                .checked_sub(1)
144                .is_some_and(|prev| Self::is_visible_base(chars, prev));
145        }
146
147        c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
148    }
149
150    /// Message for a reportable stretch inside a run of consecutive invisible
151    /// characters. A stretch shortens to one character when presentation sits next
152    /// to it, which is still a cluster worth reporting.
153    fn cluster_message(len: usize, first: char) -> String {
154        let codepoint = unicode::format_codepoint(first);
155        if len >= 2 {
156            format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
157        } else {
158            format!("Invisible character {codepoint} detected next to another invisible character")
159        }
160    }
161
162    /// Build a warning covering `len_chars` characters, with a fix rewriting them to
163    /// `replacement` when one is given. An empty replacement deletes the run.
164    #[inline]
165    fn build_warning(
166        &self,
167        ctx: &LintContext,
168        line: usize,
169        start_col: usize,
170        len_chars: usize,
171        message: String,
172        replacement: Option<String>,
173    ) -> LintWarning {
174        let fix = replacement.map(|replacement| {
175            Fix::new(
176                ctx.line_index
177                    .line_col_to_byte_range_with_length(line, start_col, len_chars),
178                replacement,
179            )
180        });
181
182        LintWarning {
183            rule_name: Some(self.name().to_string()),
184            line,
185            column: start_col,
186            end_line: line,
187            end_column: start_col + len_chars,
188            severity: Severity::Warning,
189            message,
190            fix,
191        }
192    }
193}
194
195impl Rule for MD084InvisibleCharacters {
196    fn name(&self) -> &'static str {
197        "MD084"
198    }
199
200    fn description(&self) -> &'static str {
201        "Invisible or discouraged Unicode characters should be intentional"
202    }
203
204    fn category(&self) -> RuleCategory {
205        RuleCategory::Whitespace
206    }
207
208    fn fix_capability(&self) -> FixCapability {
209        FixCapability::ConditionallyFixable
210    }
211
212    fn should_skip(&self, ctx: &LintContext) -> bool {
213        ctx.content.is_empty()
214            || !ctx
215                .content
216                .chars()
217                .any(|c| (unicode::is_invisible_char(c) || Self::is_markup_char(c)) && !self.is_allowed(c))
218    }
219
220    fn check(&self, ctx: &LintContext) -> LintResult {
221        let mut warnings = Vec::new();
222
223        for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
224            let line_num = line_idx + 1;
225            let chars: Vec<char> = line.chars().collect();
226
227            if chars.is_empty() {
228                continue;
229            }
230
231            // Quick return for strict mode: flag any invisible character that is not allow-listed.
232            if self.config.strict {
233                warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
234                    if self.is_allowed(c) {
235                        None
236                    } else if unicode::is_invisible_char(c) {
237                        Some(self.build_warning(
238                            ctx,
239                            line_num,
240                            i + 1,
241                            1,
242                            format!(
243                                "Invisible character {} detected (strict mode)",
244                                unicode::format_codepoint(c)
245                            ),
246                            Some(String::new()),
247                        ))
248                    } else {
249                        Self::markup_finding(c).map(|(message, replacement)| {
250                            self.build_warning(ctx, line_num, i + 1, 1, message, replacement)
251                        })
252                    }
253                }));
254                continue;
255            }
256
257            // In non-strict mode, we only flag the three triggers defined in the rule
258            // description. Presentation characters and annotation delimiters are never
259            // reported or removed by those triggers, but they still draw no glyph, so
260            // they count toward a cluster and nothing can hide behind one.
261            let mut flagged = vec![false; chars.len()];
262            let flaggable: Vec<bool> = chars
263                .iter()
264                .map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
265                .collect();
266            let exempt: Vec<bool> = (0..chars.len())
267                .map(|i| Self::is_annotation_delimiter(chars[i]) || Self::is_presentation(&chars, i))
268                .collect();
269            let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
270
271            // Trigger 1: runs of two or more consecutive characters that draw nothing.
272            // The run is measured over all of them, then reported one reportable stretch
273            // at a time so exempt characters inside it are left intact.
274            let mut offset = 0;
275            for group in flaggable.chunk_by(|a, b| a == b) {
276                let len = group.len();
277                if group[0] && len >= 2 {
278                    let mut start = offset;
279                    for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
280                        let stretch_len = stretch.len();
281                        if !stretch[0] {
282                            flagged[start..start + stretch_len].fill(true);
283                            warnings.push(self.build_warning(
284                                ctx,
285                                line_num,
286                                start + 1,
287                                stretch_len,
288                                Self::cluster_message(stretch_len, chars[start]),
289                                Some(String::new()),
290                            ));
291                        }
292                        start += stretch_len;
293                    }
294                }
295                offset += len;
296            }
297
298            // Triggers 2 and 3 need to inspect each remaining candidate's neighbors.
299            for (i, &c) in chars.iter().enumerate() {
300                if !is_target[i] || flagged[i] {
301                    continue;
302                }
303
304                // Trigger 2: any invisible character at line boundaries.
305                if i == 0 || i == chars.len() - 1 {
306                    flagged[i] = true;
307                    warnings.push(self.build_warning(
308                        ctx,
309                        line_num,
310                        i + 1,
311                        1,
312                        format!(
313                            "Invisible character {} detected at line boundary",
314                            unicode::format_codepoint(c)
315                        ),
316                        Some(String::new()),
317                    ));
318                    continue;
319                }
320
321                // Trigger 3: invisible char adjacent to any whitespace. `i` is guaranteed
322                // interior here (the boundary case above already handled 0 and len - 1),
323                // so both neighbors can be indexed directly.
324                if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
325                    flagged[i] = true;
326                    warnings.push(self.build_warning(
327                        ctx,
328                        line_num,
329                        i + 1,
330                        1,
331                        format!(
332                            "Invisible character {} detected adjacent to visible whitespace",
333                            unicode::format_codepoint(c)
334                        ),
335                        Some(String::new()),
336                    ));
337                }
338            }
339
340            // Trigger 4: code points Unicode itself steers authors away from, reported
341            // wherever no invisible-character trigger already spoke. Several of them are
342            // invisible too, and the invisible triggers carry a removal fix this one
343            // cannot offer, so running last keeps the more actionable diagnostic and
344            // reports each character once.
345            for (i, &c) in chars.iter().enumerate() {
346                if flagged[i] || self.is_allowed(c) {
347                    continue;
348                }
349                let Some((message, replacement)) = Self::markup_finding(c) else {
350                    continue;
351                };
352                flagged[i] = true;
353                warnings.push(self.build_warning(ctx, line_num, i + 1, 1, message, replacement));
354            }
355        }
356
357        Ok(warnings)
358    }
359
360    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
361        if self.should_skip(ctx) {
362            return Ok(ctx.content.to_string());
363        }
364
365        let warnings = self.check(ctx)?;
366        if warnings.is_empty() {
367            return Ok(ctx.content.to_string());
368        }
369
370        let warnings =
371            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
373            .map_err(crate::rule::LintError::InvalidInput)
374    }
375
376    fn as_any(&self) -> &dyn std::any::Any {
377        self
378    }
379
380    crate::impl_rule_config_methods!(MD084Config);
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::config::MarkdownFlavor;
387
388    fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
389        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
390        let config = MD084Config {
391            strict,
392            allow: allow.chars().collect(),
393        };
394        MD084InvisibleCharacters::from_config_struct(config)
395            .check(&ctx)
396            .unwrap()
397    }
398
399    fn check(content: &str) -> Vec<LintWarning> {
400        check_with_config(content, false, "")
401    }
402
403    fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
404        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
405        let config = MD084Config {
406            strict,
407            allow: allow.chars().collect(),
408        };
409        MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
410    }
411
412    fn fix(content: &str) -> String {
413        fix_with_config(content, false, "")
414    }
415
416    #[test]
417    fn test_default_no_findings_on_plain_text() {
418        let findings = check("plain text\nsecond line\n");
419        assert!(findings.is_empty());
420    }
421
422    #[test]
423    fn test_default_flags_multiple_consecutive_invisibles() {
424        let findings = check("a\u{200B}\u{200C}b");
425        assert_eq!(findings.len(), 1);
426        assert!(
427            findings[0]
428                .message
429                .contains("2 multiple consecutive invisible characters detected")
430        );
431        assert_eq!(findings[0].column, 2);
432        assert_eq!(findings[0].end_column, 4);
433        assert!(findings[0].fix.is_some());
434    }
435
436    #[test]
437    fn test_default_flags_invisible_chars_at_line_boundaries() {
438        let findings = check("\u{2060}start\nend\u{200B}");
439        assert_eq!(findings.len(), 2);
440        assert!(
441            findings[0]
442                .message
443                .contains("Invisible character U+2060 detected at line boundary")
444        );
445        assert!(
446            findings[1]
447                .message
448                .contains("Invisible character U+200B detected at line boundary")
449        );
450    }
451
452    #[test]
453    fn test_default_flags_invisible_adjacent_to_whitespace() {
454        let findings = check("a \u{2060}b");
455        assert_eq!(findings.len(), 1);
456        assert!(
457            findings[0]
458                .message
459                .contains("Invisible character U+2060 detected adjacent to visible whitespace")
460        );
461    }
462
463    #[test]
464    fn test_default_fix_removes_triggered_characters() {
465        assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft  right");
466    }
467
468    #[test]
469    fn test_strict_flags_any_invisible_character() {
470        let findings = check_with_config("ca\u{200C}t", true, "");
471        assert_eq!(findings.len(), 1);
472        assert!(findings[0].message.contains("strict mode"));
473        assert!(findings[0].fix.is_some());
474
475        assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
476    }
477
478    #[test]
479    fn test_allow_list_suppresses_findings() {
480        assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
481    }
482
483    #[test]
484    fn test_md084_default_triggers_are_targeted() {
485        let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
486        assert_eq!(findings.len(), 4);
487
488        // Default mode should provide auto-fixes.
489        assert!(findings.iter().all(|w| w.fix.is_some()));
490    }
491
492    #[test]
493    fn test_md084_strict_mode_flags_any_invisible() {
494        let findings = check_with_config("in\u{200C}word", true, "");
495        assert_eq!(findings.len(), 1);
496        assert!(findings[0].fix.is_some());
497    }
498
499    #[test]
500    fn test_md084_allow_list_by_codepoint() {
501        let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
502        assert!(findings.is_empty());
503    }
504
505    #[test]
506    fn test_tab_characters() {
507        let findings = check("text\n\tindented\n");
508        assert!(findings.is_empty());
509    }
510
511    #[test]
512    fn test_default_ignores_variation_selector_attached_to_base() {
513        // U+FE0F gives the preceding character emoji presentation. It legitimately
514        // sits at a line end or next to a space, which are two of the default triggers.
515        let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
516        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
517
518        let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
519        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
520    }
521
522    #[test]
523    fn test_default_fix_preserves_emoji_presentation() {
524        let content = "> \u{26A0}\u{FE0F} Note: important\n";
525        assert_eq!(fix(content), content);
526    }
527
528    #[test]
529    fn test_default_flags_orphaned_variation_selector() {
530        // No base character to modify: the selector is hidden content, not presentation.
531        let findings = check("\u{FE0F}starts with a selector");
532        assert_eq!(findings.len(), 1);
533        assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
534
535        let findings = check("a \u{FE0F}b");
536        assert_eq!(findings.len(), 1);
537        assert!(
538            findings[0]
539                .message
540                .contains("U+FE0F detected adjacent to visible whitespace")
541        );
542
543        // Preceded by another invisible character, so it still modifies nothing.
544        let findings = check("a\u{200B}\u{FE0F}b");
545        assert_eq!(findings.len(), 1);
546        assert!(
547            findings[0]
548                .message
549                .contains("2 multiple consecutive invisible characters")
550        );
551    }
552
553    #[test]
554    fn test_default_flags_redundant_variation_selector() {
555        // The first selector is attached to the base; the duplicate after it is not.
556        // Mid-line matters here: the duplicate is neither at a boundary nor next to
557        // whitespace, so it is only caught by counting the attached selector as part
558        // of the cluster.
559        for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
560            let findings = check(content);
561            assert_eq!(findings.len(), 1, "content {content:?}");
562            assert_eq!(findings[0].column, 3, "content {content:?}");
563            assert_eq!(findings[0].end_column, 4, "content {content:?}");
564            assert!(
565                findings[0]
566                    .message
567                    .contains("U+FE0F detected next to another invisible character"),
568                "content {content:?}: {}",
569                findings[0].message
570            );
571        }
572    }
573
574    #[test]
575    fn test_default_ignores_emoji_zwj_sequences() {
576        // Each of these is a single glyph held together by joiners, and some carry a
577        // variation selector next to the joiner. Removing either splits the emoji.
578        let sequences = [
579            "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",                           // rainbow flag
580            "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}",           // couple with heart
581            "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}",                     // woman bouncing ball
582            "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}",                            // pirate flag
583            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", // family
584        ];
585
586        for sequence in sequences {
587            let content = format!("look: {sequence} here");
588            let findings = check(&content);
589            assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
590
591            assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
592        }
593    }
594
595    #[test]
596    fn test_default_flags_orphaned_joiner() {
597        // A joiner only earns its exemption by fusing visible characters on both sides.
598        let findings = check("joins nothing\u{200D}");
599        assert_eq!(findings.len(), 1);
600        assert!(findings[0].message.contains("U+200D detected at line boundary"));
601
602        let findings = check("a \u{200D}b");
603        assert_eq!(findings.len(), 1);
604        assert!(
605            findings[0]
606                .message
607                .contains("U+200D detected adjacent to visible whitespace")
608        );
609
610        // Joiner followed by a zero-width space rather than a visible character.
611        let findings = check("a\u{200D}\u{200B}b");
612        assert_eq!(findings.len(), 1);
613        assert!(
614            findings[0]
615                .message
616                .contains("2 multiple consecutive invisible characters")
617        );
618    }
619
620    #[test]
621    fn test_default_flags_invisible_hiding_behind_an_emoji() {
622        // A zero-width space tucked between an emoji and the next word is surrounded
623        // by an attached selector on one side, so it only surfaces if the selector
624        // still counts toward the cluster.
625        let content = "\u{26A0}\u{FE0F}\u{200B}x";
626        let findings = check(content);
627        assert_eq!(findings.len(), 1);
628        assert_eq!(findings[0].column, 3);
629        assert!(
630            findings[0]
631                .message
632                .contains("U+200B detected next to another invisible character")
633        );
634
635        // The fix removes only the zero-width space, leaving the emoji intact.
636        assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
637    }
638
639    #[test]
640    fn test_strict_still_flags_attached_variation_selector() {
641        // Strict mode is deliberately literal: it reports every invisible codepoint,
642        // and users who want emoji left alone allow-list U+FE0F.
643        let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
644        assert_eq!(findings.len(), 1);
645        assert!(findings[0].message.contains("strict mode"));
646    }
647
648    #[test]
649    fn test_default_markup_unsuitable_characters_are_flagged() {
650        // Only a substitution that preserves the text comes with a fix: the tone marks
651        // have canonical equivalents, the object replacement character does not.
652        let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
653        assert_eq!(findings.len(), 3, "Got {findings:?}");
654        assert!(
655            findings[0]
656                .message
657                .contains("U+0340 is not suitable for use with markup")
658        );
659        assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
660        assert!(
661            findings[1]
662                .message
663                .contains("U+0341 is not suitable for use with markup")
664        );
665        assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
666        assert!(
667            findings[2]
668                .message
669                .contains("U+FFFC is not suitable for use with markup")
670        );
671        assert!(findings[2].fix.is_none());
672    }
673
674    #[test]
675    fn test_strict_markup_unsuitable_characters_are_flagged() {
676        let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
677        assert_eq!(findings.len(), 3, "Got {findings:?}");
678        assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
679        assert!(findings[1].message.contains("U+0341"));
680        assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
681        assert!(findings[2].message.contains("U+FFFC"));
682        assert!(findings[2].fix.is_none());
683    }
684
685    #[test]
686    fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
687        let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
688        assert!(findings.is_empty());
689    }
690
691    #[test]
692    fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
693        // U+0149 renders, so only the author knows what it should say instead.
694        let findings = check("Cote d\u{0149}Ivoire");
695        assert_eq!(findings.len(), 1, "Got {findings:?}");
696        assert!(
697            findings[0]
698                .message
699                .contains("Deprecated Unicode code point U+0149 detected")
700        );
701        assert!(findings[0].fix.is_none());
702        assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
703    }
704
705    #[test]
706    fn test_deprecated_and_invisible_keeps_the_removal_fix() {
707        // U+206A is both invisible and deprecated. The invisible triggers carry a
708        // removal fix, so they must win over the unfixable deprecated diagnostic.
709        for (content, expected_fix) in [
710            ("\u{206A}x", "x"),
711            ("x\u{206A}", "x"),
712            ("x \u{206A}y", "x y"),
713            ("x\u{206A}\u{206B}y", "xy"),
714        ] {
715            let findings = check(content);
716            assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
717            assert!(
718                findings[0].message.starts_with("Invisible character")
719                    || findings[0].message.contains("consecutive invisible characters"),
720                "{content:?} gave {:?}",
721                findings[0].message
722            );
723            assert_eq!(fix(content), expected_fix, "fixing {content:?}");
724        }
725    }
726
727    #[test]
728    fn test_deprecated_and_invisible_is_reported_once() {
729        // Interior, non-adjacent to whitespace: no invisible trigger applies, so the
730        // deprecated diagnostic is the only one, and it carries no fix.
731        let findings = check("x\u{206A}y");
732        assert_eq!(findings.len(), 1, "Got {findings:?}");
733        assert!(
734            findings[0]
735                .message
736                .contains("Deprecated Unicode code point U+206A detected")
737        );
738        assert!(findings[0].fix.is_none());
739        assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
740    }
741
742    #[test]
743    fn test_interlinear_annotation_is_reported_but_never_stripped() {
744        // U+FFF9..U+FFFB delimit ruby text. They are not default-ignorable, and deleting
745        // one would leave the annotation half-open, so they are reported without a fix.
746        let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
747        let findings = check(content);
748        assert_eq!(findings.len(), 3, "Got {findings:?}");
749        for finding in &findings {
750            assert!(finding.message.contains("is not suitable for use with markup"));
751            assert!(finding.fix.is_none());
752        }
753        assert_eq!(fix(content), content);
754    }
755
756    #[test]
757    fn test_annotation_delimiter_is_not_a_presentation_base() {
758        // An annotation delimiter draws no glyph, so a selector or joiner beside one
759        // has nothing to modify and stays reportable, mid-line as much as at a line
760        // boundary: the pair is a cluster of characters that draw nothing. The fix
761        // removes only the orphan; the delimiter itself is never stripped.
762        for (content, expected_fix) in [
763            ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
764            ("\u{FFF9}\u{200D}", "\u{FFF9}"),
765            ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
766            ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
767            ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
768        ] {
769            let findings = check(content);
770            assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
771            assert!(
772                findings.iter().any(|f| f.message.contains("Invisible character")
773                    || f.message.contains("consecutive invisible characters")),
774                "{content:?} gave {findings:?}"
775            );
776            assert_eq!(fix(content), expected_fix, "fixing {content:?}");
777        }
778    }
779
780    #[test]
781    fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
782        // U+FFF0..U+FFF8 are default-ignorable, so they keep the removal fix.
783        let findings = check("\u{FFF8}x");
784        assert_eq!(findings.len(), 1, "Got {findings:?}");
785        assert!(
786            findings[0]
787                .message
788                .contains("Invisible character U+FFF8 detected at line boundary")
789        );
790        assert_eq!(fix("\u{FFF8}x"), "x");
791    }
792}