Skip to main content

oxc_markdown_parser/syntax/
label.rs

1//! Link label normalization (CommonMark's "matches" relation),
2//! micromark's `normalizeIdentifier` equivalence:
3//!
4//! ```js
5//! value
6//!   .replace(/[\t\n\r ]+/g, ' ')
7//!   .replace(/^ | $/g, '')
8//!   .toLowerCase()
9//!   .toUpperCase()
10//! ```
11//!
12//! Only markdown whitespace collapses (never NBSP / U+3000),
13//! and the fold is lowercase-then-uppercase
14//! (unifies ß/ẞ/SS, σ/ς, ff/FF, which lowercasing alone does not).
15//! The key here is that fold lowercased once more:
16//! it induces the same equivalence (`f∘g == f` holds for every char, checked exhaustively),
17//! keeps ordinary lowercase labels on the borrowing fast path,
18//! and is exactly the id micromark's footnote HTML derives (`normalizeIdentifier(x).toLowerCase()`).
19//! An opaque key for equality, not display text.
20//! Public because resolving reference links from `Definition` nodes needs
21//! the same normalization the parser used.
22
23use std::borrow::Cow;
24
25/// Borrow-through fast path for labels that are already in key form.
26///
27/// Lookups happen per reference candidate.
28/// Unlike [`crate::decode`]'s coarse trigger bytes,
29/// `is_normalized` is an exact mirror of the building loop below,
30/// the `idempotent` test pins the two in lockstep.
31pub fn normalize(raw: &str) -> Cow<'_, str> {
32    if is_normalized(raw) {
33        return Cow::Borrowed(raw);
34    }
35    let mut out = String::with_capacity(raw.len());
36    let mut pending_space = false;
37    for c in raw.chars() {
38        if is_markdown_space(c) {
39            pending_space = true;
40            continue;
41        }
42        if pending_space && !out.is_empty() {
43            out.push(' ');
44        }
45        pending_space = false;
46        if c.is_ascii() {
47            out.push(c.to_ascii_lowercase());
48        } else {
49            out.extend(fold(c));
50        }
51    }
52    Cow::Owned(out)
53}
54
55/// JS `[\t\n\r ]`.
56fn is_markdown_space(c: char) -> bool {
57    matches!(c, ' ' | '\t' | '\n' | '\r')
58}
59
60/// `c.toLowerCase().toUpperCase().toLowerCase()`; for ASCII this is `to_ascii_lowercase`.
61fn fold(c: char) -> impl Iterator<Item = char> {
62    c.to_lowercase().flat_map(char::to_uppercase).flat_map(char::to_lowercase)
63}
64
65/// Whether trim/collapse/fold would all be no-ops.
66fn is_normalized(raw: &str) -> bool {
67    // Leading/trailing space would be trimmed; a space run would collapse.
68    if raw.starts_with(' ') || raw.ends_with(' ') || raw.contains("  ") {
69        return false;
70    }
71    raw.chars().all(|c| match c {
72        ' ' => true,
73        c if is_markdown_space(c) => false,
74        c if c.is_ascii() => !c.is_ascii_uppercase(),
75        c => {
76            let mut folded = fold(c);
77            folded.next() == Some(c) && folded.next().is_none()
78        }
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::normalize;
85    use std::borrow::Cow;
86
87    /// The fast path must agree with the building path:
88    /// a borrowed result means "already normalized", so normalizing again must be a no-op,
89    /// and an owned result must itself be in normalized (borrowed) form.
90    #[test]
91    fn idempotent() {
92        for raw in [
93            "",
94            "a",
95            "a b",
96            "toc",
97            "ß",
98            "ẞ",
99            "ss",
100            "A",
101            "Толпой",
102            "толпой",
103            " a",
104            "a ",
105            "a  b",
106            "a\tb",
107            "a\nb",
108            "é",
109            "É",
110            "漢字",
111            "ff",
112        ] {
113            let once = normalize(raw);
114            let twice = normalize(&once);
115            assert_eq!(once, twice, "not idempotent for {raw:?}");
116            assert!(matches!(twice, Cow::Borrowed(_)), "owned twice for {raw:?}");
117        }
118    }
119
120    #[test]
121    fn matches_relation() {
122        assert_eq!(normalize("  A  B\t"), "a b");
123        assert_eq!(normalize("ẞ"), "ss");
124        assert_eq!(normalize("ß"), "ss");
125        assert_eq!(normalize("ТОЛПОЙ"), normalize("Толпой"));
126        // micromark's fold unifies these; lowercasing alone would not
127        assert_eq!(normalize("σ"), normalize("ς"));
128        assert_eq!(normalize("ff"), normalize("ff"));
129        // Only markdown whitespace collapses or trims
130        assert_ne!(normalize("a\u{3000}b"), normalize("a b"));
131        assert_eq!(normalize("\u{a0}"), "\u{a0}");
132        assert!(matches!(normalize("a b"), Cow::Borrowed(_)));
133        assert!(matches!(normalize("A b"), Cow::Owned(_)));
134        assert!(matches!(normalize("a  b"), Cow::Owned(_)));
135        assert!(matches!(normalize("ß"), Cow::Owned(_)));
136    }
137}