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}