slugify-core 0.1.0

Fast, Unicode-aware slug generation library with multi-language bindings
Documentation
use unicode_normalization::UnicodeNormalization;
use unicode_segmentation::UnicodeSegmentation;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

#[derive(Debug, Clone)]
pub struct SlugOptions {
    pub separator: char,
    pub max_length: Option<usize>,
    pub lowercase: bool,
    pub remove_stopwords: bool,
    pub ascii_only: bool,
}

impl Default for SlugOptions {
    fn default() -> Self {
        Self {
            separator: '-',
            max_length: None,
            lowercase: true,
            remove_stopwords: false,
            ascii_only: false,
        }
    }
}

const COMMON_STOPWORDS: &[&str] = &[
    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from",
    "has", "he", "in", "is", "it", "its", "of", "on", "that", "the",
    "to", "was", "will", "with", "the", "this", "but", "they", "have",
];

pub fn slugify(input: &str, options: &SlugOptions) -> String {
    if input.trim().is_empty() {
        return String::new();
    }

    let mut result = input
        .nfc()
        .collect::<String>()
        .unicode_words()
        .filter_map(|word| {
            let word = word.trim();
            if word.is_empty() {
                return None;
            }
            
            if options.remove_stopwords && COMMON_STOPWORDS.contains(&word.to_lowercase().as_str()) {
                return None;
            }
            
            let processed = if options.ascii_only {
                word.chars()
                    .filter_map(|c| {
                        if c.is_ascii_alphanumeric() {
                            Some(c)
                        } else if c.is_alphabetic() {
                            transliterate_char(c)
                        } else {
                            None
                        }
                    })
                    .collect::<String>()
            } else {
                word.chars()
                    .filter(|c| c.is_alphanumeric() || c.is_alphabetic())
                    .collect::<String>()
            };
            
            if processed.is_empty() {
                None
            } else {
                Some(processed)
            }
        })
        .collect::<Vec<_>>()
        .join(&options.separator.to_string());

    if options.lowercase {
        result = result.to_lowercase();
    }

    if let Some(max_len) = options.max_length {
        if result.len() > max_len {
            if let Some((idx, _)) = result.char_indices().nth(max_len) {
                result.truncate(idx);
            }
            
            while result.ends_with(options.separator) {
                result.pop();
            }
        }
    }

    result
}

fn transliterate_char(c: char) -> Option<char> {
    match c {
        'à'..='å' | 'À'..='Å' => Some('a'),
        'ç' | 'Ç' => Some('c'),
        'è'..='ë' | 'È'..='Ë' => Some('e'),
        'ì'..='ï' | 'Ì'..='Ï' => Some('i'),
        'ñ' | 'Ñ' => Some('n'),
        'ò'..='ö' | 'Ò'..='Ö' => Some('o'),
        'ù'..='ü' | 'Ù'..='Ü' => Some('u'),
        'ý' | 'ÿ' | 'Ý' => Some('y'),
        'ß' => Some('s'),
        _ => None,
    }
}

#[no_mangle]
pub extern "C" fn slugify_simple(input: *const c_char) -> *mut c_char {
    if input.is_null() {
        return std::ptr::null_mut();
    }
    
    let c_str = unsafe { CStr::from_ptr(input) };
    let input_str = match c_str.to_str() {
        Ok(s) => s,
        Err(_) => return std::ptr::null_mut(),
    };
    
    let options = SlugOptions::default();
    let result = slugify(input_str, &options);
    
    match CString::new(result) {
        Ok(c_string) => c_string.into_raw(),
        Err(_) => std::ptr::null_mut(),
    }
}

#[no_mangle]
pub extern "C" fn slugify_with_options(
    input: *const c_char,
    separator: c_char,
    max_length: i32,
    lowercase: bool,
    remove_stopwords: bool,
    ascii_only: bool,
) -> *mut c_char {
    if input.is_null() {
        return std::ptr::null_mut();
    }
    
    let c_str = unsafe { CStr::from_ptr(input) };
    let input_str = match c_str.to_str() {
        Ok(s) => s,
        Err(_) => return std::ptr::null_mut(),
    };
    
    let options = SlugOptions {
        separator: separator as u8 as char,
        max_length: if max_length > 0 { Some(max_length as usize) } else { None },
        lowercase,
        remove_stopwords,
        ascii_only,
    };
    
    let result = slugify(input_str, &options);
    
    match CString::new(result) {
        Ok(c_string) => c_string.into_raw(),
        Err(_) => std::ptr::null_mut(),
    }
}

#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
    if !ptr.is_null() {
        unsafe {
            let _ = CString::from_raw(ptr);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_basic_slugify() {
        let options = SlugOptions::default();
        assert_eq!(slugify("Hello World", &options), "hello-world");
        assert_eq!(slugify("Test 123", &options), "test-123");
    }

    #[test]
    fn test_unicode_handling() {
        let options = SlugOptions::default();
        assert_eq!(slugify("Café münü", &options), "café-münü");
        
        let ascii_options = SlugOptions { ascii_only: true, ..Default::default() };
        assert_eq!(slugify("Café münü", &ascii_options), "cafe-munu");
    }

    #[test]
    fn test_custom_separator() {
        let options = SlugOptions { separator: '_', ..Default::default() };
        assert_eq!(slugify("Hello World", &options), "hello_world");
    }

    #[test]
    fn test_max_length() {
        let options = SlugOptions { max_length: Some(10), ..Default::default() };
        assert_eq!(slugify("This is a very long title", &options), "this-is-a");
    }

    #[test]
    fn test_stopwords() {
        let options = SlugOptions { remove_stopwords: true, ..Default::default() };
        assert_eq!(slugify("The quick brown fox", &options), "quick-brown-fox");
    }

    #[test]
    fn test_empty_input() {
        let options = SlugOptions::default();
        assert_eq!(slugify("", &options), "");
        assert_eq!(slugify("   ", &options), "");
    }

    #[test]
    fn test_special_characters() {
        let options = SlugOptions::default();
        assert_eq!(slugify("Hello, World! @#$%", &options), "hello-world");
    }
}