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