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