mod md084_config;
use crate::lint_context::LintContext;
use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use md084_config::MD084Config;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct MD084InvisibleCharacters {
config: MD084Config,
allowed_codepoints: HashSet<u32>,
}
impl Default for MD084InvisibleCharacters {
fn default() -> Self {
Self::from_config_struct(MD084Config::default())
}
}
impl MD084InvisibleCharacters {
fn from_config_struct(config: MD084Config) -> Self {
let allowed_codepoints = config
.allow
.iter()
.filter_map(|token| parse_codepoint_token(token))
.collect();
Self {
config,
allowed_codepoints,
}
}
#[inline]
fn is_allowed(&self, c: char) -> bool {
self.allowed_codepoints.contains(&(c as u32))
}
fn format_codepoint(c: char) -> String {
let cp = c as u32;
if cp <= 0xFFFF {
format!("U+{cp:04X}")
} else {
format!("U+{cp:06X}")
}
}
fn is_invisible_char(c: char) -> bool {
let cp = c as u32;
matches!(
cp,
0x0000..=0x0008
| 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 )
}
#[inline]
fn is_flaggable(&self, c: char) -> bool {
Self::is_invisible_char(c) && !self.is_allowed(c)
}
fn is_variation_selector(c: char) -> bool {
matches!(
c as u32,
0x180B..=0x180D | 0xFE00..=0xFE0F | 0xE0100..=0xE01EF )
}
const ZWJ: char = '\u{200D}';
fn is_visible_base(chars: &[char], index: usize) -> bool {
chars
.get(index)
.is_some_and(|&c| !c.is_whitespace() && !Self::is_invisible_char(c))
}
fn follows_visible_base(chars: &[char], index: usize) -> bool {
let Some(prev) = index.checked_sub(1) else {
return false;
};
Self::is_visible_base(chars, prev)
|| (Self::is_variation_selector(chars[prev])
&& prev
.checked_sub(1)
.is_some_and(|base| Self::is_visible_base(chars, base)))
}
fn is_presentation(chars: &[char], index: usize) -> bool {
let c = chars[index];
if Self::is_variation_selector(c) {
return index
.checked_sub(1)
.is_some_and(|prev| Self::is_visible_base(chars, prev));
}
c == Self::ZWJ && Self::follows_visible_base(chars, index) && Self::is_visible_base(chars, index + 1)
}
fn cluster_message(len: usize, first: char) -> String {
let codepoint = Self::format_codepoint(first);
if len >= 2 {
format!("{len} multiple consecutive invisible characters detected, first one is {codepoint}")
} else {
format!("Invisible character {codepoint} detected next to another invisible character")
}
}
fn build_warning(
rule_name: &str,
ctx: &LintContext,
line: usize,
start_col: usize,
len_chars: usize,
message: String,
fixable: bool,
) -> LintWarning {
let fix = fixable.then(|| {
Fix::new(
ctx.line_index
.line_col_to_byte_range_with_length(line, start_col, len_chars),
String::new(),
)
});
LintWarning {
rule_name: Some(rule_name.to_string()),
line,
column: start_col,
end_line: line,
end_column: start_col + len_chars,
severity: Severity::Warning,
message,
fix,
}
}
}
impl Rule for MD084InvisibleCharacters {
fn name(&self) -> &'static str {
"MD084"
}
fn description(&self) -> &'static str {
"Invisible Unicode characters should be intentional"
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn fix_capability(&self) -> FixCapability {
FixCapability::ConditionallyFixable
}
fn should_skip(&self, ctx: &LintContext) -> bool {
ctx.content.is_empty()
|| !ctx
.content
.chars()
.any(|c| Self::is_invisible_char(c) && !self.is_allowed(c))
}
fn check(&self, ctx: &LintContext) -> LintResult {
let mut warnings = Vec::new();
for (line_idx, line) in ctx.raw_lines().iter().enumerate() {
let line_num = line_idx + 1;
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
continue;
}
if self.config.strict {
warnings.extend(
chars
.iter()
.enumerate()
.filter(|&(_, &c)| self.is_flaggable(c))
.map(|(i, &c)| {
Self::build_warning(
self.name(),
ctx,
line_num,
i + 1,
1,
format!(
"Invisible character {} detected (strict mode)",
Self::format_codepoint(c)
),
true,
)
}),
);
continue;
}
let mut flagged = vec![false; chars.len()];
let flaggable: Vec<bool> = chars.iter().map(|&c| self.is_flaggable(c)).collect();
let exempt: Vec<bool> = (0..chars.len()).map(|i| Self::is_presentation(&chars, i)).collect();
let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
let mut offset = 0;
for group in flaggable.chunk_by(|a, b| a == b) {
let len = group.len();
if group[0] && len >= 2 {
let mut start = offset;
for stretch in exempt[offset..offset + len].chunk_by(|a, b| a == b) {
let stretch_len = stretch.len();
if !stretch[0] {
flagged[start..start + stretch_len].fill(true);
warnings.push(Self::build_warning(
self.name(),
ctx,
line_num,
start + 1,
stretch_len,
Self::cluster_message(stretch_len, chars[start]),
true,
));
}
start += stretch_len;
}
}
offset += len;
}
for (i, &c) in chars.iter().enumerate() {
if !is_target[i] || flagged[i] {
continue;
}
if i == 0 || i == chars.len() - 1 {
flagged[i] = true;
warnings.push(Self::build_warning(
self.name(),
ctx,
line_num,
i + 1,
1,
format!(
"Invisible character {} detected at line boundary",
Self::format_codepoint(c)
),
true,
));
continue;
}
if chars[i - 1].is_whitespace() || chars[i + 1].is_whitespace() {
flagged[i] = true;
warnings.push(Self::build_warning(
self.name(),
ctx,
line_num,
i + 1,
1,
format!(
"Invisible character {} detected adjacent to visible whitespace",
Self::format_codepoint(c)
),
true,
));
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
.map_err(crate::rule::LintError::InvalidInput)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD084Config);
}
fn parse_codepoint_token(token: &str) -> Option<u32> {
let trimmed = token.trim();
let hex = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+"))?;
if !(4..=6).contains(&hex.len()) || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
return None;
}
Some(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, MarkdownFlavor};
fn check(content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
MD084InvisibleCharacters::default().check(&ctx).unwrap()
}
#[test]
fn test_default_no_findings_on_plain_text() {
let findings = check("plain text\nsecond line\n");
assert!(findings.is_empty());
}
#[test]
fn test_default_flags_multiple_consecutive_invisibles() {
let findings = check("a\u{200B}\u{200C}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("2 multiple consecutive invisible characters detected")
);
assert_eq!(findings[0].column, 2);
assert_eq!(findings[0].end_column, 4);
assert!(findings[0].fix.is_some());
}
#[test]
fn test_default_flags_invisible_chars_at_line_boundaries() {
let findings = check("\u{2060}start\nend\u{200B}");
assert_eq!(findings.len(), 2);
assert!(
findings[0]
.message
.contains("Invisible character U+2060 detected at line boundary")
);
assert!(
findings[1]
.message
.contains("Invisible character U+200B detected at line boundary")
);
}
#[test]
fn test_default_flags_invisible_adjacent_to_whitespace() {
let findings = check("a \u{2060}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("Invisible character U+2060 detected adjacent to visible whitespace")
);
}
#[test]
fn test_default_fix_removes_triggered_characters() {
let content = "x\u{200B}\u{200C}y\nleft \u{2060} right";
let rule = MD084InvisibleCharacters::default();
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "xy\nleft right");
}
#[test]
fn test_strict_flags_any_invisible_character() {
let config: Config = toml::from_str(
r#"
[MD084]
strict = true
"#,
)
.unwrap();
let rule = MD084InvisibleCharacters::from_config(&config);
let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
let ctx = LintContext::new("ca\u{200C}t", MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("strict mode"));
assert!(findings[0].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), "cat");
}
#[test]
fn test_allow_list_suppresses_findings() {
let config: Config = toml::from_str(
r#"
[MD084]
allow = ["U+200B"]
"#,
)
.unwrap();
let rule = MD084InvisibleCharacters::from_config(&config);
let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
let ctx = LintContext::new("\u{200B}ok\u{200B}", MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert!(findings.is_empty());
}
#[test]
fn test_md084_default_triggers_are_targeted() {
let rule = MD084InvisibleCharacters::default();
let content = "a\u{200B}\u{200C}b\nleft \u{2060} right\n\u{2060}edge\nend\u{200B}";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert_eq!(findings.len(), 4);
assert!(findings.iter().all(|w| w.fix.is_some()));
}
#[test]
fn test_md084_strict_mode_flags_any_invisible() {
let config: Config = toml::from_str(
r#"
[MD084]
strict = true
"#,
)
.unwrap();
let rule = MD084InvisibleCharacters::from_config(&config);
let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
let ctx = LintContext::new("in\u{200C}word", MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert_eq!(findings.len(), 1);
assert!(findings[0].fix.is_some());
}
#[test]
fn test_md084_allow_list_by_codepoint() {
let config: Config = toml::from_str(
r#"
[MD084]
allow = ["U+200B"]
"#,
)
.unwrap();
let rule = MD084InvisibleCharacters::from_config(&config);
let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
let ctx = LintContext::new("\u{200B}safe\u{200B}", MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert!(findings.is_empty());
}
#[test]
fn test_tab_characters() {
let rule = MD084InvisibleCharacters::default();
let content = "text\n\tindented\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert!(findings.is_empty());
}
#[test]
fn test_default_ignores_variation_selector_attached_to_base() {
let findings = check("> \u{26A0}\u{FE0F} Note: important\nends with \u{2764}\u{FE0F}\n");
assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
let findings = check("# Features \u{25B6}\u{FE0F}\n\ntwo \u{2714}\u{FE0F}\u{2764}\u{FE0F} in a row\n");
assert!(findings.is_empty(), "attached variation selectors: {findings:?}");
}
#[test]
fn test_default_fix_preserves_emoji_presentation() {
let content = "> \u{26A0}\u{FE0F} Note: important\n";
let rule = MD084InvisibleCharacters::default();
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
#[test]
fn test_default_flags_orphaned_variation_selector() {
let findings = check("\u{FE0F}starts with a selector");
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("U+FE0F detected at line boundary"));
let findings = check("a \u{FE0F}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("U+FE0F detected adjacent to visible whitespace")
);
let findings = check("a\u{200B}\u{FE0F}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("2 multiple consecutive invisible characters")
);
}
#[test]
fn test_default_flags_redundant_variation_selector() {
for content in ["\u{26A0}\u{FE0F}\u{FE0F}", "\u{26A0}\u{FE0F}\u{FE0F}x"] {
let findings = check(content);
assert_eq!(findings.len(), 1, "content {content:?}");
assert_eq!(findings[0].column, 3, "content {content:?}");
assert_eq!(findings[0].end_column, 4, "content {content:?}");
assert!(
findings[0]
.message
.contains("U+FE0F detected next to another invisible character"),
"content {content:?}: {}",
findings[0].message
);
}
}
#[test]
fn test_default_ignores_emoji_zwj_sequences() {
let sequences = [
"\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}", ];
for sequence in sequences {
let content = format!("look: {sequence} here");
let findings = check(&content);
assert!(findings.is_empty(), "sequence {sequence:?}: {findings:?}");
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
assert_eq!(
MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
content,
"sequence {sequence:?} was rewritten"
);
}
}
#[test]
fn test_default_flags_orphaned_joiner() {
let findings = check("joins nothing\u{200D}");
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("U+200D detected at line boundary"));
let findings = check("a \u{200D}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("U+200D detected adjacent to visible whitespace")
);
let findings = check("a\u{200D}\u{200B}b");
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.message
.contains("2 multiple consecutive invisible characters")
);
}
#[test]
fn test_default_flags_invisible_hiding_behind_an_emoji() {
let content = "\u{26A0}\u{FE0F}\u{200B}x";
let findings = check(content);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].column, 3);
assert!(
findings[0]
.message
.contains("U+200B detected next to another invisible character")
);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
MD084InvisibleCharacters::default().fix(&ctx).unwrap(),
"\u{26A0}\u{FE0F}x"
);
}
#[test]
fn test_strict_still_flags_attached_variation_selector() {
let config: Config = toml::from_str(
r#"
[MD084]
strict = true
"#,
)
.unwrap();
let rule = MD084InvisibleCharacters::from_config(&config);
let rule = rule.as_any().downcast_ref::<MD084InvisibleCharacters>().unwrap();
let ctx = LintContext::new("\u{26A0}\u{FE0F} Note", MarkdownFlavor::Standard, None);
let findings = rule.check(&ctx).unwrap();
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("strict mode"));
}
}