Skip to main content

rumdl_lib/rules/
md084_invisible_characters.rs

1//! Rule MD084: Invisible 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.
5//!
6//! By default, it tries to avoid false positives by only flagging:
7//! 1. Multiple consecutive invisible characters,
8//! 2. Invisible characters at the start or end of a line,
9//! 3. Invisible characters adjacent to any visible whitespace.
10//!
11//! In strict mode, it flags any invisible character that is not explicitly allowed in the configuration.
12
13mod md084_config;
14
15use crate::lint_context::LintContext;
16use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
17use md084_config::MD084Config;
18use std::collections::HashSet;
19
20#[derive(Debug, Clone)]
21pub struct MD084InvisibleCharacters {
22    config: MD084Config,
23    allowed_codepoints: HashSet<u32>,
24}
25
26impl Default for MD084InvisibleCharacters {
27    fn default() -> Self {
28        Self::from_config_struct(MD084Config::default())
29    }
30}
31
32impl MD084InvisibleCharacters {
33    fn from_config_struct(config: MD084Config) -> Self {
34        let allowed_codepoints = config
35            .allow
36            .iter()
37            .filter_map(|token| parse_codepoint_token(token))
38            .collect();
39
40        Self {
41            config,
42            allowed_codepoints,
43        }
44    }
45
46    #[inline]
47    fn is_allowed(&self, c: char) -> bool {
48        self.allowed_codepoints.contains(&(c as u32))
49    }
50
51    fn format_codepoint(c: char) -> String {
52        let cp = c as u32;
53        if cp <= 0xFFFF {
54            format!("U+{cp:04X}")
55        } else {
56            format!("U+{cp:06X}")
57        }
58    }
59
60    fn is_invisible_char(c: char) -> bool {
61        let cp = c as u32;
62        matches!(
63            cp,
64            0x0000..=0x0008
65                | 0x000A..=0x001F // C0 Control characters, excluding TAB (0x0009)
66                | 0x007F..=0x009F // DEL + C1 control characters
67                | 0x00AD // SOFT HYPHEN
68                | 0x034F // COMBINING GRAPHEME JOINER
69                | 0x061C // ARABIC LETTER MARK
70                | 0x115F // HANGUL CHOSEONG FILLER
71                | 0x1160 // HANGUL JUNGSEONG FILLER
72                | 0x17B4 // KHMER VOWEL INHERENT AQ
73                | 0x17B5 // KHMER VOWEL INHERENT AA
74                | 0x180B..=0x180E // Mongolian variation selectors + MONGOLIAN VOWEL SEPARATOR
75                | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
76                | 0x202A..=0x202E // Bidi embedding/override controls
77                | 0x2060..=0x206F // WORD JOINER, invisibles, and bidi isolate controls
78                | 0x3164 // HANGUL FILLER
79                | 0xFE00..=0xFE0F // Variation Selectors (VS1..VS16)
80                | 0xFEFF // ZERO WIDTH NO-BREAK SPACE (BOM)
81                | 0xFFA0 // HALFWIDTH HANGUL FILLER
82                | 0xFFF0..=0xFFF8 // Interlinear annotation and reserved non-rendering specials
83                | 0x1BCA0..=0x1BCA3 // Shorthand format controls
84                | 0x1D173..=0x1D17A // Musical symbol format controls
85                | 0xE0000..=0xE0FFF // Tags block + Variation Selectors Supplement
86        )
87    }
88
89    /// Whether `c` is a codepoint this rule cares about: invisible and not allow-listed.
90    #[inline]
91    fn is_flaggable(&self, c: char) -> bool {
92        Self::is_invisible_char(c) && !self.is_allowed(c)
93    }
94
95    /// Variation selectors modify the *preceding* base character: `U+26A0 U+FE0F`
96    /// is the emoji-presentation warning sign `⚠️`, where `U+26A0` alone is the
97    /// text-presentation `⚠`.
98    fn is_variation_selector(c: char) -> bool {
99        matches!(
100            c as u32,
101            0x180B..=0x180D // Mongolian free variation selectors FVS1..FVS3
102                | 0xFE00..=0xFE0F // Variation Selectors VS1..VS16
103                | 0xE0100..=0xE01EF // Variation Selectors Supplement VS17..VS256
104        )
105    }
106
107    /// ZERO WIDTH JOINER, which fuses adjacent characters into one glyph.
108    const ZWJ: char = '\u{200D}';
109
110    /// Whether the character at `index` is visible content: present, not whitespace,
111    /// and not one of the invisible code points this rule tracks.
112    fn is_visible_base(chars: &[char], index: usize) -> bool {
113        chars
114            .get(index)
115            .is_some_and(|&c| !c.is_whitespace() && !Self::is_invisible_char(c))
116    }
117
118    /// Whether the character before `index` resolves to visible content, looking past
119    /// a variation selector that is itself attached to a base. That is what lets the
120    /// joiner in `U+1F3F3 U+FE0F U+200D U+1F308` (the rainbow flag) see its base.
121    fn follows_visible_base(chars: &[char], index: usize) -> bool {
122        let Some(prev) = index.checked_sub(1) else {
123            return false;
124        };
125
126        Self::is_visible_base(chars, prev)
127            || (Self::is_variation_selector(chars[prev])
128                && prev
129                    .checked_sub(1)
130                    .is_some_and(|base| Self::is_visible_base(chars, base)))
131    }
132
133    /// Whether the character at `index` is presentation rather than hidden content.
134    /// Both forms below are part of the grapheme cluster a reader sees, so removing
135    /// one changes the rendered text: a variation selector picks the glyph form of the
136    /// character before it, and a joiner fuses the characters on either side of it.
137    /// Orphaned - at the start or end of a line, next to whitespace, or with another
138    /// invisible character where its base should be - neither is doing that job, and
139    /// stays reportable.
140    fn is_presentation(chars: &[char], index: usize) -> bool {
141        let c = chars[index];
142
143        if Self::is_variation_selector(c) {
144            // A selector modifies exactly the character before it, so a duplicated
145            // selector has nothing left of its own to modify.
146            return index
147                .checked_sub(1)
148                .is_some_and(|prev| Self::is_visible_base(chars, prev));
149        }
150
151        c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
152    }
153
154    /// Message for a reportable stretch inside a run of consecutive invisible
155    /// characters. A stretch shortens to one character when presentation sits next
156    /// to it, which is still a cluster worth reporting.
157    fn cluster_message(len: usize, first: char) -> String {
158        let codepoint = Self::format_codepoint(first);
159        if len >= 2 {
160            format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
161        } else {
162            format!("Invisible character {codepoint} detected next to another invisible character")
163        }
164    }
165
166    /// Build a single-character-run warning, optionally with a removal fix.
167    fn build_warning(
168        rule_name: &str,
169        ctx: &LintContext,
170        line: usize,
171        start_col: usize,
172        len_chars: usize,
173        message: String,
174        fixable: bool,
175    ) -> LintWarning {
176        let fix = fixable.then(|| {
177            Fix::new(
178                ctx.line_index
179                    .line_col_to_byte_range_with_length(line, start_col, len_chars),
180                String::new(),
181            )
182        });
183
184        LintWarning {
185            rule_name: Some(rule_name.to_string()),
186            line,
187            column: start_col,
188            end_line: line,
189            end_column: start_col + len_chars,
190            severity: Severity::Warning,
191            message,
192            fix,
193        }
194    }
195}
196
197impl Rule for MD084InvisibleCharacters {
198    fn name(&self) -> &'static str {
199        "MD084"
200    }
201
202    fn description(&self) -> &'static str {
203        "Invisible Unicode characters should be intentional"
204    }
205
206    fn category(&self) -> RuleCategory {
207        RuleCategory::Whitespace
208    }
209
210    fn fix_capability(&self) -> FixCapability {
211        FixCapability::ConditionallyFixable
212    }
213
214    fn should_skip(&self, ctx: &LintContext) -> bool {
215        ctx.content.is_empty()
216            || !ctx
217                .content
218                .chars()
219                .any(|c| Self::is_invisible_char(c) && !self.is_allowed(c))
220    }
221
222    fn check(&self, ctx: &LintContext) -> LintResult {
223        let mut warnings = Vec::new();
224
225        for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
226            let line_num = line_idx + 1;
227            let chars: Vec<char> = line.chars().collect();
228
229            if chars.is_empty() {
230                continue;
231            }
232
233            // Quick return for strict mode: flag any invisible character that is not allow-listed.
234            if self.config.strict {
235                warnings.extend(
236                    chars
237                        .iter()
238                        .enumerate()
239                        .filter(|&(_, &c)| self.is_flaggable(c))
240                        .map(|(i, &c)| {
241                            Self::build_warning(
242                                self.name(),
243                                ctx,
244                                line_num,
245                                i + 1,
246                                1,
247                                format!(
248                                    "Invisible character {} detected (strict mode)",
249                                    Self::format_codepoint(c)
250                                ),
251                                true,
252                            )
253                        }),
254                );
255                continue;
256            }
257
258            // In non-strict mode, we only flag the three triggers defined in the rule
259            // description. Presentation characters are never reported or removed, but
260            // they stay invisible characters for the purpose of detecting a cluster,
261            // so nothing can hide behind an emoji.
262            let mut flagged = vec![false; chars.len()];
263            let flaggable: Vec<bool> = chars.iter().map(|&c| self.is_flaggable(c)).collect();
264            let exempt: Vec<bool> = (0..chars.len()).map(|i| Self::is_presentation(&chars, i)).collect();
265            let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
266
267            // Trigger 1: runs of two or more consecutive invisible characters. The run
268            // is measured over every invisible character, then reported one reportable
269            // stretch at a time so presentation inside it is left intact.
270            let mut offset = 0;
271            for group in flaggable.chunk_by(|a, b| a == b) {
272                let len = group.len();
273                if group[0] && len >= 2 {
274                    let mut start = offset;
275                    for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
276                        let stretch_len = stretch.len();
277                        if !stretch[0] {
278                            flagged[start..start + stretch_len].fill(true);
279                            warnings.push(Self::build_warning(
280                                self.name(),
281                                ctx,
282                                line_num,
283                                start + 1,
284                                stretch_len,
285                                Self::cluster_message(stretch_len, chars[start]),
286                                true,
287                            ));
288                        }
289                        start += stretch_len;
290                    }
291                }
292                offset += len;
293            }
294
295            // Triggers 2 and 3 need to inspect each remaining candidate's neighbors.
296            for (i, &c) in chars.iter().enumerate() {
297                if !is_target[i] || flagged[i] {
298                    continue;
299                }
300
301                // Trigger 2: any invisible character at line boundaries.
302                if i == 0 || i == chars.len() - 1 {
303                    flagged[i] = true;
304                    warnings.push(Self::build_warning(
305                        self.name(),
306                        ctx,
307                        line_num,
308                        i + 1,
309                        1,
310                        format!(
311                            "Invisible character {} detected at line boundary",
312                            Self::format_codepoint(c)
313                        ),
314                        true,
315                    ));
316                    continue;
317                }
318
319                // Trigger 3: invisible char adjacent to any whitespace. `i` is guaranteed
320                // interior here (the boundary case above already handled 0 and len - 1),
321                // so both neighbors can be indexed directly.
322                if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
323                    flagged[i] = true;
324                    warnings.push(Self::build_warning(
325                        self.name(),
326                        ctx,
327                        line_num,
328                        i + 1,
329                        1,
330                        format!(
331                            "Invisible character {} detected adjacent to visible whitespace",
332                            Self::format_codepoint(c)
333                        ),
334                        true,
335                    ));
336                }
337            }
338        }
339
340        Ok(warnings)
341    }
342
343    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
344        if self.should_skip(ctx) {
345            return Ok(ctx.content.to_string());
346        }
347
348        let warnings = self.check(ctx)?;
349        if warnings.is_empty() {
350            return Ok(ctx.content.to_string());
351        }
352
353        let warnings =
354            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
355        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
356            .map_err(crate::rule::LintError::InvalidInput)
357    }
358
359    fn as_any(&self) -> &dyn std::any::Any {
360        self
361    }
362
363    crate::impl_rule_config_methods!(MD084Config);
364}
365
366fn parse_codepoint_token(token: &str) -> Option<u32> {
367    let trimmed = token.trim();
368    let hex = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+"))?;
369    if !(4..=6).contains(&hex.len()) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
370        return None;
371    }
372
373    let value = u32::from_str_radix(hex, 16).ok()?;
374    if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
375        return None;
376    }
377    Some(value)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::config::{Config, MarkdownFlavor};
384
385    fn check(content: &str) -> Vec<LintWarning> {
386        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
387        MD084InvisibleCharacters::default().check(&ctx).unwrap()
388    }
389
390    #[test]
391    fn test_default_no_findings_on_plain_text() {
392        let findings = check("plain text\nsecond line\n");
393        assert!(findings.is_empty());
394    }
395
396    #[test]
397    fn test_default_flags_multiple_consecutive_invisibles() {
398        let findings = check("a\u{200B}\u{200C}b");
399        assert_eq!(findings.len(), 1);
400        assert!(
401            findings[0]
402                .message
403                .contains("2 multiple consecutive invisible characters detected")
404        );
405        assert_eq!(findings[0].column, 2);
406        assert_eq!(findings[0].end_column, 4);
407        assert!(findings[0].fix.is_some());
408    }
409
410    #[test]
411    fn test_default_flags_invisible_chars_at_line_boundaries() {
412        let findings = check("\u{2060}start\nend\u{200B}");
413        assert_eq!(findings.len(), 2);
414        assert!(
415            findings[0]
416                .message
417                .contains("Invisible character U+2060 detected at line boundary")
418        );
419        assert!(
420            findings[1]
421                .message
422                .contains("Invisible character U+200B detected at line boundary")
423        );
424    }
425
426    #[test]
427    fn test_default_flags_invisible_adjacent_to_whitespace() {
428        let findings = check("a \u{2060}b");
429        assert_eq!(findings.len(), 1);
430        assert!(
431            findings[0]
432                .message
433                .contains("Invisible character U+2060 detected adjacent to visible whitespace")
434        );
435    }
436
437    #[test]
438    fn test_default_fix_removes_triggered_characters() {
439        let content = "x\u{200B}\u{200C}y\nleft \u{2060} right";
440        let rule = MD084InvisibleCharacters::default();
441        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
442
443        let fixed = rule.fix(&ctx).unwrap();
444        assert_eq!(fixed, "xy\nleft  right");
445    }
446
447    #[test]
448    fn test_strict_flags_any_invisible_character() {
449        let config: Config = toml::from_str(
450            r#"
451            [MD084]
452            strict = true
453            "#,
454        )
455        .unwrap();
456
457        let rule = MD084InvisibleCharacters::from_config(&config);
458        let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
459
460        let ctx = LintContext::new("ca\u{200C}t", MarkdownFlavor::Standard, None);
461        let findings = rule.check(&ctx).unwrap();
462        assert_eq!(findings.len(), 1);
463        assert!(findings[0].message.contains("strict mode"));
464        assert!(findings[0].fix.is_some());
465
466        assert_eq!(rule.fix(&ctx).unwrap(), "cat");
467    }
468
469    #[test]
470    fn test_allow_list_suppresses_findings() {
471        let config: Config = toml::from_str(
472            r#"
473            [MD084]
474            allow = ["U+200B"]
475            "#,
476        )
477        .unwrap();
478
479        let rule = MD084InvisibleCharacters::from_config(&config);
480        let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
481
482        let ctx = LintContext::new("\u{200B}ok\u{200B}", MarkdownFlavor::Standard, None);
483        let findings = rule.check(&ctx).unwrap();
484        assert!(findings.is_empty());
485    }
486
487    #[test]
488    fn test_md084_default_triggers_are_targeted() {
489        let rule = MD084InvisibleCharacters::default();
490        let content = "a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}";
491        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
492
493        let findings = rule.check(&ctx).unwrap();
494        assert_eq!(findings.len(), 4);
495
496        // Default mode should provide auto-fixes.
497        assert!(findings.iter().all(|w| w.fix.is_some()));
498    }
499
500    #[test]
501    fn test_md084_strict_mode_flags_any_invisible() {
502        let config: Config = toml::from_str(
503            r#"
504        [MD084]
505        strict = true
506        "#,
507        )
508        .unwrap();
509        let rule = MD084InvisibleCharacters::from_config(&config);
510        let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
511
512        let ctx = LintContext::new("in\u{200C}word", MarkdownFlavor::Standard, None);
513        let findings = rule.check(&ctx).unwrap();
514
515        assert_eq!(findings.len(), 1);
516        assert!(findings[0].fix.is_some());
517    }
518
519    #[test]
520    fn test_md084_allow_list_by_codepoint() {
521        let config: Config = toml::from_str(
522            r#"
523        [MD084]
524        allow = ["U+200B"]
525        "#,
526        )
527        .unwrap();
528        let rule = MD084InvisibleCharacters::from_config(&config);
529        let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
530
531        let ctx = LintContext::new("\u{200B}safe\u{200B}", MarkdownFlavor::Standard, None);
532        let findings = rule.check(&ctx).unwrap();
533        assert!(findings.is_empty());
534    }
535
536    #[test]
537    fn test_tab_characters() {
538        let rule = MD084InvisibleCharacters::default();
539        let content = "text\n\tindented\n";
540        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
541        let findings = rule.check(&ctx).unwrap();
542        assert!(findings.is_empty());
543    }
544
545    #[test]
546    fn test_default_ignores_variation_selector_attached_to_base() {
547        // U+FE0F gives the preceding character emoji presentation. It legitimately
548        // sits at a line end or next to a space, which are two of the default triggers.
549        let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
550        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
551
552        let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
553        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
554    }
555
556    #[test]
557    fn test_default_fix_preserves_emoji_presentation() {
558        let content = "> \u{26A0}\u{FE0F} Note: important\n";
559        let rule = MD084InvisibleCharacters::default();
560        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
561
562        assert_eq!(rule.fix(&ctx).unwrap(), content);
563    }
564
565    #[test]
566    fn test_default_flags_orphaned_variation_selector() {
567        // No base character to modify: the selector is hidden content, not presentation.
568        let findings = check("\u{FE0F}starts with a selector");
569        assert_eq!(findings.len(), 1);
570        assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
571
572        let findings = check("a \u{FE0F}b");
573        assert_eq!(findings.len(), 1);
574        assert!(
575            findings[0]
576                .message
577                .contains("U+FE0F detected adjacent to visible whitespace")
578        );
579
580        // Preceded by another invisible character, so it still modifies nothing.
581        let findings = check("a\u{200B}\u{FE0F}b");
582        assert_eq!(findings.len(), 1);
583        assert!(
584            findings[0]
585                .message
586                .contains("2 multiple consecutive invisible characters")
587        );
588    }
589
590    #[test]
591    fn test_default_flags_redundant_variation_selector() {
592        // The first selector is attached to the base; the duplicate after it is not.
593        // Mid-line matters here: the duplicate is neither at a boundary nor next to
594        // whitespace, so it is only caught by counting the attached selector as part
595        // of the cluster.
596        for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
597            let findings = check(content);
598            assert_eq!(findings.len(), 1, "content {content:?}");
599            assert_eq!(findings[0].column, 3, "content {content:?}");
600            assert_eq!(findings[0].end_column, 4, "content {content:?}");
601            assert!(
602                findings[0]
603                    .message
604                    .contains("U+FE0F detected next to another invisible character"),
605                "content {content:?}: {}",
606                findings[0].message
607            );
608        }
609    }
610
611    #[test]
612    fn test_default_ignores_emoji_zwj_sequences() {
613        // Each of these is a single glyph held together by joiners, and some carry a
614        // variation selector next to the joiner. Removing either splits the emoji.
615        let sequences = [
616            "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",                           // rainbow flag
617            "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}",           // couple with heart
618            "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}",                     // woman bouncing ball
619            "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}",                            // pirate flag
620            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", // family
621        ];
622
623        for sequence in sequences {
624            let content = format!("look: {sequence} here");
625            let findings = check(&content);
626            assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
627
628            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
629            assert_eq!(
630                MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
631                content,
632                "sequence {sequence:?} was rewritten"
633            );
634        }
635    }
636
637    #[test]
638    fn test_default_flags_orphaned_joiner() {
639        // A joiner only earns its exemption by fusing visible characters on both sides.
640        let findings = check("joins nothing\u{200D}");
641        assert_eq!(findings.len(), 1);
642        assert!(findings[0].message.contains("U+200D detected at line boundary"));
643
644        let findings = check("a \u{200D}b");
645        assert_eq!(findings.len(), 1);
646        assert!(
647            findings[0]
648                .message
649                .contains("U+200D detected adjacent to visible whitespace")
650        );
651
652        // Joiner followed by a zero-width space rather than a visible character.
653        let findings = check("a\u{200D}\u{200B}b");
654        assert_eq!(findings.len(), 1);
655        assert!(
656            findings[0]
657                .message
658                .contains("2 multiple consecutive invisible characters")
659        );
660    }
661
662    #[test]
663    fn test_default_flags_invisible_hiding_behind_an_emoji() {
664        // A zero-width space tucked between an emoji and the next word is surrounded
665        // by an attached selector on one side, so it only surfaces if the selector
666        // still counts toward the cluster.
667        let content = "\u{26A0}\u{FE0F}\u{200B}x";
668        let findings = check(content);
669        assert_eq!(findings.len(), 1);
670        assert_eq!(findings[0].column, 3);
671        assert!(
672            findings[0]
673                .message
674                .contains("U+200B detected next to another invisible character")
675        );
676
677        // The fix removes only the zero-width space, leaving the emoji intact.
678        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
679        assert_eq!(
680            MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
681            "\u{26A0}\u{FE0F}x"
682        );
683    }
684
685    #[test]
686    fn test_strict_still_flags_attached_variation_selector() {
687        // Strict mode is deliberately literal: it reports every invisible codepoint,
688        // and users who want emoji left alone allow-list U+FE0F.
689        let config: Config = toml::from_str(
690            r#"
691            [MD084]
692            strict = true
693            "#,
694        )
695        .unwrap();
696
697        let rule = MD084InvisibleCharacters::from_config(&config);
698        let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
699
700        let ctx = LintContext::new("\u{26A0}\u{FE0F} Note", MarkdownFlavor::Standard, None);
701        let findings = rule.check(&ctx).unwrap();
702        assert_eq!(findings.len(), 1);
703        assert!(findings[0].message.contains("strict mode"));
704    }
705}