1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::borrow::Cow;
5use std::fmt;
6
7use unicode_general_category::{GeneralCategory, get_general_category};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SlugConfig {
12 pub replacement_delimiter: char,
14 pub lowercase_enabled: bool,
16 pub max_slug_chars: Option<usize>,
18 pub allowed_character_set: AllowedCharacterSet,
20 pub dot_handling_policy: DotHandlingPolicy,
22 pub transliteration_policy: TransliterationPolicy,
24 pub validation_policy: SlugValidationPolicy,
26 pub empty_output_policy: EmptyOutputPolicy,
28}
29
30impl Default for SlugConfig {
31 fn default() -> Self {
32 Self {
33 replacement_delimiter: '-',
34 lowercase_enabled: true,
35 max_slug_chars: None,
36 allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
37 dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
38 transliteration_policy: TransliterationPolicy::None,
39 validation_policy: SlugValidationPolicy::None,
40 empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AllowedCharacterSet {
48 UnicodeAlphanumericCharacters,
50 AsciiAlphanumericCharacters,
52 UnicodeLettersAndDecimalDigits,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DotHandlingPolicy {
59 ReplaceAllDots,
61 PreserveAllDots,
63 PreserveDotsBetweenDecimalDigits,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TransliterationPolicy {
70 None,
72 StaticReplacementMap(&'static [(&'static str, &'static str)]),
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum SlugValidationPolicy {
81 None,
83 LocalPathSegment,
85 UrlPathSegment,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum EmptyOutputPolicy {
92 KeepEmptySlug,
94 UseFallbackSlug(String),
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct SlugResult {
101 pub slug: String,
103 pub changed_from_input: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum SlugError {
110 EmptyTransliterationPattern,
112 EmptyPathSegment,
114 PathSegmentSeparator { character: char },
116 PathSegmentWhitespace { character: char },
118 PathSegmentControlCharacter { character: char },
120 PathSegmentDotValue,
122 UrlPathSegmentReservedCharacter { character: char },
124}
125
126impl fmt::Display for SlugError {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 match self {
129 Self::EmptyTransliterationPattern => {
130 write!(f, "transliteration patterns must not be empty")
131 }
132 Self::EmptyPathSegment => write!(f, "path segment must not be empty"),
133 Self::PathSegmentSeparator { character } => {
134 write!(f, "path segment must not contain separator `{character}`")
135 }
136 Self::PathSegmentWhitespace { character } => {
137 write!(f, "path segment must not contain whitespace `{character}`")
138 }
139 Self::PathSegmentControlCharacter { character } => {
140 write!(
141 f,
142 "path segment must not contain control character U+{:04X}",
143 *character as u32
144 )
145 }
146 Self::PathSegmentDotValue => write!(f, "path segment must not be `.` or `..`"),
147 Self::UrlPathSegmentReservedCharacter { character } => write!(
148 f,
149 "URL path segment must not contain reserved character `{character}`"
150 ),
151 }
152 }
153}
154
155impl std::error::Error for SlugError {}
156
157pub fn slugify(input: &str, config: &SlugConfig) -> Result<SlugResult, SlugError> {
181 let transliterated = apply_transliteration(input, config.transliteration_policy)?;
182 let filtered = filter_chars(&transliterated, config);
183 let trimmed = filtered.trim_matches(config.replacement_delimiter);
184 let lowered = if config.lowercase_enabled {
185 trimmed.to_lowercase()
186 } else {
187 trimmed.to_string()
188 };
189 let truncated = match config.max_slug_chars {
190 Some(max_slug_chars) => {
191 truncate_chars(lowered, max_slug_chars, config.replacement_delimiter)
192 }
193 None => lowered,
194 };
195 let slug = match (&config.empty_output_policy, truncated.is_empty()) {
196 (EmptyOutputPolicy::UseFallbackSlug(fallback), true) => fallback.clone(),
197 _ => truncated,
198 };
199
200 validate_slug(&slug, config.validation_policy)?;
201
202 Ok(SlugResult {
203 changed_from_input: slug != input,
204 slug,
205 })
206}
207
208pub fn validate_slug(value: &str, policy: SlugValidationPolicy) -> Result<(), SlugError> {
210 match policy {
211 SlugValidationPolicy::None => Ok(()),
212 SlugValidationPolicy::LocalPathSegment => validate_local_path_segment(value),
213 SlugValidationPolicy::UrlPathSegment => validate_url_path_segment(value),
214 }
215}
216
217fn apply_transliteration(
218 input: &str,
219 policy: TransliterationPolicy,
220) -> Result<Cow<'_, str>, SlugError> {
221 let map = match policy {
222 TransliterationPolicy::None => return Ok(Cow::Borrowed(input)),
223 TransliterationPolicy::StaticReplacementMap(map) => map,
224 };
225
226 if map.iter().any(|(pattern, _)| pattern.is_empty()) {
227 return Err(SlugError::EmptyTransliterationPattern);
228 }
229
230 let mut output = String::with_capacity(input.len());
231 let mut remaining = input;
232 while !remaining.is_empty() {
233 if let Some((pattern, replacement)) = map
234 .iter()
235 .filter(|(pattern, _)| remaining.starts_with(*pattern))
236 .max_by_key(|(pattern, _)| pattern.len())
237 {
238 output.push_str(replacement);
239 remaining = &remaining[pattern.len()..];
240 continue;
241 }
242
243 let Some(ch) = remaining.chars().next() else {
244 break;
245 };
246 output.push(ch);
247 remaining = &remaining[ch.len_utf8()..];
248 }
249
250 Ok(Cow::Owned(output))
251}
252
253fn filter_chars(input: &str, config: &SlugConfig) -> String {
254 let mut output = String::with_capacity(input.len());
255 let mut previous: Option<char> = None;
256 let mut chars = input.chars().peekable();
257
258 while let Some(ch) = chars.next() {
259 if is_allowed(ch, config.allowed_character_set) {
260 output.push(ch);
261 } else if ch == '.'
262 && should_preserve_dot(previous, chars.peek().copied(), config.dot_handling_policy)
263 {
264 output.push('.');
265 } else {
266 push_replacement_delimiter(&mut output, config.replacement_delimiter);
267 }
268 previous = Some(ch);
269 }
270
271 output
272}
273
274fn push_replacement_delimiter(output: &mut String, replacement_delimiter: char) {
275 if output.is_empty() || output.ends_with(replacement_delimiter) {
276 return;
277 }
278 output.push(replacement_delimiter);
279}
280
281fn truncate_chars(mut value: String, max_chars: usize, replacement_delimiter: char) -> String {
286 if let Some((byte_index, _)) = value.char_indices().nth(max_chars) {
287 value.truncate(byte_index);
288 }
289 while value.ends_with(replacement_delimiter) {
290 value.pop();
291 }
292 value
293}
294
295fn should_preserve_dot(
296 previous: Option<char>,
297 next: Option<char>,
298 policy: DotHandlingPolicy,
299) -> bool {
300 match policy {
301 DotHandlingPolicy::ReplaceAllDots => false,
302 DotHandlingPolicy::PreserveAllDots => true,
303 DotHandlingPolicy::PreserveDotsBetweenDecimalDigits => {
304 matches!(
305 (previous, next),
306 (Some(previous), Some(next))
307 if is_unicode_decimal_digit(previous) && is_unicode_decimal_digit(next)
308 )
309 }
310 }
311}
312
313fn is_allowed(ch: char, allowed_character_set: AllowedCharacterSet) -> bool {
314 match allowed_character_set {
315 AllowedCharacterSet::UnicodeAlphanumericCharacters => ch.is_alphanumeric(),
316 AllowedCharacterSet::AsciiAlphanumericCharacters => ch.is_ascii_alphanumeric(),
317 AllowedCharacterSet::UnicodeLettersAndDecimalDigits => {
318 is_unicode_letter(ch) || is_unicode_decimal_digit(ch)
319 }
320 }
321}
322
323fn is_unicode_letter(ch: char) -> bool {
324 if ch.is_ascii() {
325 return ch.is_ascii_alphabetic();
326 }
327 matches!(
328 get_general_category(ch),
329 GeneralCategory::UppercaseLetter
330 | GeneralCategory::LowercaseLetter
331 | GeneralCategory::TitlecaseLetter
332 | GeneralCategory::ModifierLetter
333 | GeneralCategory::OtherLetter
334 )
335}
336
337fn is_unicode_decimal_digit(ch: char) -> bool {
338 if ch.is_ascii() {
339 return ch.is_ascii_digit();
340 }
341 get_general_category(ch) == GeneralCategory::DecimalNumber
342}
343
344fn validate_local_path_segment(value: &str) -> Result<(), SlugError> {
345 if value.is_empty() {
346 return Err(SlugError::EmptyPathSegment);
347 }
348 if value == "." || value == ".." {
349 return Err(SlugError::PathSegmentDotValue);
350 }
351
352 for ch in value.chars() {
353 match ch {
354 '/' | '\\' => return Err(SlugError::PathSegmentSeparator { character: ch }),
355 _ if ch.is_whitespace() => {
356 return Err(SlugError::PathSegmentWhitespace { character: ch });
357 }
358 _ if ch.is_control() => {
359 return Err(SlugError::PathSegmentControlCharacter { character: ch });
360 }
361 _ => {}
362 }
363 }
364
365 Ok(())
366}
367
368fn validate_url_path_segment(value: &str) -> Result<(), SlugError> {
369 validate_local_path_segment(value)?;
370
371 for ch in value.chars() {
372 if matches!(ch, '?' | '#' | '%') {
373 return Err(SlugError::UrlPathSegmentReservedCharacter { character: ch });
374 }
375 }
376
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn slug(input: &str, config: &SlugConfig) -> Result<String, SlugError> {
385 slugify(input, config).map(|result| result.slug)
386 }
387
388 fn unicode_local_path_config() -> SlugConfig {
389 SlugConfig {
390 replacement_delimiter: '-',
391 lowercase_enabled: true,
392 max_slug_chars: None,
393 allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
394 dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
395 transliteration_policy: TransliterationPolicy::None,
396 validation_policy: SlugValidationPolicy::LocalPathSegment,
397 empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
398 }
399 }
400
401 fn url_path_segment_config() -> SlugConfig {
402 SlugConfig {
403 replacement_delimiter: '-',
404 lowercase_enabled: true,
405 max_slug_chars: None,
406 allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
407 dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
408 transliteration_policy: TransliterationPolicy::None,
409 validation_policy: SlugValidationPolicy::UrlPathSegment,
410 empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
411 }
412 }
413
414 fn ascii_local_path_config_with_fallback() -> SlugConfig {
415 SlugConfig {
416 replacement_delimiter: '-',
417 lowercase_enabled: true,
418 max_slug_chars: None,
419 allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
420 dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
421 transliteration_policy: TransliterationPolicy::None,
422 validation_policy: SlugValidationPolicy::LocalPathSegment,
423 empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
424 }
425 }
426
427 #[test]
428 fn default_config_is_minimal_and_keeps_empty() {
429 let config = SlugConfig::default();
430
431 assert_eq!(
432 slugify("", &config),
433 Ok(SlugResult {
434 slug: String::new(),
435 changed_from_input: false,
436 })
437 );
438 assert_eq!(slug("!!!", &config), Ok(String::new()));
439 }
440
441 #[test]
442 fn unicode_local_path_segment_examples_pass_when_non_empty() {
443 let config = unicode_local_path_config();
444
445 assert_eq!(
446 slug("現在的Nobody,未來的Somebody!", &config),
447 Ok("現在的nobody-未來的somebody".to_string())
448 );
449 assert_eq!(
450 slug("牙好,胃口就好,身体倍儿棒,吃嘛嘛香。", &config),
451 Ok("牙好-胃口就好-身体倍儿棒-吃嘛嘛香".to_string())
452 );
453 assert_eq!(
454 slug("お元気ですか?", &config),
455 Ok("お元気ですか".to_string())
456 );
457 assert_eq!(
458 slug("Ubuntu 16.04", &config),
459 Ok("ubuntu-16-04".to_string())
460 );
461 }
462
463 #[test]
464 fn local_path_segment_validation_rejects_empty_slug_after_keep_empty() {
465 assert_eq!(
466 slug("!!!", &unicode_local_path_config()),
467 Err(SlugError::EmptyPathSegment)
468 );
469 }
470
471 #[test]
472 fn url_path_segment_examples_pass() {
473 let config = url_path_segment_config();
474
475 assert_eq!(
476 slug("Ubuntu 16.04", &config),
477 Ok("ubuntu-16.04".to_string())
478 );
479 assert_eq!(
480 slug("T.U.S.F.G.E.3.0.8", &config),
481 Ok("t-u-s-f-g-e-3.0.8".to_string())
482 );
483 assert_eq!(
484 slug(".18 increased ! ", &config),
485 Ok("18-increased".to_string())
486 );
487 assert_eq!(
488 slug("お元気ですか?", &config),
489 Ok("お元気ですか".to_string())
490 );
491 }
492
493 #[test]
494 fn ascii_local_path_segment_examples_pass_with_configured_fallback() {
495 let config = ascii_local_path_config_with_fallback();
496
497 assert_eq!(slug("Hello 世界", &config), Ok("hello".to_string()));
498 assert_eq!(
499 slug("Ubuntu 16.04", &config),
500 Ok("ubuntu-16-04".to_string())
501 );
502 assert_eq!(slug("你好,世界", &config), Ok("fallback".to_string()));
503 }
504
505 #[test]
506 fn preserve_all_dots_keeps_every_dot() {
507 let config = SlugConfig {
508 dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
509 ..SlugConfig::default()
510 };
511
512 assert_eq!(slug("A.B..C", &config), Ok("a.b..c".to_string()));
513 }
514
515 #[test]
516 fn ascii_character_set_removes_non_ascii_letters() {
517 let config = SlugConfig {
518 allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
519 ..SlugConfig::default()
520 };
521
522 assert_eq!(slug("Cafe 世界 42", &config), Ok("cafe-42".to_string()));
523 }
524
525 #[test]
526 fn unicode_letters_decimal_digits_excludes_letter_numbers() {
527 let config = SlugConfig {
528 allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
529 ..SlugConfig::default()
530 };
531
532 assert_eq!(slug("Chapter \u{2163}", &config), Ok("chapter".to_string()));
533 }
534
535 #[test]
536 fn unicode_alphanumeric_keeps_letter_numbers() {
537 let config = SlugConfig {
538 allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
539 ..SlugConfig::default()
540 };
541
542 assert_eq!(
543 slug("Chapter \u{2163}", &config),
544 Ok("chapter-\u{2173}".to_string())
545 );
546 }
547
548 #[test]
549 fn static_transliteration_runs_before_filtering() {
550 static MAP: &[(&str, &str)] = &[("Æ", "AE"), ("東京", "Tokyo")];
551 let config = SlugConfig {
552 allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
553 transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
554 ..SlugConfig::default()
555 };
556
557 assert_eq!(slug("Æther 東京", &config), Ok("aether-tokyo".to_string()));
558 }
559
560 #[test]
561 fn transliteration_prefers_the_longest_match() {
562 static MAP: &[(&str, &str)] = &[("a", "1"), ("abc", "9")];
563 let config = SlugConfig {
564 transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
565 ..SlugConfig::default()
566 };
567
568 assert_eq!(slug("abc", &config), Ok("9".to_string()));
571 assert_eq!(slug("ax", &config), Ok("1x".to_string()));
572 }
573
574 #[test]
575 fn empty_transliteration_pattern_is_rejected() {
576 static MAP: &[(&str, &str)] = &[("", "x")];
577 let config = SlugConfig {
578 transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
579 ..SlugConfig::default()
580 };
581
582 assert_eq!(
583 slugify("anything", &config),
584 Err(SlugError::EmptyTransliterationPattern)
585 );
586 }
587
588 #[test]
589 fn max_slug_chars_runs_after_lowercase_and_can_trigger_fallback() {
590 let lower_before_max = SlugConfig {
591 max_slug_chars: Some(1),
592 ..SlugConfig::default()
593 };
594 assert_eq!(slug("\u{0130}", &lower_before_max), Ok("i".to_string()));
595
596 let fallback_after_max = SlugConfig {
597 max_slug_chars: Some(0),
598 empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
599 ..SlugConfig::default()
600 };
601 assert_eq!(slug("abc", &fallback_after_max), Ok("fallback".to_string()));
602 }
603
604 #[test]
605 fn truncation_strips_trailing_delimiter_the_cut_exposes() {
606 let config = SlugConfig {
607 max_slug_chars: Some(6),
608 ..SlugConfig::default()
609 };
610 assert_eq!(slug("hello world", &config), Ok("hello".to_string()));
612
613 let mid_word = SlugConfig {
615 max_slug_chars: Some(4),
616 ..SlugConfig::default()
617 };
618 assert_eq!(slug("hello world", &mid_word), Ok("hell".to_string()));
619
620 let after_delimiter = SlugConfig {
622 max_slug_chars: Some(2),
623 ..SlugConfig::default()
624 };
625 assert_eq!(slug("a bb cc", &after_delimiter), Ok("a".to_string()));
626 }
627
628 #[test]
629 fn validation_runs_after_fallback() {
630 let valid_fallback = ascii_local_path_config_with_fallback();
631 assert_eq!(slug("你好", &valid_fallback), Ok("fallback".to_string()));
632
633 let invalid_fallback = SlugConfig {
634 empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("bad/fallback".to_string()),
635 validation_policy: SlugValidationPolicy::LocalPathSegment,
636 ..SlugConfig::default()
637 };
638 assert_eq!(
639 slug("!!!", &invalid_fallback),
640 Err(SlugError::PathSegmentSeparator { character: '/' })
641 );
642 }
643
644 #[test]
645 fn no_validation_accepts_raw_unsafe_value() {
646 assert_eq!(validate_slug("", SlugValidationPolicy::None), Ok(()));
647 assert_eq!(validate_slug("../x", SlugValidationPolicy::None), Ok(()));
648 }
649
650 #[test]
651 fn local_path_segment_validation_rejects_unsafe_values() {
652 assert_eq!(
653 validate_slug("", SlugValidationPolicy::LocalPathSegment),
654 Err(SlugError::EmptyPathSegment)
655 );
656 assert_eq!(
657 validate_slug("a/b", SlugValidationPolicy::LocalPathSegment),
658 Err(SlugError::PathSegmentSeparator { character: '/' })
659 );
660 assert_eq!(
661 validate_slug("a\\b", SlugValidationPolicy::LocalPathSegment),
662 Err(SlugError::PathSegmentSeparator { character: '\\' })
663 );
664 assert_eq!(
665 validate_slug("a b", SlugValidationPolicy::LocalPathSegment),
666 Err(SlugError::PathSegmentWhitespace { character: ' ' })
667 );
668 assert_eq!(
669 validate_slug("a\u{0007}b", SlugValidationPolicy::LocalPathSegment),
670 Err(SlugError::PathSegmentControlCharacter {
671 character: '\u{0007}'
672 })
673 );
674 assert_eq!(
675 validate_slug(".", SlugValidationPolicy::LocalPathSegment),
676 Err(SlugError::PathSegmentDotValue)
677 );
678 assert_eq!(
679 validate_slug("..", SlugValidationPolicy::LocalPathSegment),
680 Err(SlugError::PathSegmentDotValue)
681 );
682 }
683
684 #[test]
685 fn url_path_segment_validation_rejects_url_delimiters_and_raw_percent() {
686 assert_eq!(
687 validate_slug("a?b", SlugValidationPolicy::UrlPathSegment),
688 Err(SlugError::UrlPathSegmentReservedCharacter { character: '?' })
689 );
690 assert_eq!(
691 validate_slug("a#b", SlugValidationPolicy::UrlPathSegment),
692 Err(SlugError::UrlPathSegmentReservedCharacter { character: '#' })
693 );
694 assert_eq!(
695 validate_slug("a%b", SlugValidationPolicy::UrlPathSegment),
696 Err(SlugError::UrlPathSegmentReservedCharacter { character: '%' })
697 );
698 assert_eq!(
699 validate_slug("a/b", SlugValidationPolicy::UrlPathSegment),
700 Err(SlugError::PathSegmentSeparator { character: '/' })
701 );
702 assert_eq!(
703 validate_slug("safe-現在-16.04", SlugValidationPolicy::UrlPathSegment),
704 Ok(())
705 );
706 }
707}