use std::fmt;
use unicode_script::{Script, UnicodeScript};
use unicode_security::mixed_script::AugmentedScriptSet;
use unicode_security::skeleton;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NameError {
pub ch: char,
pub kind: NameErrorKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NameErrorKind {
Whitespace,
Invisible,
NotAnIdentifier,
AmbiguousAscii,
DottedName,
}
impl fmt::Display for NameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.kind == NameErrorKind::DottedName {
return write!(
f,
"variable name contains `.` (U+002E) — write `name[key]` for collection \
access, or quote the word to use it as a literal string"
);
}
if self.kind == NameErrorKind::AmbiguousAscii {
return write!(
f,
"variable name contains `{}` (U+{:04X}) — an ASCII name is letters, \
digits, and `_`; quote the word to use it as a literal string",
self.ch, self.ch as u32
);
}
let what = match self.kind {
NameErrorKind::Whitespace => "whitespace",
NameErrorKind::Invisible => "an invisible character",
NameErrorKind::AmbiguousAscii | NameErrorKind::DottedName => {
unreachable!("handled above")
}
NameErrorKind::NotAnIdentifier => "a character that is not a letter, digit, or emoji",
};
write!(
f,
"variable name contains {what} (U+{:04X}) — quote the word to use it \
as a literal string",
self.ch as u32
)
}
}
const INVISIBLE: &[char] = &[
'\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200c}', '\u{200e}', '\u{200f}', '\u{2028}', '\u{2029}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2060}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{feff}', ];
const ZWJ: char = '\u{200d}';
const VARIATION_SELECTORS: [char; 2] = ['\u{fe0e}', '\u{fe0f}'];
fn is_emoji(c: char) -> bool {
matches!(c as u32,
0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF )
}
pub fn is_name_start(c: char) -> bool {
c == '_' || unicode_ident::is_xid_start(c) || is_emoji(c)
}
pub fn is_name_continue(c: char) -> bool {
unicode_ident::is_xid_continue(c)
|| is_emoji(c)
|| c == ZWJ
|| VARIATION_SELECTORS.contains(&c)
}
pub fn validate(name: &str) -> Result<(), NameError> {
if name == "$" || name == "?" {
return Ok(());
}
let mut previous: Option<char> = None;
for (i, c) in name.chars().enumerate() {
if c.is_ascii() {
if c == '.' {
return Err(NameError { ch: c, kind: NameErrorKind::DottedName });
}
if !(c.is_ascii_alphanumeric() || c == '_') {
return Err(NameError { ch: c, kind: NameErrorKind::AmbiguousAscii });
}
previous = Some(c);
continue;
}
if c.is_whitespace() {
return Err(NameError { ch: c, kind: NameErrorKind::Whitespace });
}
if INVISIBLE.contains(&c) {
return Err(NameError { ch: c, kind: NameErrorKind::Invisible });
}
if c == ZWJ || VARIATION_SELECTORS.contains(&c) {
match previous {
Some(p) if is_emoji(p) => {}
_ => return Err(NameError { ch: c, kind: NameErrorKind::Invisible }),
}
previous = Some(c);
continue;
}
let legal = if i == 0 { is_name_start(c) } else { is_name_continue(c) };
if !legal {
return Err(NameError { ch: c, kind: NameErrorKind::NotAnIdentifier });
}
previous = Some(c);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MixedScript {
pub name: String,
pub ch: char,
pub script: &'static str,
pub other_script: &'static str,
pub reads_as: Option<String>,
}
impl MixedScript {
pub fn suggestion(&self) -> String {
match &self.reads_as {
Some(plain) => format!("write the name in one script, e.g. `{plain}`"),
None => "write the name in one script".to_string(),
}
}
}
impl fmt::Display for MixedScript {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "`{}` mixes {} and {}", self.name, self.script, self.other_script)?;
if let Some(plain) = &self.reads_as {
write!(f, " and reads as `{plain}`")?;
}
write!(
f,
" — `{}` (U+{:04X}) is {}, so this names a different variable",
self.ch, self.ch as u32, self.other_script
)
}
}
pub fn mixed_script(name: &str) -> Option<MixedScript> {
let mut resolved = AugmentedScriptSet::default();
let mut without_latin = AugmentedScriptSet::default();
for c in name.chars() {
let set = AugmentedScriptSet::for_char(c);
if set.is_empty() {
continue;
}
resolved.intersect_with(set);
if !set.base.contains_script(Script::Latin) {
without_latin.intersect_with(set);
}
}
if !resolved.is_empty() {
return None;
}
if without_latin.jpan || without_latin.hanb || without_latin.kore {
return None;
}
let spelled: Vec<(char, Script)> = name
.chars()
.map(|c| (c, c.script()))
.filter(|(_, s)| !matches!(s, Script::Common | Script::Inherited | Script::Unknown))
.collect();
let mut tally: Vec<(Script, usize)> = Vec::new();
for (_, s) in &spelled {
match tally.iter_mut().find(|(t, _)| t == s) {
Some(entry) => entry.1 += 1,
None => tally.push((*s, 1)),
}
}
let (mut main_script, mut best) = *tally.first()?;
for &(script, count) in &tally[1..] {
if count > best {
main_script = script;
best = count;
}
}
let (ch, other) = spelled.into_iter().find(|&(_, s)| s != main_script)?;
let plain: String = skeleton(name).collect();
let reads_as = (plain.is_ascii() && plain != name).then_some(plain);
Some(MixedScript {
name: name.to_string(),
ch,
script: main_script.full_name(),
other_script: other.full_name(),
reads_as,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visible_names_in_any_script_are_accepted() {
for name in ["v", "_x", "café", "名前", "Ω", "переменная", "x1", "😁", "x😁", "👨\u{200d}👩", "❤\u{fe0f}"] {
assert!(validate(name).is_ok(), "{name:?} should be a legal name");
}
}
#[test]
fn whitespace_that_looks_like_a_word_break_is_refused() {
for (name, ch) in [("a\u{a0}b", '\u{a0}'), ("a\u{3000}b", '\u{3000}')] {
let err = validate(name).expect_err("should be refused");
assert_eq!(err.ch, ch);
assert_eq!(err.kind, NameErrorKind::Whitespace);
}
}
#[test]
fn invisible_characters_are_refused() {
for name in ["a\u{200b}b", "a\u{202e}b", "a\u{200c}b", "a\u{feff}b", "a\u{ad}b"] {
let err = validate(name).expect_err("{name:?} should be refused");
assert_eq!(err.kind, NameErrorKind::Invisible, "for {name:?}");
}
}
#[test]
fn joiners_are_refused_away_from_emoji() {
for name in ["a\u{200d}b", "\u{200d}x", "a\u{fe0f}"] {
let err = validate(name).expect_err("should be refused");
assert_eq!(err.kind, NameErrorKind::Invisible, "for {name:?}");
}
}
#[test]
fn punctuation_and_symbols_are_not_identifiers() {
for name in ["a«b", "a→b", "a⌘b", "a▪b"] {
assert!(validate(name).is_err(), "{name:?} should be refused");
}
}
#[test]
fn single_script_names_are_not_mixed() {
for name in [
"v", "_x", "x1", "café", "名前", "Ω", "переменная", "😁", "x😁", "👨\u{200d}👩",
"❤\u{fe0f}", "$", "?",
] {
assert_eq!(mixed_script(name), None, "{name:?} is one script");
}
}
#[test]
fn latin_with_japanese_is_not_mixed() {
for name in ["変数x", "x変数", "カタカナ1", "名前_v2"] {
assert_eq!(mixed_script(name), None, "{name:?} is Highly Restrictive");
}
}
#[test]
fn latin_with_cyrillic_is_mixed() {
let found = mixed_script("PАTH").expect("PАTH mixes scripts");
assert_eq!(found.ch, '\u{0410}');
assert_eq!(found.script, "Latin");
assert_eq!(found.other_script, "Cyrillic");
assert_eq!(found.reads_as.as_deref(), Some("PATH"));
let text = found.to_string();
assert!(text.contains("U+0410"), "got: {text}");
assert!(text.contains("Cyrillic"), "got: {text}");
assert!(text.contains("`PATH`"), "got: {text}");
}
#[test]
fn the_minority_script_is_the_one_named() {
let found = mixed_script("Аbc").expect("Аbc mixes scripts");
assert_eq!(found.ch, '\u{0410}');
assert_eq!(found.other_script, "Cyrillic");
}
#[test]
fn latin_with_greek_is_mixed_without_a_plain_reading() {
let found = mixed_script("Ωmega").expect("Ωmega mixes scripts");
assert_eq!(found.ch, 'Ω');
assert_eq!(found.other_script, "Greek");
assert_eq!(found.reads_as, None);
assert!(!found.to_string().contains("reads as"), "{found}");
}
#[test]
fn a_mixed_script_name_still_validates() {
assert!(validate("PАTH").is_ok());
}
#[test]
fn the_message_names_the_codepoint() {
let err = validate("a\u{a0}b").expect_err("refused");
let text = err.to_string();
assert!(text.contains("U+00A0"), "got: {text}");
assert!(text.contains("quote"), "the message must say what to do: {text}");
}
}