use crate::byte_scan::AsciiSet;
use alloc::string::String;
static WHITESPACE: AsciiSet = AsciiSet::new(b" \t\r\n");
pub fn normalize_identifier(value: &str) -> String {
let mut result = String::with_capacity(value.len());
normalize_identifier_into(value, &mut result);
result
}
pub(crate) fn normalize_identifier_into(value: &str, result: &mut String) {
collapse_whitespace_into(value, result);
if result.is_ascii() {
result.make_ascii_uppercase();
} else {
*result = fold_unicode_identifier(result);
}
}
pub(crate) fn normalize_reference_identifier_into(value: &str, result: &mut String) {
collapse_whitespace_into(value, result);
if result.is_ascii() {
result.make_ascii_lowercase();
} else {
*result = fold_unicode_identifier(result);
result.make_ascii_lowercase();
}
}
fn fold_unicode_identifier(value: &str) -> String {
if value
.chars()
.any(|character| !character.is_ascii() && character.is_uppercase())
{
value.to_lowercase().to_uppercase()
} else {
value.to_uppercase()
}
}
fn collapse_whitespace_into(value: &str, result: &mut String) {
result.clear();
let mut remaining = value;
while !remaining.is_empty() {
let end = WHITESPACE.find(remaining.as_bytes());
if end > 0 {
if !result.is_empty() {
result.push(' ');
}
result.push_str(&remaining[..end]);
}
remaining = &remaining[end..];
let padding = remaining
.bytes()
.take_while(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
.count();
remaining = &remaining[padding..];
}
}
#[cfg(test)]
mod tests {
use alloc::string::String;
#[test]
fn unicode_folding_matches_string_casing_for_every_scalar() {
let mut value = String::new();
let mut result = String::new();
for character in '\0'..=char::MAX {
value.clear();
value.push(character);
let expected = if matches!(character, ' ' | '\t' | '\r' | '\n') {
String::new()
} else {
value.to_lowercase().to_uppercase()
};
super::normalize_identifier_into(&value, &mut result);
assert_eq!(result, expected, "{character:?}");
super::normalize_reference_identifier_into(&value, &mut result);
assert_eq!(result, expected.to_ascii_lowercase());
}
}
#[test]
fn contextual_casing_and_expansion() {
let mut result = String::from("stale scratch");
for value in [
"ΟΣ",
"ΟΣΑ",
"ΟΣ\u{301}",
"ΣΣ",
"Σ\u{345}Σ",
"Σ'Σ",
" Σ\tΣ\r\nΣ ",
"ẞ ß SS",
"İ ı I i",
"ϴ θ Θ",
"ffi ǰ ΐ",
"a\u{a0}B\u{2003}Σ",
] {
let collapsed = value
.split([' ', '\t', '\r', '\n'])
.filter(|part| !part.is_empty())
.collect::<alloc::vec::Vec<_>>()
.join(" ");
let expected = collapsed.to_lowercase().to_uppercase();
super::normalize_identifier_into(value, &mut result);
assert_eq!(result, expected);
super::normalize_reference_identifier_into(value, &mut result);
assert_eq!(result, expected.to_ascii_lowercase());
}
}
#[test]
fn labels_preserve_collapsed_spaces_and_unicode_folding() {
assert_eq!(super::normalize_identifier(" a\tb\r\nc "), "A B C");
assert_eq!(super::normalize_identifier(""), "");
assert_eq!(super::normalize_identifier("a b"), "A B");
assert_ne!(
super::normalize_identifier("a b"),
super::normalize_identifier("ab")
);
assert_eq!(super::normalize_identifier("ẞ ϴ"), "SS Θ");
}
#[test]
fn labels_collapse_only_markdown_whitespace() {
assert_eq!(super::normalize_identifier(" \t\r\n "), "");
assert_eq!(
super::normalize_identifier("a\u{a0}b\u{2003}c"),
"A\u{a0}B\u{2003}C"
);
assert_eq!(super::normalize_identifier("İ\tẞ\nϴ"), "I\u{307} SS Θ");
}
#[test]
fn labels_scan_whole_runs_across_chunk_boundaries() {
for length in [15, 16, 17, 31, 32, 33, 63, 64, 65] {
let word = "a".repeat(length);
let value = alloc::format!("\t{word}\r\n \t{word}\n");
let expected = alloc::format!("{} {}", word.to_uppercase(), word.to_uppercase());
let mut result = alloc::string::String::from("old value");
super::normalize_identifier_into(&value, &mut result);
assert_eq!(result, expected);
}
}
}