pub fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
pub fn is_joiner(c: char) -> bool {
matches!(c, '\u{200C}' | '\u{200D}')
}
pub fn is_invisible(c: char) -> bool {
is_bidi_control(c)
|| is_joiner(c)
|| matches!(
c,
'\u{00AD}'
| '\u{034F}'
| '\u{115F}'
| '\u{1160}'
| '\u{180E}'
| '\u{200B}'
| '\u{2060}'..='\u{2064}'
| '\u{206A}'..='\u{206F}'
| '\u{2800}'
| '\u{3164}'
| '\u{FEFF}'
| '\u{FFA0}'
| '\u{FFF9}'..='\u{FFFB}'
)
}
pub fn is_tag(c: char) -> bool {
matches!(c, '\u{E0000}'..='\u{E007F}')
}
pub fn is_variation_selector(c: char) -> bool {
matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')
}
pub fn is_combining_mark(c: char) -> bool {
matches!(c, '\u{0300}'..='\u{036F}' | '\u{1AB0}'..='\u{1AFF}' | '\u{1DC0}'..='\u{1DFF}' | '\u{20D0}'..='\u{20FF}' | '\u{FE20}'..='\u{FE2F}')
}
pub const MAX_COMBINING_RUN: usize = 8;
fn shows_nothing(c: char) -> bool {
c.is_whitespace() || is_invisible(c) || is_tag(c) || is_variation_selector(c) || is_combining_mark(c)
}
pub fn name_problem(text: &str) -> Option<&'static str> {
if text.chars().any(char::is_control) {
Some("contains control characters")
} else if text.chars().any(|c| is_invisible(c) || is_tag(c)) {
Some("contains invisible or direction-changing characters")
} else {
None
}
}
pub fn message_problem(text: &str) -> Option<&'static str> {
if text.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
return Some("contains control characters (only line breaks and tabs are allowed)");
}
if text.chars().any(|c| is_invisible(c) && !is_joiner(c)) {
return Some("contains invisible or direction-changing characters");
}
if text.chars().all(shows_nothing) {
return Some("shows nothing (only spaces, joiners or marks)");
}
let mut run = 0usize;
for c in text.chars() {
run = if is_combining_mark(c) { run + 1 } else { 0 };
if run > MAX_COMBINING_RUN {
return Some("stacks too many combining marks");
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules() {
assert_eq!(name_problem("Ada Lovelace"), None);
assert_eq!(name_problem("Zoë 🙂"), None);
for bad in [
"a\0b",
"a\u{7}",
"a\u{7F}",
"a\u{85}",
"\u{200B}",
"\u{202E}evil",
"\u{FEFF}x",
"a\u{200D}b",
"a\u{00AD}b",
"a\u{2066}b",
"\u{3164}",
"\u{115F}",
"\u{2800}",
"a\u{E0041}",
] {
assert!(name_problem(bad).is_some(), "{bad:?}");
}
assert_eq!(message_problem("line 1\nline 2\tend"), None);
assert_eq!(message_problem("family: 👨\u{200D}👩\u{200D}👧"), None);
for good in ["\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}", "1\u{FE0F}\u{20E3}", "cafe\u{301} na\u{303}o"] {
assert_eq!(message_problem(good), None, "{good:?}");
}
let zalgo = format!("z{}", "\u{0301}".repeat(MAX_COMBINING_RUN + 1));
let fine = format!("z{}", "\u{0301}".repeat(MAX_COMBINING_RUN));
assert_eq!(message_problem(&fine), None);
for bad in [
"a\0",
"bell\u{7}",
"cr\r\n",
"\u{202E}gnp.exe",
"zw\u{200B}sp",
"\u{FEFF}",
"\u{200D}",
"\u{200D}\u{200C}\u{200D}",
"\u{3164}",
"\u{115F}",
"\u{2800}",
"\u{E0041}\u{E0042}",
"\u{0301}\u{0302}",
"\u{FE0F}",
zalgo.as_str(),
] {
assert!(message_problem(bad).is_some(), "{bad:?}");
}
}
}