1mod md084_config;
14
15use crate::lint_context::LintContext;
16use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
17use md084_config::MD084Config;
18use std::collections::HashSet;
19
20#[derive(Debug, Clone)]
21pub struct MD084InvisibleCharacters {
22 config: MD084Config,
23 allowed_codepoints: HashSet<u32>,
24}
25
26impl Default for MD084InvisibleCharacters {
27 fn default() -> Self {
28 Self::from_config_struct(MD084Config::default())
29 }
30}
31
32impl MD084InvisibleCharacters {
33 fn from_config_struct(config: MD084Config) -> Self {
34 let allowed_codepoints = config
35 .allow
36 .iter()
37 .filter_map(|token| parse_codepoint_token(token))
38 .collect();
39
40 Self {
41 config,
42 allowed_codepoints,
43 }
44 }
45
46 #[inline]
47 fn is_allowed(&self, c: char) -> bool {
48 self.allowed_codepoints.contains(&(c as u32))
49 }
50
51 fn format_codepoint(c: char) -> String {
52 let cp = c as u32;
53 if cp <= 0xFFFF {
54 format!("U+{cp:04X}")
55 } else {
56 format!("U+{cp:06X}")
57 }
58 }
59
60 fn is_invisible_char(c: char) -> bool {
61 let cp = c as u32;
62 matches!(
63 cp,
64 0x0000..=0x0008
65 | 0x000A..=0x001F | 0x007F..=0x009F | 0x00AD | 0x034F | 0x061C | 0x115F | 0x1160 | 0x17B4 | 0x17B5 | 0x180B..=0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x206F | 0x3164 | 0xFE00..=0xFE0F | 0xFEFF | 0xFFA0 | 0xFFF0..=0xFFF8 | 0x1BCA0..=0x1BCA3 | 0x1D173..=0x1D17A | 0xE0000..=0xE0FFF )
87 }
88
89 #[inline]
91 fn is_flaggable(&self, c: char) -> bool {
92 Self::is_invisible_char(c) && !self.is_allowed(c)
93 }
94
95 fn is_variation_selector(c: char) -> bool {
99 matches!(
100 c as u32,
101 0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
105 }
106
107 const ZWJ: char = '\u{200D}';
109
110 fn is_visible_base(chars: &[char], index: usize) -> bool {
113 chars
114 .get(index)
115 .is_some_and(|&c| !c.is_whitespace() && !Self::is_invisible_char(c))
116 }
117
118 fn follows_visible_base(chars: &[char], index: usize) -> bool {
122 let Some(prev) = index.checked_sub(1) else {
123 return false;
124 };
125
126 Self::is_visible_base(chars, prev)
127 || (Self::is_variation_selector(chars[prev])
128 && prev
129 .checked_sub(1)
130 .is_some_and(|base| Self::is_visible_base(chars, base)))
131 }
132
133 fn is_presentation(chars: &[char], index: usize) -> bool {
141 let c = chars[index];
142
143 if Self::is_variation_selector(c) {
144 return index
147 .checked_sub(1)
148 .is_some_and(|prev| Self::is_visible_base(chars, prev));
149 }
150
151 c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
152 }
153
154 fn cluster_message(len: usize, first: char) -> String {
158 let codepoint = Self::format_codepoint(first);
159 if len >= 2 {
160 format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
161 } else {
162 format!("Invisible character {codepoint} detected next to another invisible character")
163 }
164 }
165
166 fn build_warning(
168 rule_name: &str,
169 ctx: &LintContext,
170 line: usize,
171 start_col: usize,
172 len_chars: usize,
173 message: String,
174 fixable: bool,
175 ) -> LintWarning {
176 let fix = fixable.then(|| {
177 Fix::new(
178 ctx.line_index
179 .line_col_to_byte_range_with_length(line, start_col, len_chars),
180 String::new(),
181 )
182 });
183
184 LintWarning {
185 rule_name: Some(rule_name.to_string()),
186 line,
187 column: start_col,
188 end_line: line,
189 end_column: start_col + len_chars,
190 severity: Severity::Warning,
191 message,
192 fix,
193 }
194 }
195}
196
197impl Rule for MD084InvisibleCharacters {
198 fn name(&self) -> &'static str {
199 "MD084"
200 }
201
202 fn description(&self) -> &'static str {
203 "Invisible Unicode characters should be intentional"
204 }
205
206 fn category(&self) -> RuleCategory {
207 RuleCategory::Whitespace
208 }
209
210 fn fix_capability(&self) -> FixCapability {
211 FixCapability::ConditionallyFixable
212 }
213
214 fn should_skip(&self, ctx: &LintContext) -> bool {
215 ctx.content.is_empty()
216 || !ctx
217 .content
218 .chars()
219 .any(|c| Self::is_invisible_char(c) && !self.is_allowed(c))
220 }
221
222 fn check(&self, ctx: &LintContext) -> LintResult {
223 let mut warnings = Vec::new();
224
225 for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
226 let line_num = line_idx + 1;
227 let chars: Vec<char> = line.chars().collect();
228
229 if chars.is_empty() {
230 continue;
231 }
232
233 if self.config.strict {
235 warnings.extend(
236 chars
237 .iter()
238 .enumerate()
239 .filter(|&(_, &c)| self.is_flaggable(c))
240 .map(|(i, &c)| {
241 Self::build_warning(
242 self.name(),
243 ctx,
244 line_num,
245 i + 1,
246 1,
247 format!(
248 "Invisible character {} detected (strict mode)",
249 Self::format_codepoint(c)
250 ),
251 true,
252 )
253 }),
254 );
255 continue;
256 }
257
258 let mut flagged = vec![false; chars.len()];
263 let flaggable: Vec<bool> = chars.iter().map(|&c| self.is_flaggable(c)).collect();
264 let exempt: Vec<bool> = (0..chars.len()).map(|i| Self::is_presentation(&chars, i)).collect();
265 let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
266
267 let mut offset = 0;
271 for group in flaggable.chunk_by(|a, b| a == b) {
272 let len = group.len();
273 if group[0] && len >= 2 {
274 let mut start = offset;
275 for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
276 let stretch_len = stretch.len();
277 if !stretch[0] {
278 flagged[start..start + stretch_len].fill(true);
279 warnings.push(Self::build_warning(
280 self.name(),
281 ctx,
282 line_num,
283 start + 1,
284 stretch_len,
285 Self::cluster_message(stretch_len, chars[start]),
286 true,
287 ));
288 }
289 start += stretch_len;
290 }
291 }
292 offset += len;
293 }
294
295 for (i, &c) in chars.iter().enumerate() {
297 if !is_target[i] || flagged[i] {
298 continue;
299 }
300
301 if i == 0 || i == chars.len() - 1 {
303 flagged[i] = true;
304 warnings.push(Self::build_warning(
305 self.name(),
306 ctx,
307 line_num,
308 i + 1,
309 1,
310 format!(
311 "Invisible character {} detected at line boundary",
312 Self::format_codepoint(c)
313 ),
314 true,
315 ));
316 continue;
317 }
318
319 if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
323 flagged[i] = true;
324 warnings.push(Self::build_warning(
325 self.name(),
326 ctx,
327 line_num,
328 i + 1,
329 1,
330 format!(
331 "Invisible character {} detected adjacent to visible whitespace",
332 Self::format_codepoint(c)
333 ),
334 true,
335 ));
336 }
337 }
338 }
339
340 Ok(warnings)
341 }
342
343 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
344 if self.should_skip(ctx) {
345 return Ok(ctx.content.to_string());
346 }
347
348 let warnings = self.check(ctx)?;
349 if warnings.is_empty() {
350 return Ok(ctx.content.to_string());
351 }
352
353 let warnings =
354 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
355 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
356 .map_err(crate::rule::LintError::InvalidInput)
357 }
358
359 fn as_any(&self) -> &dyn std::any::Any {
360 self
361 }
362
363 crate::impl_rule_config_methods!(MD084Config);
364}
365
366fn parse_codepoint_token(token: &str) -> Option<u32> {
367 let trimmed = token.trim();
368 let hex = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+"))?;
369 if !(4..=6).contains(&hex.len()) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
370 return None;
371 }
372
373 let value = u32::from_str_radix(hex, 16).ok()?;
374 if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
375 return None;
376 }
377 Some(value)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::config::{Config, MarkdownFlavor};
384
385 fn check(content: &str) -> Vec<LintWarning> {
386 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
387 MD084InvisibleCharacters::default().check(&ctx).unwrap()
388 }
389
390 #[test]
391 fn test_default_no_findings_on_plain_text() {
392 let findings = check("plain text\nsecond line\n");
393 assert!(findings.is_empty());
394 }
395
396 #[test]
397 fn test_default_flags_multiple_consecutive_invisibles() {
398 let findings = check("a\u{200B}\u{200C}b");
399 assert_eq!(findings.len(), 1);
400 assert!(
401 findings[0]
402 .message
403 .contains("2 multiple consecutive invisible characters detected")
404 );
405 assert_eq!(findings[0].column, 2);
406 assert_eq!(findings[0].end_column, 4);
407 assert!(findings[0].fix.is_some());
408 }
409
410 #[test]
411 fn test_default_flags_invisible_chars_at_line_boundaries() {
412 let findings = check("\u{2060}start\nend\u{200B}");
413 assert_eq!(findings.len(), 2);
414 assert!(
415 findings[0]
416 .message
417 .contains("Invisible character U+2060 detected at line boundary")
418 );
419 assert!(
420 findings[1]
421 .message
422 .contains("Invisible character U+200B detected at line boundary")
423 );
424 }
425
426 #[test]
427 fn test_default_flags_invisible_adjacent_to_whitespace() {
428 let findings = check("a \u{2060}b");
429 assert_eq!(findings.len(), 1);
430 assert!(
431 findings[0]
432 .message
433 .contains("Invisible character U+2060 detected adjacent to visible whitespace")
434 );
435 }
436
437 #[test]
438 fn test_default_fix_removes_triggered_characters() {
439 let content = "x\u{200B}\u{200C}y\nleft \u{2060} right";
440 let rule = MD084InvisibleCharacters::default();
441 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
442
443 let fixed = rule.fix(&ctx).unwrap();
444 assert_eq!(fixed, "xy\nleft right");
445 }
446
447 #[test]
448 fn test_strict_flags_any_invisible_character() {
449 let config: Config = toml::from_str(
450 r#"
451 [MD084]
452 strict = true
453 "#,
454 )
455 .unwrap();
456
457 let rule = MD084InvisibleCharacters::from_config(&config);
458 let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
459
460 let ctx = LintContext::new("ca\u{200C}t", MarkdownFlavor::Standard, None);
461 let findings = rule.check(&ctx).unwrap();
462 assert_eq!(findings.len(), 1);
463 assert!(findings[0].message.contains("strict mode"));
464 assert!(findings[0].fix.is_some());
465
466 assert_eq!(rule.fix(&ctx).unwrap(), "cat");
467 }
468
469 #[test]
470 fn test_allow_list_suppresses_findings() {
471 let config: Config = toml::from_str(
472 r#"
473 [MD084]
474 allow = ["U+200B"]
475 "#,
476 )
477 .unwrap();
478
479 let rule = MD084InvisibleCharacters::from_config(&config);
480 let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
481
482 let ctx = LintContext::new("\u{200B}ok\u{200B}", MarkdownFlavor::Standard, None);
483 let findings = rule.check(&ctx).unwrap();
484 assert!(findings.is_empty());
485 }
486
487 #[test]
488 fn test_md084_default_triggers_are_targeted() {
489 let rule = MD084InvisibleCharacters::default();
490 let content = "a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}";
491 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
492
493 let findings = rule.check(&ctx).unwrap();
494 assert_eq!(findings.len(), 4);
495
496 assert!(findings.iter().all(|w| w.fix.is_some()));
498 }
499
500 #[test]
501 fn test_md084_strict_mode_flags_any_invisible() {
502 let config: Config = toml::from_str(
503 r#"
504 [MD084]
505 strict = true
506 "#,
507 )
508 .unwrap();
509 let rule = MD084InvisibleCharacters::from_config(&config);
510 let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
511
512 let ctx = LintContext::new("in\u{200C}word", MarkdownFlavor::Standard, None);
513 let findings = rule.check(&ctx).unwrap();
514
515 assert_eq!(findings.len(), 1);
516 assert!(findings[0].fix.is_some());
517 }
518
519 #[test]
520 fn test_md084_allow_list_by_codepoint() {
521 let config: Config = toml::from_str(
522 r#"
523 [MD084]
524 allow = ["U+200B"]
525 "#,
526 )
527 .unwrap();
528 let rule = MD084InvisibleCharacters::from_config(&config);
529 let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
530
531 let ctx = LintContext::new("\u{200B}safe\u{200B}", MarkdownFlavor::Standard, None);
532 let findings = rule.check(&ctx).unwrap();
533 assert!(findings.is_empty());
534 }
535
536 #[test]
537 fn test_tab_characters() {
538 let rule = MD084InvisibleCharacters::default();
539 let content = "text\n\tindented\n";
540 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
541 let findings = rule.check(&ctx).unwrap();
542 assert!(findings.is_empty());
543 }
544
545 #[test]
546 fn test_default_ignores_variation_selector_attached_to_base() {
547 let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
550 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
551
552 let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
553 assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
554 }
555
556 #[test]
557 fn test_default_fix_preserves_emoji_presentation() {
558 let content = "> \u{26A0}\u{FE0F} Note: important\n";
559 let rule = MD084InvisibleCharacters::default();
560 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
561
562 assert_eq!(rule.fix(&ctx).unwrap(), content);
563 }
564
565 #[test]
566 fn test_default_flags_orphaned_variation_selector() {
567 let findings = check("\u{FE0F}starts with a selector");
569 assert_eq!(findings.len(), 1);
570 assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
571
572 let findings = check("a \u{FE0F}b");
573 assert_eq!(findings.len(), 1);
574 assert!(
575 findings[0]
576 .message
577 .contains("U+FE0F detected adjacent to visible whitespace")
578 );
579
580 let findings = check("a\u{200B}\u{FE0F}b");
582 assert_eq!(findings.len(), 1);
583 assert!(
584 findings[0]
585 .message
586 .contains("2 multiple consecutive invisible characters")
587 );
588 }
589
590 #[test]
591 fn test_default_flags_redundant_variation_selector() {
592 for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
597 let findings = check(content);
598 assert_eq!(findings.len(), 1, "content {content:?}");
599 assert_eq!(findings[0].column, 3, "content {content:?}");
600 assert_eq!(findings[0].end_column, 4, "content {content:?}");
601 assert!(
602 findings[0]
603 .message
604 .contains("U+FE0F detected next to another invisible character"),
605 "content {content:?}: {}",
606 findings[0].message
607 );
608 }
609 }
610
611 #[test]
612 fn test_default_ignores_emoji_zwj_sequences() {
613 let sequences = [
616 "\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}", "\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}", "\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}", "\u{1F3F4}\u{200D}\u{2620}\u{FE0F}", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", ];
622
623 for sequence in sequences {
624 let content = format!("look: {sequence} here");
625 let findings = check(&content);
626 assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
627
628 let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
629 assert_eq!(
630 MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
631 content,
632 "sequence {sequence:?} was rewritten"
633 );
634 }
635 }
636
637 #[test]
638 fn test_default_flags_orphaned_joiner() {
639 let findings = check("joins nothing\u{200D}");
641 assert_eq!(findings.len(), 1);
642 assert!(findings[0].message.contains("U+200D detected at line boundary"));
643
644 let findings = check("a \u{200D}b");
645 assert_eq!(findings.len(), 1);
646 assert!(
647 findings[0]
648 .message
649 .contains("U+200D detected adjacent to visible whitespace")
650 );
651
652 let findings = check("a\u{200D}\u{200B}b");
654 assert_eq!(findings.len(), 1);
655 assert!(
656 findings[0]
657 .message
658 .contains("2 multiple consecutive invisible characters")
659 );
660 }
661
662 #[test]
663 fn test_default_flags_invisible_hiding_behind_an_emoji() {
664 let content = "\u{26A0}\u{FE0F}\u{200B}x";
668 let findings = check(content);
669 assert_eq!(findings.len(), 1);
670 assert_eq!(findings[0].column, 3);
671 assert!(
672 findings[0]
673 .message
674 .contains("U+200B detected next to another invisible character")
675 );
676
677 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
679 assert_eq!(
680 MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
681 "\u{26A0}\u{FE0F}x"
682 );
683 }
684
685 #[test]
686 fn test_strict_still_flags_attached_variation_selector() {
687 let config: Config = toml::from_str(
690 r#"
691 [MD084]
692 strict = true
693 "#,
694 )
695 .unwrap();
696
697 let rule = MD084InvisibleCharacters::from_config(&config);
698 let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
699
700 let ctx = LintContext::new("\u{26A0}\u{FE0F} Note", MarkdownFlavor::Standard, None);
701 let findings = rule.check(&ctx).unwrap();
702 assert_eq!(findings.len(), 1);
703 assert!(findings[0].message.contains("strict mode"));
704 }
705}