sansaccent 0.1.0

Convertit les chaînes françaises en slugs URL-friendly en supprimant accents et caractères spéciaux
Documentation
/// Convertit une chaîne de caractères en slug URL-friendly.
///
/// Cette fonction est particulièrement optimisée pour le français et :
/// - Supprime les accents et caractères diacritiques
/// - Convertit en minuscules
/// - Remplace les espaces et caractères spéciaux par des tirets
/// - Gère les ligatures françaises courantes (œ, æ)
/// - Normalise les espaces multiples
///
/// # Arguments
///
/// * `input` - La chaîne à convertir
///
/// # Exemples
///
/// ```
/// use sansaccent::slugify;
///
/// assert_eq!(slugify("Café de l'été"), "cafe-de-l-ete");
/// assert_eq!(slugify("Un cœur en hiver"), "un-coeur-en-hiver");
/// assert_eq!(slugify("L'æther & l'€"), "l-aether-et-l-euro");
/// ```
///
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 {
            // Mappages sémantiques
            '' => 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),

            // Ligatures françaises courantes
            'œ' | 'Œ' => {
                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");
            }

            // Guillemets et caractères typographiques - ignorés (séparateurs)
            '«' | '»' | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' | '`' | '´' => {
                prev_was_separator = true;
            }

            // Ponctuation courante considérée comme séparateur
            ',' | ';' | '!' | '.' | ':' | '?' | '-' | '_' | '/' | '\\' | '(' | ')' | '[' | ']' | '{' | '}' | '' | '' => {
                prev_was_separator = true;
            }

            // ASCII alphanumériques
            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);
            }

            // Espaces et séparateurs
            ch if ch.is_whitespace() => {
                prev_was_separator = true;
            }

            // Autres caractères - normalisation personnalisée
            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;
                }
            }
        }
    }

    // Supprime d'éventuels tirets en fin de chaîne
    while result.ends_with('-') {
        result.pop();
    }
    result
}

/// Supprime les accents d'un caractère en le convertissant vers sa forme de base
///
/// # Arguments
///
/// * `ch` - Le caractère à normaliser
///
/// # Retour
///
/// * `&'static str` - Représentation ASCII en minuscules du caractère sans accent
fn remove_accents(ch: char) -> &'static str {
    match ch {
        // Voyelles avec accents (minuscules et majuscules)
        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => "a",
        'è' | 'é' | 'ê' | 'ë' | 'È' | 'É' | 'Ê' | 'Ë' => "e",
        'ì' | 'í' | 'î' | 'ï' | 'Ì' | 'Í' | 'Î' | 'Ï' => "i",
        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => "o",
        'ù' | 'ú' | 'û' | 'ü' | 'Ù' | 'Ú' | 'Û' | 'Ü' => "u",
        'ý' | 'ÿ' | 'Ý' | 'Ÿ' => "y",

        // Consonnes avec accents (minuscules et majuscules)
        'ç' | 'Ç' => "c",
        'ñ' | 'Ñ' => "n",

        // Caractères spéciaux européens (et variantes majuscules)
        'ß' | '' => "ss",
        'đ' | 'Đ' => "d",
        'ł' | 'Ł' => "l",
        'ø' | 'Ø' => "o",
        'þ' | 'Þ' => "th",
        'ð' | 'Ð' => "d",

        // Inconnu ou non mappable vers ascii bas-de-casse
        _ => "",
    }
}

/// Trait d'extension permettant de convertir facilement une chaîne en slug URL-friendly.
///
/// Fournit la méthode `.slugify()` sur `&str`, `String` et `Cow<'_, str>`.
///
/// # Exemples
///
/// ```
/// use sansaccent::Slugify;
///
/// assert_eq!("Café de l'été".slugify(), "cafe-de-l-ete");
/// assert_eq!(String::from("Un cœur en hiver").slugify(), "un-coeur-en-hiver");
/// ```
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")]
/// Intégration Serde optionnelle pour transformer automatiquement les chaînes en slugs
///
/// Utilisation côté consommateur:
///
///   use serde::{Serialize, Deserialize};
///
///   #[derive(Serialize, Deserialize)]
///   struct Post {
///       // Le slug sera généré à la sérialisation et normalisé à la désérialisation
///       #[serde(with = "sansaccent::serde")]
///       slug: String,
///   }
pub mod serde {
    use crate::slugify;
    use ::serde::{Deserialize, Deserializer, Serializer};

    /// Sérialise une &str en slug normalisé via slugify
    pub fn serialize<S>(value: &str, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let slug = slugify(value);
        serializer.serialize_str(&slug)
    }

    /// Désérialise une String et la normalise en slug via slugify
    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("-"), "");
    }
}