pulldown_cmark_toc/
slug.rs

1use std::{borrow::Cow, collections::HashMap, sync::LazyLock};
2
3use regex::Regex;
4
5/// A trait to specify the anchor calculation.
6pub trait Slugify {
7    fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str>;
8}
9
10/// A slugifier that attempts to mimic GitHub's behavior.
11///
12/// Unfortunately GitHub's behavior is not documented anywhere by GitHub.
13/// This should really be part of the [GitHub Flavored Markdown Spec][gfm]
14/// but alas it's not. And there also does not appear to be a public issue
15/// tracker for the spec where that issue could be raised.
16///
17/// [gfm]: https://github.github.com/gfm/
18#[derive(Default)]
19pub struct GitHubSlugifier {
20    counts: HashMap<String, i32>,
21}
22
23impl Slugify for GitHubSlugifier {
24    fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str> {
25        static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\w\- ]").unwrap());
26        let anchor = RE
27            .replace_all(&str.to_lowercase().replace(' ', "-"), "")
28            .into_owned();
29
30        let i = self
31            .counts
32            .entry(anchor.clone())
33            .and_modify(|i| *i += 1)
34            .or_insert(0);
35
36        match *i {
37            0 => anchor,
38            i => format!("{}-{}", anchor, i),
39        }
40        .into()
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use crate::slug::{GitHubSlugifier, Slugify};
47    use crate::Heading;
48    use pulldown_cmark::CowStr::Borrowed;
49    use pulldown_cmark::Event::{Code, Text};
50    use pulldown_cmark::{HeadingLevel, Parser};
51
52    #[test]
53    fn heading_anchor_with_code() {
54        let heading = Heading {
55            events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
56            level: HeadingLevel::H1,
57        };
58        assert_eq!(
59            GitHubSlugifier::default().slugify(&heading.text()),
60            "another-heading"
61        );
62    }
63
64    #[test]
65    fn heading_anchor_with_links() {
66        let events = Parser::new("Here [TOML](https://toml.io)").collect();
67        let heading = Heading {
68            events,
69            level: HeadingLevel::H1,
70        };
71        assert_eq!(
72            GitHubSlugifier::default().slugify(&heading.text()),
73            "here-toml"
74        );
75    }
76
77    #[test]
78    fn github_slugger_non_ascii_lowercase() {
79        assert_eq!(GitHubSlugifier::default().slugify("Привет"), "привет");
80    }
81}