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 fixes_from_diagnostics(&self) -> bool {
384        true
385    }
386
387    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
388        if self.should_skip(ctx) {
389            return Ok(ctx.content.to_string());
390        }
391
392        let warnings = self.check(ctx)?;
393        if warnings.is_empty() {
394            return Ok(ctx.content.to_string());
395        }
396
397        let warnings =
398            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
399        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
400            .map_err(crate::rule::LintError::InvalidInput)
401    }
402
403    fn as_any(&self) -> &dyn std::any::Any {
404        self
405    }
406
407    crate::impl_rule_config_methods!(MD084Config);
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::config::MarkdownFlavor;
414
415    fn check_with_config(content: &str, strict: bool, allow: &str) -> Vec<LintWarning> {
416        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
417        let config = MD084Config {
418            strict,
419            allow: allow.chars().collect(),
420        };
421        MD084InvisibleCharacters::from_config_struct(config)
422            .check(&ctx)
423            .unwrap()
424    }
425
426    fn check(content: &str) -> Vec<LintWarning> {
427        check_with_config(content, false, "")
428    }
429
430    fn fix_with_config(content: &str, strict: bool, allow: &str) -> String {
431        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
432        let config = MD084Config {
433            strict,
434            allow: allow.chars().collect(),
435        };
436        MD084InvisibleCharacters::from_config_struct(config).fix(&ctx).unwrap()
437    }
438
439    fn fix(content: &str) -> String {
440        fix_with_config(content, false, "")
441    }
442
443    #[test]
444    fn test_default_no_findings_on_plain_text() {
445        let findings = check("plain text\nsecond line\n");
446        assert!(findings.is_empty());
447    }
448
449    #[test]
450    fn test_default_flags_multiple_consecutive_invisibles() {
451        let findings = check("a\u{200B}\u{200C}b");
452        assert_eq!(findings.len(), 1);
453        assert!(
454            findings[0]
455                .message
456                .contains("2 multiple consecutive invisible characters detected")
457        );
458        assert_eq!(findings[0].column, 2);
459        assert_eq!(findings[0].end_column, 4);
460        assert!(findings[0].fix.is_some());
461    }
462
463    #[test]
464    fn test_default_flags_invisible_chars_at_line_boundaries() {
465        let findings = check("\u{2060}start\nend\u{200B}");
466        assert_eq!(findings.len(), 2);
467        assert!(
468            findings[0]
469                .message
470                .contains("Invisible character U+2060 detected at line boundary")
471        );
472        assert!(
473            findings[1]
474                .message
475                .contains("Invisible character U+200B detected at line boundary")
476        );
477    }
478
479    #[test]
480    fn test_default_flags_invisible_adjacent_to_whitespace() {
481        let findings = check("a \u{2060}b");
482        assert_eq!(findings.len(), 1);
483        assert!(
484            findings[0]
485                .message
486                .contains("Invisible character U+2060 detected adjacent to visible whitespace")
487        );
488    }
489
490    #[test]
491    fn test_default_fix_removes_triggered_characters() {
492        assert_eq!(fix("x\u{200B}\u{200C}y\nleft \u{2060} right"), "xy\nleft  right");
493    }
494
495    #[test]
496    fn test_strict_flags_any_invisible_character() {
497        let findings = check_with_config("ca\u{200C}t", true, "");
498        assert_eq!(findings.len(), 1);
499        assert!(findings[0].message.contains("strict mode"));
500        assert!(findings[0].fix.is_some());
501
502        assert_eq!(fix_with_config("ca\u{200C}t", true, ""), "cat");
503    }
504
505    #[test]
506    fn test_allow_list_suppresses_findings() {
507        assert!(check_with_config("\u{200B}ok\u{200B}", false, "\u{200B}").is_empty());
508    }
509
510    #[test]
511    fn test_md084_default_triggers_are_targeted() {
512        let findings = check("a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}");
513        assert_eq!(findings.len(), 4);
514
515        // Default mode should provide auto-fixes.
516        assert!(findings.iter().all(|w| w.fix.is_some()));
517    }
518
519    #[test]
520    fn test_md084_strict_mode_flags_any_invisible() {
521        let findings = check_with_config("in\u{200C}word", true, "");
522        assert_eq!(findings.len(), 1);
523        assert!(findings[0].fix.is_some());
524    }
525
526    #[test]
527    fn test_md084_allow_list_by_codepoint() {
528        let findings = check_with_config("\u{200B}safe\u{200B}", false, "\u{200B}");
529        assert!(findings.is_empty());
530    }
531
532    #[test]
533    fn test_tab_characters() {
534        let findings = check("text\n\tindented\n");
535        assert!(findings.is_empty());
536    }
537
538    #[test]
539    fn test_carriage_returns_are_line_endings_not_hidden_content() {
540        // A document written with classic-Mac line endings is a single line to
541        // `str::lines()`, which splits on `\n` and `\r\n` only. Every carriage return
542        // in it is therefore visible to this rule, and each of the three default
543        // triggers used to reach one: the last is at a line boundary, a doubled pair
544        // is a run of two, and one after a space is adjacent to whitespace. Removing
545        // any of them joins two lines or drops the document's final line ending.
546        for content in [
547            "# Title\rSome text\rMore text\r",
548            "# Title\r\rSome text\r",
549            "# Title \rSome text\r",
550            "a\rb\n",
551        ] {
552            let findings = check(content);
553            assert!(findings.is_empty(), "{content:?} gave {findings:?}");
554            assert_eq!(fix(content), content, "fixing {content:?}");
555        }
556    }
557
558    #[test]
559    fn test_strict_mode_keeps_carriage_returns() {
560        // Strict mode widens which hidden characters are reported. Line endings are
561        // not among them at any strictness: deleting all three here would collapse
562        // the document onto one line.
563        for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
564            let findings = check_with_config(content, true, "");
565            assert!(findings.is_empty(), "{content:?} gave {findings:?}");
566            assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
567        }
568    }
569
570    #[test]
571    fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
572        // Sparing the line ending must not spare what hides against it. A carriage
573        // return draws no glyph, so it still forms a run with its neighbor and still
574        // counts as the whitespace trigger 3 looks for; only the line ending survives
575        // the fix.
576        for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
577            let findings = check_with_config(content, strict, "");
578            assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
579            assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
580        }
581    }
582
583    #[test]
584    fn test_line_feeds_do_not_defeat_the_skip_guard() {
585        // `should_skip` scans the whole document rather than the split lines, so it
586        // sees line feeds. They are in the invisible set, so counting them as
587        // reportable left the guard unable to skip any document with more than one
588        // line.
589        let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
590        assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
591
592        let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
593        assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
594    }
595
596    #[test]
597    fn test_default_ignores_variation_selector_attached_to_base() {
598        // U+FE0F gives the preceding character emoji presentation. It legitimately
599        // sits at a line end or next to a space, which are two of the default triggers.
600        let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
601        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
602
603        let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
604        assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
605    }
606
607    #[test]
608    fn test_default_fix_preserves_emoji_presentation() {
609        let content = "> \u{26A0}\u{FE0F} Note: important\n";
610        assert_eq!(fix(content), content);
611    }
612
613    #[test]
614    fn test_default_flags_orphaned_variation_selector() {
615        // No base character to modify: the selector is hidden content, not presentation.
616        let findings = check("\u{FE0F}starts with a selector");
617        assert_eq!(findings.len(), 1);
618        assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
619
620        let findings = check("a \u{FE0F}b");
621        assert_eq!(findings.len(), 1);
622        assert!(
623            findings[0]
624                .message
625                .contains("U+FE0F detected adjacent to visible whitespace")
626        );
627
628        // Preceded by another invisible character, so it still modifies nothing.
629        let findings = check("a\u{200B}\u{FE0F}b");
630        assert_eq!(findings.len(), 1);
631        assert!(
632            findings[0]
633                .message
634                .contains("2 multiple consecutive invisible characters")
635        );
636    }
637
638    #[test]
639    fn test_default_flags_redundant_variation_selector() {
640        // The first selector is attached to the base; the duplicate after it is not.
641        // Mid-line matters here: the duplicate is neither at a boundary nor next to
642        // whitespace, so it is only caught by counting the attached selector as part
643        // of the cluster.
644        for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
645            let findings = check(content);
646            assert_eq!(findings.len(), 1, "content {content:?}");
647            assert_eq!(findings[0].column, 3, "content {content:?}");
648            assert_eq!(findings[0].end_column, 4, "content {content:?}");
649            assert!(
650                findings[0]
651                    .message
652                    .contains("U+FE0F detected next to another invisible character"),
653                "content {content:?}: {}",
654                findings[0].message
655            );
656        }
657    }
658
659    #[test]
660    fn test_default_ignores_emoji_zwj_sequences() {
661        // Each of these is a single glyph held together by joiners, and some carry a
662        // variation selector next to the joiner. Removing either splits the emoji.
663        let sequences = [
664            "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",                           // rainbow flag
665            "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}",           // couple with heart
666            "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}",                     // woman bouncing ball
667            "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}",                            // pirate flag
668            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", // family
669        ];
670
671        for sequence in sequences {
672            let content = format!("look: {sequence} here");
673            let findings = check(&content);
674            assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
675
676            assert_eq!(fix(&content), content, "sequence {sequence:?} was rewritten");
677        }
678    }
679
680    #[test]
681    fn test_default_flags_orphaned_joiner() {
682        // A joiner only earns its exemption by fusing visible characters on both sides.
683        let findings = check("joins nothing\u{200D}");
684        assert_eq!(findings.len(), 1);
685        assert!(findings[0].message.contains("U+200D detected at line boundary"));
686
687        let findings = check("a \u{200D}b");
688        assert_eq!(findings.len(), 1);
689        assert!(
690            findings[0]
691                .message
692                .contains("U+200D detected adjacent to visible whitespace")
693        );
694
695        // Joiner followed by a zero-width space rather than a visible character.
696        let findings = check("a\u{200D}\u{200B}b");
697        assert_eq!(findings.len(), 1);
698        assert!(
699            findings[0]
700                .message
701                .contains("2 multiple consecutive invisible characters")
702        );
703    }
704
705    #[test]
706    fn test_default_flags_invisible_hiding_behind_an_emoji() {
707        // A zero-width space tucked between an emoji and the next word is surrounded
708        // by an attached selector on one side, so it only surfaces if the selector
709        // still counts toward the cluster.
710        let content = "\u{26A0}\u{FE0F}\u{200B}x";
711        let findings = check(content);
712        assert_eq!(findings.len(), 1);
713        assert_eq!(findings[0].column, 3);
714        assert!(
715            findings[0]
716                .message
717                .contains("U+200B detected next to another invisible character")
718        );
719
720        // The fix removes only the zero-width space, leaving the emoji intact.
721        assert_eq!(fix(content), "\u{26A0}\u{FE0F}x");
722    }
723
724    #[test]
725    fn test_strict_still_flags_attached_variation_selector() {
726        // Strict mode is deliberately literal: it reports every invisible codepoint,
727        // and users who want emoji left alone allow-list U+FE0F.
728        let findings = check_with_config("\u{26A0}\u{FE0F} Note", true, "");
729        assert_eq!(findings.len(), 1);
730        assert!(findings[0].message.contains("strict mode"));
731    }
732
733    #[test]
734    fn test_default_markup_unsuitable_characters_are_flagged() {
735        // Only a substitution that preserves the text comes with a fix: the tone marks
736        // have canonical equivalents, the object replacement character does not.
737        let findings = check("\u{0340}deprecated\u{0341}\u{FFFC}");
738        assert_eq!(findings.len(), 3, "Got {findings:?}");
739        assert!(
740            findings[0]
741                .message
742                .contains("U+0340 is not suitable for use with markup")
743        );
744        assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
745        assert!(
746            findings[1]
747                .message
748                .contains("U+0341 is not suitable for use with markup")
749        );
750        assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
751        assert!(
752            findings[2]
753                .message
754                .contains("U+FFFC is not suitable for use with markup")
755        );
756        assert!(findings[2].fix.is_none());
757    }
758
759    #[test]
760    fn test_strict_markup_unsuitable_characters_are_flagged() {
761        let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", true, "");
762        assert_eq!(findings.len(), 3, "Got {findings:?}");
763        assert_eq!(findings[0].fix.as_ref().unwrap().replacement, "\u{0300}");
764        assert!(findings[1].message.contains("U+0341"));
765        assert_eq!(findings[1].fix.as_ref().unwrap().replacement, "\u{0301}");
766        assert!(findings[2].message.contains("U+FFFC"));
767        assert!(findings[2].fix.is_none());
768    }
769
770    #[test]
771    fn test_allowed_markup_unsuitable_characters_are_not_flagged() {
772        let findings = check_with_config("\u{0340}deprecated\u{0341}\u{FFFC}", false, "\u{0340}\u{0341}\u{FFFC}");
773        assert!(findings.is_empty());
774    }
775
776    #[test]
777    fn test_default_deprecated_visible_character_is_flagged_without_a_fix() {
778        // U+0149 renders, so only the author knows what it should say instead.
779        let findings = check("Cote d\u{0149}Ivoire");
780        assert_eq!(findings.len(), 1, "Got {findings:?}");
781        assert!(
782            findings[0]
783                .message
784                .contains("Deprecated Unicode code point U+0149 detected")
785        );
786        assert!(findings[0].fix.is_none());
787        assert_eq!(fix("Cote d\u{0149}Ivoire"), "Cote d\u{0149}Ivoire");
788    }
789
790    #[test]
791    fn test_deprecated_and_invisible_keeps_the_removal_fix() {
792        // U+206A is both invisible and deprecated. The invisible triggers carry a
793        // removal fix, so they must win over the unfixable deprecated diagnostic.
794        for (content, expected_fix) in [
795            ("\u{206A}x", "x"),
796            ("x\u{206A}", "x"),
797            ("x \u{206A}y", "x y"),
798            ("x\u{206A}\u{206B}y", "xy"),
799        ] {
800            let findings = check(content);
801            assert_eq!(findings.len(), 1, "{content:?} gave {findings:?}");
802            assert!(
803                findings[0].message.starts_with("Invisible character")
804                    || findings[0].message.contains("consecutive invisible characters"),
805                "{content:?} gave {:?}",
806                findings[0].message
807            );
808            assert_eq!(fix(content), expected_fix, "fixing {content:?}");
809        }
810    }
811
812    #[test]
813    fn test_deprecated_and_invisible_is_reported_once() {
814        // Interior, non-adjacent to whitespace: no invisible trigger applies, so the
815        // deprecated diagnostic is the only one, and it carries no fix.
816        let findings = check("x\u{206A}y");
817        assert_eq!(findings.len(), 1, "Got {findings:?}");
818        assert!(
819            findings[0]
820                .message
821                .contains("Deprecated Unicode code point U+206A detected")
822        );
823        assert!(findings[0].fix.is_none());
824        assert_eq!(fix("x\u{206A}y"), "x\u{206A}y");
825    }
826
827    #[test]
828    fn test_interlinear_annotation_is_reported_but_never_stripped() {
829        // U+FFF9..U+FFFB delimit ruby text. They are not default-ignorable, and deleting
830        // one would leave the annotation half-open, so they are reported without a fix.
831        let content = "\u{FFF9}base\u{FFFA}gloss\u{FFFB}";
832        let findings = check(content);
833        assert_eq!(findings.len(), 3, "Got {findings:?}");
834        for finding in &findings {
835            assert!(finding.message.contains("is not suitable for use with markup"));
836            assert!(finding.fix.is_none());
837        }
838        assert_eq!(fix(content), content);
839    }
840
841    #[test]
842    fn test_annotation_delimiter_is_not_a_presentation_base() {
843        // An annotation delimiter draws no glyph, so a selector or joiner beside one
844        // has nothing to modify and stays reportable, mid-line as much as at a line
845        // boundary: the pair is a cluster of characters that draw nothing. The fix
846        // removes only the orphan; the delimiter itself is never stripped.
847        for (content, expected_fix) in [
848            ("\u{FFF9}\u{FE0F}", "\u{FFF9}"),
849            ("\u{FFF9}\u{200D}", "\u{FFF9}"),
850            ("base\u{FFF9}\u{FE0F}", "base\u{FFF9}"),
851            ("x\u{FFF9}\u{FE0F}y", "x\u{FFF9}y"),
852            ("x\u{FFF9}\u{200D}y", "x\u{FFF9}y"),
853        ] {
854            let findings = check(content);
855            assert_eq!(findings.len(), 2, "{content:?} gave {findings:?}");
856            assert!(
857                findings.iter().any(|f| f.message.contains("Invisible character")
858                    || f.message.contains("consecutive invisible characters")),
859                "{content:?} gave {findings:?}"
860            );
861            assert_eq!(fix(content), expected_fix, "fixing {content:?}");
862        }
863    }
864
865    #[test]
866    fn test_reserved_specials_below_the_annotation_block_stay_invisible() {
867        // U+FFF0..U+FFF8 are default-ignorable, so they keep the removal fix.
868        let findings = check("\u{FFF8}x");
869        assert_eq!(findings.len(), 1, "Got {findings:?}");
870        assert!(
871            findings[0]
872                .message
873                .contains("Invisible character U+FFF8 detected at line boundary")
874        );
875        assert_eq!(fix("\u{FFF8}x"), "x");
876    }
877}