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