Skip to main content

dmc_parser/
slugger.rs

1//! GitHub-style heading-anchor slug generator. Mirrors npm `github-slugger`
2//! (used by `rehype-slug`): punctuation is stripped, NOT replaced, so
3//! `0.4.3` -> `043` and `It's` -> `its` (matches velite output).
4//!
5//! For document-scoped dedupe (`#patch-changes`, `#patch-changes-1`, ...)
6//! use [`Slugger`].
7
8use std::collections::HashMap;
9
10/// Compute the GitHub-style slug, ignoring dedupe. For document-wide
11/// dedupe, use [`Slugger::slug`].
12pub fn github_slugify(input: &str) -> String {
13  let lower = input.trim().to_lowercase();
14  let mut out = String::with_capacity(lower.len());
15  let mut prev_dash = false;
16  for ch in lower.chars() {
17    if ch.is_control() {
18      continue;
19    }
20    if ch.is_whitespace() {
21      if !prev_dash && !out.is_empty() {
22        out.push('-');
23        prev_dash = true;
24      }
25      continue;
26    }
27    // github-slugger "strip, don't replace": drop anything that isn't
28    // alphanumeric / `_` / `-`. Existing `-` collapses with whitespace runs.
29    if ch.is_alphanumeric() || ch == '_' || ch == '-' {
30      if ch == '-' {
31        if prev_dash {
32          continue;
33        }
34        out.push('-');
35        prev_dash = true;
36      } else {
37        out.push(ch);
38        prev_dash = false;
39      }
40    }
41  }
42  while out.ends_with('-') {
43    out.pop();
44  }
45  out
46}
47
48/// Document-scoped slugger. Collisions get a `-1`, `-2`, ... suffix.
49#[derive(Debug, Default)]
50pub struct Slugger {
51  seen: HashMap<String, u32>,
52}
53
54impl Slugger {
55  pub fn new() -> Self {
56    Self { seen: HashMap::new() }
57  }
58
59  /// Slug for `text`, with `-N` suffix on the N+1th collision. Empty input
60  /// -> empty string; dedupe still applies (`""`, then `"-1"`).
61  pub fn slug(&mut self, text: &str) -> String {
62    let base = github_slugify(text);
63    let count = self.seen.entry(base.clone()).or_insert(0);
64    let out = if *count == 0 { base.clone() } else { format!("{}-{}", base, *count) };
65    *count += 1;
66    out
67  }
68}
69
70#[cfg(test)]
71mod tests {
72  use super::*;
73
74  #[test]
75  fn strips_dots() {
76    assert_eq!(github_slugify("0.4.3"), "043");
77  }
78
79  #[test]
80  fn strips_apostrophes() {
81    assert_eq!(github_slugify("How It's Built"), "how-its-built");
82  }
83
84  #[test]
85  fn replaces_spaces_with_dash() {
86    assert_eq!(github_slugify("Patch Changes"), "patch-changes");
87  }
88
89  #[test]
90  fn collapses_runs() {
91    assert_eq!(github_slugify("Hello -- World"), "hello-world");
92    assert_eq!(github_slugify("foo   bar"), "foo-bar");
93  }
94
95  #[test]
96  fn dedupes() {
97    let mut s = Slugger::new();
98    assert_eq!(s.slug("Patch Changes"), "patch-changes");
99    assert_eq!(s.slug("Patch Changes"), "patch-changes-1");
100    assert_eq!(s.slug("Patch Changes"), "patch-changes-2");
101  }
102
103  #[test]
104  fn keeps_underscores_and_existing_dashes() {
105    assert_eq!(github_slugify("foo_bar-baz"), "foo_bar-baz");
106  }
107}