pub fn slugify(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let mut result = String::with_capacity(input.len());
let mut prev_was_separator = true;
#[inline]
fn push_word_start_if_needed(buf: &mut String, prev_sep: &mut bool) {
if *prev_sep && !buf.is_empty() {
buf.push('-');
}
*prev_sep = false;
}
#[inline]
fn push_token(token: &str, buf: &mut String, prev_sep: &mut bool) {
if !buf.is_empty() {
buf.push('-');
}
buf.push_str(token);
*prev_sep = true;
}
for ch in input.chars() {
match ch {
'€' => push_token("euro", &mut result, &mut prev_was_separator),
'@' => push_token("at", &mut result, &mut prev_was_separator),
'&' => push_token("et", &mut result, &mut prev_was_separator),
'%' => push_token("pourcent", &mut result, &mut prev_was_separator),
'œ' | 'Œ' => {
push_word_start_if_needed(&mut result, &mut prev_was_separator);
result.push_str("oe");
}
'æ' | 'Æ' => {
push_word_start_if_needed(&mut result, &mut prev_was_separator);
result.push_str("ae");
}
'«' | '»' | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' | '`' | '´' => {
prev_was_separator = true;
}
',' | ';' | '!' | '.' | ':' | '?' | '-' | '_' | '/' | '\\' | '(' | ')' | '[' | ']' | '{' | '}' | '–' | '—' => {
prev_was_separator = true;
}
ch if ch.is_ascii_alphanumeric() => {
push_word_start_if_needed(&mut result, &mut prev_was_separator);
let c = if ch.is_ascii_alphabetic() {
ch.to_ascii_lowercase()
} else {
ch
};
result.push(c);
}
ch if ch.is_whitespace() => {
prev_was_separator = true;
}
other => {
let normalized = remove_accents(other);
if !normalized.is_empty() {
push_word_start_if_needed(&mut result, &mut prev_was_separator);
result.push_str(normalized);
} else {
prev_was_separator = true;
}
}
}
}
while result.ends_with('-') {
result.pop();
}
result
}
fn remove_accents(ch: char) -> &'static str {
match ch {
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => "a",
'è' | 'é' | 'ê' | 'ë' | 'È' | 'É' | 'Ê' | 'Ë' => "e",
'ì' | 'í' | 'î' | 'ï' | 'Ì' | 'Í' | 'Î' | 'Ï' => "i",
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => "o",
'ù' | 'ú' | 'û' | 'ü' | 'Ù' | 'Ú' | 'Û' | 'Ü' => "u",
'ý' | 'ÿ' | 'Ý' | 'Ÿ' => "y",
'ç' | 'Ç' => "c",
'ñ' | 'Ñ' => "n",
'ß' | 'ẞ' => "ss",
'đ' | 'Đ' => "d",
'ł' | 'Ł' => "l",
'ø' | 'Ø' => "o",
'þ' | 'Þ' => "th",
'ð' | 'Ð' => "d",
_ => "",
}
}
pub trait Slugify {
fn slugify(&self) -> String;
}
impl Slugify for str {
#[inline]
fn slugify(&self) -> String {
slugify(self)
}
}
impl Slugify for String {
#[inline]
fn slugify(&self) -> String {
slugify(self)
}
}
impl<'a> Slugify for std::borrow::Cow<'a, str> {
#[inline]
fn slugify(&self) -> String {
slugify(self.as_ref())
}
}
#[cfg(feature = "serde")]
pub mod serde {
use crate::slugify;
use ::serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &str, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let slug = slugify(value);
serializer.serialize_str(&slug)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(slugify(&s))
}
}
#[cfg(test)]
mod tests {
use crate::{slugify, Slugify};
use std::borrow::Cow;
#[test]
fn slugify_on_str() {
assert_eq!("Un cœur en Hiver".slugify(), "un-coeur-en-hiver");
}
#[test]
fn slugify_on_string() {
let s = String::from("Café de l'été");
assert_eq!(s.slugify(), "cafe-de-l-ete");
}
#[test]
fn slugify_on_cow() {
let cow: Cow<str> = Cow::from("L'æther & l'€");
assert_eq!(cow.slugify(), "l-aether-et-l-euro");
}
#[test]
fn it_works() {
assert_eq!(slugify(" céCi - » est $ ' (un.œuf.?"), "ceci-est-un-oeuf")
}
#[test]
fn with_emphasis() {
assert_eq!(slugify("un-paté-de-pâtes"), "un-pate-de-pates")
}
#[test]
fn with_spaces() {
assert_eq!(
slugify("trop d'espace entre les mots"),
"trop-d-espace-entre-les-mots"
)
}
#[test]
fn with_emphasis_and_space() {
assert_eq!(slugify("un paté de pâtes"), "un-pate-de-pates")
}
#[test]
fn with_trademark() {
assert_eq!(slugify("du nutella® et plus"), "du-nutella-et-plus")
}
#[test]
fn test_empty_string() {
assert_eq!(slugify(""), "");
}
#[test]
fn test_only_spaces() {
assert_eq!(slugify(" "), "");
assert_eq!(slugify("\t\n\r"), "");
}
#[test]
fn test_ligatures() {
assert_eq!(slugify("cœur"), "coeur");
assert_eq!(slugify("Cœur"), "coeur");
assert_eq!(slugify("æther"), "aether");
assert_eq!(slugify("Æther"), "aether");
assert_eq!(slugify("Un cœur et de l'æther"), "un-coeur-et-de-l-aether");
}
#[test]
fn test_special_characters_semantic() {
assert_eq!(slugify("Prix: 10€"), "prix-10-euro");
assert_eq!(slugify("Tom & Jerry"), "tom-et-jerry");
assert_eq!(slugify("contact@example.com"), "contact-at-example-com");
assert_eq!(slugify("50% de réduction"), "50-pourcent-de-reduction");
}
#[test]
fn test_typographic_quotes() {
assert_eq!(slugify("«guillemets»"), "guillemets");
assert_eq!(slugify("\u{201C}smart quotes\u{201D}"), "smart-quotes");
assert_eq!(slugify("\u{2018}apostrophes\u{2019}"), "apostrophes");
assert_eq!(slugify("`backticks`"), "backticks");
}
#[test]
fn test_leading_trailing_separators() {
assert_eq!(slugify("--test--"), "test");
assert_eq!(slugify("___test___"), "test");
assert_eq!(slugify(" test "), "test");
assert_eq!(slugify("...test..."), "test");
}
#[test]
fn test_multiple_consecutive_separators() {
assert_eq!(slugify("a---b___c...d"), "a-b-c-d");
assert_eq!(slugify("test with spaces"), "test-with-spaces");
}
#[test]
fn test_unicode_edge_cases() {
assert_eq!(slugify("café🎉test"), "cafe-test");
assert_eq!(slugify("émoji 🚀 spatial"), "emoji-spatial");
assert_eq!(slugify("texte avec ñ et ç"), "texte-avec-n-et-c");
}
#[test]
fn test_mixed_case_and_accents() {
assert_eq!(slugify("CAFÉ de L'ÉTÉ"), "cafe-de-l-ete");
assert_eq!(slugify("Un CŒUR en Hiver"), "un-coeur-en-hiver");
}
#[test]
fn test_numbers_and_letters() {
assert_eq!(slugify("test123"), "test123");
assert_eq!(slugify("version 2.0"), "version-2-0");
assert_eq!(slugify("HTML5 & CSS3"), "html5-et-css3");
}
#[test]
fn test_complex_french_text() {
assert_eq!(
slugify("L'œuvre d'art coûte 1500€ & représente l'æther"),
"l-oeuvre-d-art-coute-1500-euro-et-represente-l-aether"
);
}
#[test]
fn test_very_long_string() {
let long_input = "a".repeat(1000);
let result = slugify(&long_input);
assert_eq!(result, long_input);
assert_eq!(result.len(), 1000);
}
#[test]
fn test_alternating_chars_and_separators() {
assert_eq!(slugify("a-b-c-d-e"), "a-b-c-d-e");
assert_eq!(slugify("a b c d e"), "a-b-c-d-e");
assert_eq!(slugify("a_b_c_d_e"), "a-b-c-d-e");
}
#[test]
fn test_edge_case_single_chars() {
assert_eq!(slugify("a"), "a");
assert_eq!(slugify("é"), "e");
assert_eq!(slugify("œ"), "oe");
assert_eq!(slugify("€"), "euro");
assert_eq!(slugify(" "), "");
assert_eq!(slugify("-"), "");
}
}