#[derive(Debug, Clone)]
pub struct Slugifier {
separator: String,
to_lowercase: bool,
truncate: Option<usize>,
}
impl Default for Slugifier {
fn default() -> Self {
Self {
separator: "-".to_string(),
to_lowercase: true,
truncate: None,
}
}
}
impl Slugifier {
pub fn new() -> Self {
Self::default()
}
pub fn separator(mut self, separator: &str) -> Self {
self.separator = separator.to_string();
self
}
pub fn to_lowercase(mut self, lowercase: bool) -> Self {
self.to_lowercase = lowercase;
self
}
pub fn truncate(mut self, max_length: usize) -> Self {
self.truncate = Some(max_length);
self
}
pub fn apply_truncation(&self, slug: &mut String) {
if let Some(max_len) = self.truncate {
if slug.len() > max_len {
if !self.separator.is_empty() {
if let Some(last_sep_index) = slug[..max_len].rfind(&self.separator) {
slug.truncate(last_sep_index);
return;
}
}
slug.truncate(max_len);
}
}
}
pub fn slugify(&self, text: &str) -> String {
let text = any_ascii::any_ascii(text);
let mut slug = String::with_capacity(text.len());
let mut last_char_was_separator = true;
for c in text.chars() {
if c.is_alphanumeric() {
if self.to_lowercase {
slug.push(c.to_ascii_lowercase());
} else {
slug.push(c);
}
last_char_was_separator = false;
} else {
if !last_char_was_separator {
slug.push_str(&self.separator);
last_char_was_separator = true;
}
}
}
if slug.ends_with(&self.separator) {
slug.truncate(slug.len() - self.separator.len());
}
self.apply_truncation(&mut slug);
slug
}
pub fn slugify_ascii(&self, text: &[u8]) -> String {
let mut slug = String::new();
let mut just_sep = true;
for &c in text {
if c.is_ascii_alphanumeric() {
slug.push(if self.to_lowercase {
c.to_ascii_lowercase()
} else {
c
} as char);
just_sep = false;
} else if !just_sep {
slug.push_str(&self.separator);
just_sep = true;
}
}
if slug.ends_with(&self.separator) {
slug.truncate(slug.len() - self.separator.len());
}
self.apply_truncation(&mut slug);
slug
}
}
#[macro_export]
macro_rules! slugify {
($text:expr) => {
$crate::Slugifier::new().slugify($text)
};
}
#[macro_export]
macro_rules! slugify_ascii {
($text:expr) => {
$crate::Slugifier::new().slugify_ascii($text)
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_slug() {
assert_eq!(slugify!("Hello World"), "hello-world");
}
#[test]
fn test_with_punctuation() {
assert_eq!(
slugify!("Hello, World! This is a test... 123?"),
"hello-world-this-is-a-test-123"
);
}
#[test]
fn test_unicode_slug() {
assert_eq!(slugify!("你好世界 & Rust"), "nihaoshijie-rust");
}
#[test]
fn test_leading_and_trailing_hyphens() {
assert_eq!(slugify!("--leading and trailing--"), "leading-and-trailing");
}
#[test]
fn test_multiple_hyphens() {
assert_eq!(slugify!("multiple---hyphens"), "multiple-hyphens");
}
#[test]
fn test_custom_separator() {
let slugifier = Slugifier::new().separator("_");
assert_eq!(slugifier.slugify("custom separator"), "custom_separator");
}
#[test]
fn test_no_lowercase() {
let slugifier = Slugifier::new().to_lowercase(false);
assert_eq!(slugifier.slugify("No Lowercase"), "No-Lowercase");
}
#[test]
fn test_empty_string() {
assert_eq!(slugify!(""), "");
}
#[test]
fn test_ascii_only() {
let slugifier = Slugifier::new();
assert_eq!(slugifier.slugify_ascii(b"Hello, World!"), "hello-world");
assert_eq!(
slugifier.slugify_ascii(b"Slugs are slow, but cool"),
"slugs-are-slow-but-cool"
);
}
#[test]
fn test_slugify_ascii_no_lowercase() {
let slugifier = Slugifier::new().to_lowercase(false);
assert_eq!(slugifier.slugify_ascii(b"Keep-Case"), "Keep-Case");
}
#[test]
fn test_truncation_at_word_boundary() {
let slugifier = Slugifier::new().truncate(20);
let text = "this is a very long title that should be shortened";
assert_eq!(slugifier.slugify(text), "this-is-a-very-long");
}
#[test]
fn test_truncation_with_no_separator() {
let slugifier = Slugifier::new().truncate(20);
let text = "supercalifragilisticexpialidocious";
assert_eq!(slugifier.slugify(text), "supercalifragilistic");
}
#[test]
fn test_truncation_not_needed() {
let slugifier = Slugifier::new().truncate(50);
let text = "this title is short enough";
assert_eq!(slugifier.slugify(text), "this-title-is-short-enough");
}
#[test]
fn test_truncation_on_ascii_slug() {
let slugifier = Slugifier::new().truncate(15);
let text = b"An ASCII title that is long";
assert_eq!(slugifier.slugify_ascii(text), "an-ascii-title");
}
}