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 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 // C0 Control characters, excluding TAB (0x0009)
70                | 0x007F..=0x009F // DEL + C1 control characters
71                | 0x00AD // SOFT HYPHEN
72                | 0x034F // COMBINING GRAPHEME JOINER
73                | 0x061C // ARABIC LETTER MARK
74                | 0x115F // HANGUL CHOSEONG FILLER
75                | 0x1160 // HANGUL JUNGSEONG FILLER
76                | 0x17B4 // KHMER VOWEL INHERENT AQ
77                | 0x17B5 // KHMER VOWEL INHERENT AA
78                | 0x180B..=0x180E // Mongolian variation selectors + MONGOLIAN VOWEL SEPARATOR
79                | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
80                | 0x202A..=0x202E // Bidi embedding/override controls
81                | 0x2060..=0x206F // WORD JOINER, invisibles, and bidi isolate controls
82                | 0x3164 // HANGUL FILLER
83                | 0xFE00..=0xFE0F // Variation Selectors (VS1..VS16)
84                | 0xFEFF // ZERO WIDTH NO-BREAK SPACE (BOM)
85                | 0xFFA0 // HALFWIDTH HANGUL FILLER
86                | 0xFFF0..=0xFFF8 // Reserved non-rendering specials
87                | 0x1BCA0..=0x1BCA3 // Shorthand format controls
88                | 0x1D173..=0x1D17A // Musical symbol format controls
89                | 0xE0000..=0xE0FFF // Tags block + Variation Selectors Supplement
90        )
91    }
92
93    /// The code points carrying the UCD `Deprecated` property, in full. Unicode
94    /// discourages their use but they still render, so they are reported without a
95    /// removal fix: only the author knows what the text should say instead.
96    #[inline]
97    fn is_deprecated_char(c: char) -> bool {
98        let cp = c as u32;
99        matches!(
100            cp,
101            0x0149 // LATIN SMALL LETTER N PRECEDED BY APOSTROPHE
102                | 0x0673 // ARABIC LETTER ALEF WITH WAVY HAMZA ABOVE
103                | 0x0F77 // TIBETAN VOWEL SIGN VOCALIC LL
104                | 0x0F79 // TIBETAN VOWEL SIGN VOCALIC LR
105                | 0x17A3..=0x17A4 // KHMER INHERENT VOWEL SIGN AA..KHMER INHERENT VOWEL SIGN AE
106                | 0x206A..=0x206F // INHIBIT SYMMETRIC SWAPPING..NOMINAL DIGIT SHAPES
107                | 0x2329 // LEFT-POINTING ANGLE BRACKET
108                | 0x232A // RIGHT-POINTING ANGLE BRACKET
109                | 0xE0001 // LANGUAGE TAG
110        )
111    }
112
113    /// The rows of UTR#20 table 3.1 that neither of the sets above already covers:
114    /// visible or structural code points a markup document is meant to express with
115    /// markup instead. They are not default-ignorable, so removing one would drop
116    /// content or leave a paired construct half-open, and only the two tone marks
117    /// have a replacement that preserves the text exactly.
118    #[inline]
119    fn is_unsuitable_for_markup_char(c: char) -> bool {
120        let cp = c as u32;
121        matches!(
122            cp,
123            0x0340 // COMBINING GRAVE TONE MARK
124                | 0x0341 // COMBINING ACUTE TONE MARK
125                | 0xFFF9..=0xFFFC // Interlinear annotation delimiters + OBJECT REPLACEMENT CHARACTER
126        )
127    }
128
129    /// Whether either set above claims this code point.
130    #[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    /// The interlinear annotation delimiters, which draw no glyph of their own but
136    /// bracket the text between them, so they are kept out of the deletable invisible
137    /// set: removing one would leave the annotation half-open.
138    #[inline]
139    fn is_annotation_delimiter(c: char) -> bool {
140        matches!(c as u32, 0xFFF9..=0xFFFB)
141    }
142
143    /// Whether this code point puts no glyph on the page, whether or not the rule is
144    /// willing to delete it. This is what a variation selector or joiner needs beside
145    /// it to be doing its job, and what makes a stretch of characters a cluster.
146    #[inline]
147    fn draws_no_glyph(c: char) -> bool {
148        Self::is_invisible_char(c) || Self::is_annotation_delimiter(c)
149    }
150
151    /// A code point flagged by one of the two sets above, with the message that is
152    /// true of it and the replacement that preserves the text, if one exists.
153    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        // The tone marks are canonical singletons: every normalization form already
162        // rewrites them this way, so the substitution cannot change what renders.
163        let replacement = match c as u32 {
164            0x0340 => Some("\u{0300}".to_string()), // COMBINING GRAVE ACCENT
165            0x0341 => Some("\u{0301}".to_string()), // COMBINING ACUTE ACCENT
166            _ => None,
167        };
168        Some((
169            format!("Unicode code point {codepoint} is not suitable for use with markup"),
170            replacement,
171        ))
172    }
173
174    /// Variation selectors modify the *preceding* base character: `U+26A0 U+FE0F`
175    /// is the emoji-presentation warning sign `⚠️`, where `U+26A0` alone is the
176    /// text-presentation `⚠`.
177    fn is_variation_selector(c: char) -> bool {
178        matches!(
179            c as u32,
180            0x180B..=0x180D // Mongolian free variation selectors FVS1..FVS3
181                | 0xFE00..=0xFE0F // Variation Selectors VS1..VS16
182                | 0xE0100..=0xE01EF // Variation Selectors Supplement VS17..VS256
183        )
184    }
185
186    /// ZERO WIDTH JOINER, which fuses adjacent characters into one glyph.
187    const ZWJ: char = '\u{200D}';
188
189    /// Whether the character at `index` renders a glyph a variation selector can pick
190    /// a form of, or a joiner can fuse: present, not whitespace, and not one of the
191    /// code points this rule knows draws nothing.
192    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    /// Whether the character before `index` resolves to visible content, looking past
199    /// a variation selector that is itself attached to a base. That is what lets the
200    /// joiner in `U+1F3F3 U+FE0F U+200D U+1F308` (the rainbow flag) see its base.
201    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    /// Whether the character at `index` is presentation rather than hidden content.
214    /// Both forms below are part of the grapheme cluster a reader sees, so removing
215    /// one changes the rendered text: a variation selector picks the glyph form of the
216    /// character before it, and a joiner fuses the characters on either side of it.
217    /// Orphaned - at the start or end of a line, next to whitespace, or with a
218    /// character that draws no glyph where its base should be - neither is doing that
219    /// job, and stays reportable.
220    fn is_presentation(chars: &[char], index: usize) -> bool {
221        let c = chars[index];
222
223        if Self::is_variation_selector(c) {
224            // A selector modifies exactly the character before it, so a duplicated
225            // selector has nothing left of its own to modify.
226            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    /// Message for a reportable stretch inside a run of consecutive invisible
235    /// characters. A stretch shortens to one character when presentation sits next
236    /// to it, which is still a cluster worth reporting.
237    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    /// Build a warning covering `len_chars` characters, with a fix rewriting them to
247    /// `replacement` when one is given. An empty replacement deletes the run.
248    #[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            // Quick return for strict mode: flag any invisible character that is not allow-listed.
316            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            // In non-strict mode, we only flag the three triggers defined in the rule
342            // description. Presentation characters and annotation delimiters are never
343            // reported or removed by those triggers, but they still draw no glyph, so
344            // they count toward a cluster and nothing can hide behind one.
345            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            // Trigger 1: runs of two or more consecutive characters that draw nothing.
356            // The run is measured over all of them, then reported one reportable stretch
357            // at a time so exempt characters inside it are left intact.
358            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            // Triggers 2 and 3 need to inspect each remaining candidate's neighbors.
383            for (i, &c) in chars.iter().enumerate() {
384                if !is_target[i] || flagged[i] {
385                    continue;
386                }
387
388                // Trigger 2: any invisible character at line boundaries.
389                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                // Trigger 3: invisible char adjacent to any whitespace. `i` is guaranteed
406                // interior here (the boundary case above already handled 0 and len - 1),
407                // so both neighbors can be indexed directly.
408                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            // Trigger 4: code points Unicode itself steers authors away from, reported
425            // wherever no invisible-character trigger already spoke. Several of them are
426            // invisible too, and the invisible triggers carry a removal fix this one
427            // cannot offer, so running last keeps the more actionable diagnostic and
428            // reports each character once.
429            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        // Default mode should provide auto-fixes.
587        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        // U+FE0F gives the preceding character emoji presentation. It legitimately
612        // sits at a line end or next to a space, which are two of the default triggers.
613        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        // No base character to modify: the selector is hidden content, not presentation.
629        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        // Preceded by another invisible character, so it still modifies nothing.
642        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        // The first selector is attached to the base; the duplicate after it is not.
654        // Mid-line matters here: the duplicate is neither at a boundary nor next to
655        // whitespace, so it is only caught by counting the attached selector as part
656        // of the cluster.
657        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        // Each of these is a single glyph held together by joiners, and some carry a
675        // variation selector next to the joiner. Removing either splits the emoji.
676        let sequences = [
677            "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",                           // rainbow flag
678            "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}",           // couple with heart
679            "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}",                     // woman bouncing ball
680            "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}",                            // pirate flag
681            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", // family
682        ];
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        // A joiner only earns its exemption by fusing visible characters on both sides.
696        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        // Joiner followed by a zero-width space rather than a visible character.
709        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        // A zero-width space tucked between an emoji and the next word is surrounded
721        // by an attached selector on one side, so it only surfaces if the selector
722        // still counts toward the cluster.
723        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        // The fix removes only the zero-width space, leaving the emoji intact.
734        assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
735    }
736
737    #[test]
738    fn test_strict_still_flags_attached_variation_selector() {
739        // Strict mode is deliberately literal: it reports every invisible codepoint,
740        // and users who want emoji left alone allow-list U+FE0F.
741        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        // Only a substitution that preserves the text comes with a fix: the tone marks
749        // have canonical equivalents, the object replacement character does not.
750        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        // U+0149 renders, so only the author knows what it should say instead.
793        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        // U+206A is both invisible and deprecated. The invisible triggers carry a
807        // removal fix, so they must win over the unfixable deprecated diagnostic.
808        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        // Interior, non-adjacent to whitespace: no invisible trigger applies, so the
829        // deprecated diagnostic is the only one, and it carries no fix.
830        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        // U+FFF9..U+FFFB delimit ruby text. They are not default-ignorable, and deleting
844        // one would leave the annotation half-open, so they are reported without a fix.
845        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        // An annotation delimiter draws no glyph, so a selector or joiner beside one
858        // has nothing to modify and stays reportable, mid-line as much as at a line
859        // boundary: the pair is a cluster of characters that draw nothing. The fix
860        // removes only the orphan; the delimiter itself is never stripped.
861        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        // U+FFF0..U+FFF8 are default-ignorable, so they keep the removal fix.
882        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}