Skip to main content

rumdl_lib/utils/
header_id_utils.rs

1//! Utilities for extracting custom header IDs from various Markdown flavors
2//!
3//! This module supports multiple syntax formats for custom header IDs:
4//!
5//! ## Kramdown Format
6//! - `{#custom-id}` - Simple ID without colon
7//! - Example: `# Header {#my-id}`
8//!
9//! ## Python-markdown attr-list Format
10//! - `{:#custom-id}` - ID with colon, no spaces
11//! - `{: #custom-id}` - ID with colon and spaces
12//! - `{: #custom-id .class}` - ID with classes
13//! - `{: #custom-id .class data="value"}` - ID with full attributes
14//! - Example: `# Header {: #my-id .highlight}`
15//!
16//! ## Position Support
17//! - Inline: `# Header {#id}` (all formats)
18//! - Next-line: Jekyll/kramdown style where attr-list appears on the line after the header
19//!   ```markdown
20//!   # Header
21//!   {#next-line-id}
22//!   ```
23//!
24//! ## HTML anchors
25//! - `<a id="custom-id"></a>` or `<a name="custom-id"></a>` beside the heading text
26//! - Example: `## <a name="my-id"></a>Header`
27//!
28//! An empty anchor element is stripped from the heading text but, unlike an
29//! attr-list ID, it does not replace the slug generated from the text: the
30//! rendered heading answers to both. Tags are read in source order as a browser
31//! tokenizes them, so an `<a>` written inside another tag's attribute value is
32//! part of that value. Anchor markup inside a code span or an HTML comment, or
33//! whose `<` is backslash-escaped, is heading text and defines nothing.
34//!
35//! The module provides functions to detect and extract IDs from both inline
36//! and standalone (next-line) attr-list syntax.
37
38use regex::Regex;
39use std::borrow::Cow;
40use std::sync::LazyLock;
41
42/// The name of an HTML tag: a letter, then letters, digits and hyphens.
43pub const HTML_TAG_NAME_PATTERN: &str = "[A-Za-z][A-Za-z0-9-]*";
44
45/// Attribute list of an inline HTML open tag as CommonMark defines it.
46///
47/// A quoted value may contain `>` without ending the tag, which is why a tag
48/// cannot be delimited by searching for the next `>`.
49pub const HTML_TAG_ATTRIBUTES_PATTERN: &str = r#"(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*"#;
50
51/// An inline HTML open tag, self-closing or not, with its name captured.
52pub static HTML_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
53    Regex::new(&format!(
54        r"<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
55    ))
56    .unwrap()
57});
58
59/// The name of a tag as a browser reads it inside raw HTML: a letter, then
60/// anything up to whitespace, `/` or `>`.
61pub const HTML_BLOCK_TAG_NAME_PATTERN: &str = r"[A-Za-z][^\s/>]*";
62
63/// An open tag inside an HTML block, where the browser's tokenizer rather than
64/// CommonMark's inline grammar decides what a tag is. Only the tag name is read
65/// the browser's way; attributes still follow the CommonMark grammar.
66pub static HTML_BLOCK_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
67    Regex::new(&format!(
68        r"<({HTML_BLOCK_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
69    ))
70    .unwrap()
71});
72
73/// The closing tag of an `<a>` element at the start of the text, with any
74/// whitespace before it.
75static HTML_ANCHOR_CLOSING_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*</a\s*>").unwrap());
76
77/// Pattern for custom header IDs supporting both kramdown and python-markdown attr-list formats
78/// Supports: {#id}, { #id }, {:#id}, {: #id } and full attr-list with classes/attributes
79/// Must contain #id but can have other attributes: {: #id .class data="value" }
80/// More conservative: only matches when there's actually a hash followed by valid ID characters
81static HEADER_ID_PATTERN: LazyLock<Regex> =
82    LazyLock::new(|| Regex::new(r"\s*\{\s*:?\s*([^}]*?#[^}]*?)\s*\}\s*$").unwrap());
83
84/// Pattern to validate that an ID contains only valid characters
85static ID_VALIDATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-:]+$").unwrap());
86
87/// Pattern for standalone attr-list lines (Jekyll/kramdown style on line after heading)
88/// Matches lines that are just attr-list syntax: {#id}, {: #id .class }, etc.
89static STANDALONE_ATTR_LIST_PATTERN: LazyLock<Regex> =
90    LazyLock::new(|| Regex::new(r"^\s*\{\s*:?\s*([^}]*#[a-zA-Z0-9_\-:]+[^}]*)\s*\}\s*$").unwrap());
91
92/// Extract custom header ID from a line if present, returning clean text and ID
93///
94/// Supports multiple formats:
95/// - Kramdown: `{#id}`
96/// - Python-markdown: `{:#id}`, `{: #id}`, `{: #id .class}`
97///
98/// # Examples
99/// ```
100/// use rumdl_lib::utils::header_id_utils::extract_header_id;
101///
102/// // Kramdown format
103/// let (text, id) = extract_header_id("# Header {#custom-id}");
104/// assert_eq!(text, "# Header");
105/// assert_eq!(id, Some("custom-id".to_string()));
106///
107/// // Python-markdown attr-list format
108/// let (text, id) = extract_header_id("# Header {: #my-id .highlight}");
109/// assert_eq!(text, "# Header");
110/// assert_eq!(id, Some("my-id".to_string()));
111/// ```
112pub fn extract_header_id(line: &str) -> (String, Option<String>) {
113    // An empty anchor element beside the text (`## <a name="foo"></a>Heading`) is
114    // markup, not heading text.
115    let line = strip_html_anchor_elements(line);
116    let line = line.as_ref();
117
118    if let Some(captures) = HEADER_ID_PATTERN.captures(line)
119        && let Some(full_match) = captures.get(0)
120        && let Some(attr_content) = captures.get(1)
121    {
122        let attr_str = attr_content.as_str().trim();
123
124        // First, find all potential ID matches in the attr-list
125        if let Some(hash_pos) = attr_str.find('#') {
126            // Extract everything after the hash
127            let after_hash = &attr_str[hash_pos + 1..];
128
129            // For simple cases like {#id}, the ID goes to the end
130            // For complex cases like {: #id .class}, we need to find where the ID ends
131
132            // First check if this looks like a simple kramdown ID: {#id} with no spaces or attributes
133            let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
134
135            if is_simple_format {
136                // Simple format: entire content after # should be the ID
137                let potential_id = after_hash;
138                if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
139                    let clean_text = line[..full_match.start()].trim_end().to_string();
140                    return (clean_text, Some(potential_id.to_string()));
141                }
142                // If validation fails, reject the entire attr-list
143            } else {
144                // Complex format: find proper delimiters (space for next attribute, dot for class)
145                if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
146                    let potential_id = &after_hash[..delimiter_pos];
147                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
148                        let clean_text = line[..full_match.start()].trim_end().to_string();
149                        return (clean_text, Some(potential_id.to_string()));
150                    }
151                } else {
152                    // No delimiter found in complex format, ID goes to end
153                    let potential_id = after_hash;
154                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
155                        let clean_text = line[..full_match.start()].trim_end().to_string();
156                        return (clean_text, Some(potential_id.to_string()));
157                    }
158                }
159            }
160        }
161    }
162    (line.to_string(), None)
163}
164
165/// Remove the empty `<a>` elements that give a heading its anchors.
166///
167/// Exactly the element's bytes go, so the whitespace beside it stays part of
168/// the text, as the browser shows it: `Foo<a id="x"></a> Bar` reads "Foo Bar".
169/// Whitespace left at either end goes too, as CommonMark strips it from a
170/// heading's content.
171fn strip_html_anchor_elements(text: &str) -> Cow<'_, str> {
172    let anchors = empty_anchor_elements(text);
173    if anchors.is_empty() {
174        return Cow::Borrowed(text);
175    }
176
177    let mut stripped = String::with_capacity(text.len());
178    let mut copied_up_to = 0;
179    for (range, _) in anchors {
180        stripped.push_str(&text[copied_up_to..range.start]);
181        copied_up_to = range.end;
182    }
183    stripped.push_str(&text[copied_up_to..]);
184    Cow::Owned(stripped.trim().to_string())
185}
186
187/// The targets of the empty `<a>` elements in `text`, in source order.
188///
189/// Each element contributes its `id`, or its legacy `name` when it has no `id`,
190/// so `<a name="old"></a><a id="new"></a>Heading` yields both. An element with
191/// neither attribute set is not a target.
192pub fn extract_html_anchor_ids(text: &str) -> Vec<String> {
193    empty_anchor_elements(text)
194        .into_iter()
195        .filter_map(|(_, open_tag)| html_tag_attribute(open_tag, "id").or_else(|| html_tag_attribute(open_tag, "name")))
196        .map(str::to_string)
197        .collect()
198}
199
200/// The empty `<a>` elements of `text` in source order, each as the byte range
201/// of the whole element and its open tag.
202///
203/// Tags are read the way a browser tokenizes them, one after another, so an
204/// `<a>` written inside another tag's attribute value is part of that value
205/// and no element. A tag inside a code span or an HTML comment, or whose `<`
206/// is backslash-escaped, is heading text; the scan resumes just past its `<`,
207/// since the text it was taken for may hold a real tag of its own.
208fn empty_anchor_elements(text: &str) -> Vec<(std::ops::Range<usize>, &str)> {
209    if !text.contains('<') {
210        return Vec::new();
211    }
212
213    let opaque = opaque_ranges(text);
214    let mut anchors = Vec::new();
215    let mut pos = 0;
216    while let Some(tag) = HTML_OPEN_TAG.captures_at(text, pos) {
217        let open_tag = tag.get(0).unwrap();
218        if is_within(&opaque, open_tag.start()) || is_backslash_escaped(text, open_tag.start()) {
219            pos = open_tag.start() + 1;
220            continue;
221        }
222        pos = open_tag.end();
223
224        if !tag[1].eq_ignore_ascii_case("a") {
225            continue;
226        }
227        if let Some(closing_tag) = HTML_ANCHOR_CLOSING_TAG.find(&text[open_tag.end()..]) {
228            pos = open_tag.end() + closing_tag.end();
229            anchors.push((open_tag.start()..pos, open_tag.as_str()));
230        }
231    }
232
233    anchors
234}
235
236/// Byte ranges of `text` that render as literal text: code spans and HTML comments.
237///
238/// A code span opens with a run of backticks and closes with a run of exactly
239/// the same length; an opener that never closes is literal backticks. A comment
240/// runs from `<!--` to the next `-->`, or to the end of the text; `<!-->` and
241/// `<!--->` are complete comments, so the search for the closer starts right
242/// after `<!`.
243fn opaque_ranges(text: &str) -> Vec<(usize, usize)> {
244    let bytes = text.as_bytes();
245    let mut ranges = Vec::new();
246    let mut pos = 0;
247
248    while pos < bytes.len() {
249        if bytes[pos] == b'`' {
250            let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
251            let run_len = run_end - pos;
252            match closing_backtick_run(bytes, run_end, run_len) {
253                Some(close_start) => {
254                    ranges.push((pos, close_start + run_len));
255                    pos = close_start + run_len;
256                }
257                None => pos = run_end,
258            }
259        } else if bytes[pos..].starts_with(b"<!--") {
260            let end = text[pos + 2..]
261                .find("-->")
262                .map_or(bytes.len(), |offset| pos + 2 + offset + 3);
263            ranges.push((pos, end));
264            pos = end;
265        } else {
266            pos += 1;
267        }
268    }
269
270    ranges
271}
272
273/// Start of the first backtick run of exactly `run_len` after `from`, if any.
274fn closing_backtick_run(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
275    let mut pos = from;
276    while pos < bytes.len() {
277        if bytes[pos] != b'`' {
278            pos += 1;
279            continue;
280        }
281        let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
282        if run_end - pos == run_len {
283            return Some(pos);
284        }
285        pos = run_end;
286    }
287    None
288}
289
290fn is_within(ranges: &[(usize, usize)], pos: usize) -> bool {
291    ranges.iter().any(|&(start, end)| start <= pos && pos < end)
292}
293
294/// Whether the character at byte `pos` of `text` is backslash-escaped.
295///
296/// CommonMark reads `\<` as a literal `<` and `\\<` as a literal backslash
297/// followed by a tag, so the character is escaped when an odd number of
298/// backslashes precede it.
299pub fn is_backslash_escaped(text: &str, pos: usize) -> bool {
300    text.as_bytes()[..pos].iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 1
301}
302
303/// The value of attribute `name` in the HTML open tag `tag`.
304///
305/// Attribute names are compared whole and without regard to case, so `data-id`
306/// never stands in for `id`. As in HTML, the first occurrence decides. An
307/// attribute that is absent, has no value or has an empty value yields `None`.
308pub fn html_tag_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
309    let bytes = tag.as_bytes();
310    if bytes.first() != Some(&b'<') {
311        return None;
312    }
313
314    let ends_name = |b: u8| b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/');
315    let mut pos = 1 + bytes[1..].iter().take_while(|&&b| !ends_name(b)).count();
316
317    loop {
318        pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
319        if pos >= bytes.len() || matches!(bytes[pos], b'>' | b'/') {
320            return None;
321        }
322
323        let name_start = pos;
324        pos += bytes[pos..].iter().take_while(|&&b| !ends_name(b)).count();
325        let attribute = &tag[name_start..pos];
326
327        let after_name = pos + bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
328        let value = if bytes.get(after_name) == Some(&b'=') {
329            let value_start = after_name
330                + 1
331                + bytes[after_name + 1..]
332                    .iter()
333                    .take_while(|b| b.is_ascii_whitespace())
334                    .count();
335            match bytes.get(value_start) {
336                Some(&quote @ (b'"' | b'\'')) => {
337                    let value_end = value_start + 1 + tag[value_start + 1..].find(quote as char)?;
338                    pos = value_end + 1;
339                    &tag[value_start + 1..value_end]
340                }
341                _ => {
342                    let value_end = value_start
343                        + bytes[value_start..]
344                            .iter()
345                            .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
346                            .count();
347                    pos = value_end;
348                    &tag[value_start..value_end]
349                }
350            }
351        } else {
352            ""
353        };
354
355        if attribute.eq_ignore_ascii_case(name) {
356            return (!value.is_empty()).then_some(value);
357        }
358    }
359}
360
361/// Check if a line is a standalone attr-list (Jekyll/kramdown style)
362///
363/// This detects attr-list syntax that appears on its own line, typically
364/// the line after a header to provide additional attributes.
365///
366/// # Examples
367/// ```
368/// use rumdl_lib::utils::header_id_utils::is_standalone_attr_list;
369///
370/// assert!(is_standalone_attr_list("{#custom-id}"));
371/// assert!(is_standalone_attr_list("{: #spaced .class }"));
372/// assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
373/// assert!(!is_standalone_attr_list(""));
374/// ```
375pub fn is_standalone_attr_list(line: &str) -> bool {
376    STANDALONE_ATTR_LIST_PATTERN.is_match(line)
377}
378
379/// Extract ID from a standalone attr-list line
380///
381/// Returns the ID if the line is a valid standalone attr-list with an ID.
382///
383/// # Examples
384/// ```
385/// use rumdl_lib::utils::header_id_utils::extract_standalone_attr_list_id;
386///
387/// assert_eq!(extract_standalone_attr_list_id("{#custom-id}"), Some("custom-id".to_string()));
388/// assert_eq!(extract_standalone_attr_list_id("{: #spaced .class }"), Some("spaced".to_string()));
389/// assert_eq!(extract_standalone_attr_list_id("not an attr-list"), None);
390/// ```
391pub fn extract_standalone_attr_list_id(line: &str) -> Option<String> {
392    if let Some(captures) = STANDALONE_ATTR_LIST_PATTERN.captures(line)
393        && let Some(attr_content) = captures.get(1)
394    {
395        let attr_str = attr_content.as_str().trim();
396
397        // Use the same logic as extract_header_id for consistency
398        if let Some(hash_pos) = attr_str.find('#') {
399            let after_hash = &attr_str[hash_pos + 1..];
400
401            // Check if this looks like a simple kramdown ID: {#id} with no spaces or attributes
402            let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
403
404            if is_simple_format {
405                // Simple format: entire content after # should be the ID
406                let potential_id = after_hash;
407                if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
408                    return Some(potential_id.to_string());
409                }
410            } else {
411                // Complex format: find proper delimiters (space for next attribute, dot for class)
412                if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
413                    let potential_id = &after_hash[..delimiter_pos];
414                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
415                        return Some(potential_id.to_string());
416                    }
417                } else {
418                    // No delimiter found in complex format, ID goes to end
419                    let potential_id = after_hash;
420                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
421                        return Some(potential_id.to_string());
422                    }
423                }
424            }
425        }
426    }
427    None
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_kramdown_format_extraction() {
436        // Simple kramdown format
437        let (text, id) = extract_header_id("# Header {#simple}");
438        assert_eq!(text, "# Header");
439        assert_eq!(id, Some("simple".to_string()));
440
441        let (text, id) = extract_header_id("## Section {#section-id}");
442        assert_eq!(text, "## Section");
443        assert_eq!(id, Some("section-id".to_string()));
444    }
445
446    #[test]
447    fn test_python_markdown_attr_list_extraction() {
448        // Python-markdown formats
449        let (text, id) = extract_header_id("# Header {:#colon-id}");
450        assert_eq!(text, "# Header");
451        assert_eq!(id, Some("colon-id".to_string()));
452
453        let (text, id) = extract_header_id("# Header {: #spaced-id }");
454        assert_eq!(text, "# Header");
455        assert_eq!(id, Some("spaced-id".to_string()));
456    }
457
458    #[test]
459    fn test_extended_attr_list_extraction() {
460        // ID with single class
461        let (text, id) = extract_header_id("# Header {: #with-class .highlight }");
462        assert_eq!(text, "# Header");
463        assert_eq!(id, Some("with-class".to_string()));
464
465        // ID with multiple classes
466        let (text, id) = extract_header_id("## Section {: #multi .class1 .class2 }");
467        assert_eq!(text, "## Section");
468        assert_eq!(id, Some("multi".to_string()));
469
470        // ID with key-value attributes
471        let (text, id) = extract_header_id("### Subsection {: #with-attrs data-test=\"value\" style=\"color: red\" }");
472        assert_eq!(text, "### Subsection");
473        assert_eq!(id, Some("with-attrs".to_string()));
474
475        // Complex combination
476        let (text, id) = extract_header_id("#### Complex {: #complex .highlight data-role=\"button\" title=\"Test\" }");
477        assert_eq!(text, "#### Complex");
478        assert_eq!(id, Some("complex".to_string()));
479
480        // ID with quotes in attributes
481        let (text, id) = extract_header_id("##### Quotes {: #quotes title=\"Has \\\"nested\\\" quotes\" }");
482        assert_eq!(text, "##### Quotes");
483        assert_eq!(id, Some("quotes".to_string()));
484    }
485
486    #[test]
487    fn test_attr_list_detection_edge_cases() {
488        // Attr-list without ID should not match
489        let (text, id) = extract_header_id("# Header {: .class-only }");
490        assert_eq!(text, "# Header {: .class-only }");
491        assert_eq!(id, None);
492
493        // Malformed attr-list should not match
494        let (text, id) = extract_header_id("# Header { no-hash }");
495        assert_eq!(text, "# Header { no-hash }");
496        assert_eq!(id, None);
497
498        // Empty ID should not match
499        let (text, id) = extract_header_id("# Header {: # }");
500        assert_eq!(text, "# Header {: # }");
501        assert_eq!(id, None);
502
503        // ID in middle (not at end) should not match
504        let (text, id) = extract_header_id("# Header {: #middle } with more text");
505        assert_eq!(text, "# Header {: #middle } with more text");
506        assert_eq!(id, None);
507    }
508
509    #[test]
510    fn test_standalone_attr_list_detection() {
511        // Simple ID formats
512        assert!(is_standalone_attr_list("{#custom-id}"));
513        assert!(is_standalone_attr_list("{ #spaced-id }"));
514        assert!(is_standalone_attr_list("{:#colon-id}"));
515        assert!(is_standalone_attr_list("{: #full-format }"));
516
517        // With classes and attributes
518        assert!(is_standalone_attr_list("{: #with-class .highlight }"));
519        assert!(is_standalone_attr_list("{: #multi .class1 .class2 }"));
520        assert!(is_standalone_attr_list("{: #complex .highlight data-test=\"value\" }"));
521
522        // Should not match
523        assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
524        assert!(!is_standalone_attr_list("Text before {#id}"));
525        assert!(!is_standalone_attr_list("{#id} text after"));
526        assert!(!is_standalone_attr_list(""));
527        assert!(!is_standalone_attr_list("   ")); // just spaces
528        assert!(!is_standalone_attr_list("{: .class-only }")); // no ID
529    }
530
531    #[test]
532    fn test_standalone_attr_list_id_extraction() {
533        // Basic formats
534        assert_eq!(extract_standalone_attr_list_id("{#simple}"), Some("simple".to_string()));
535        assert_eq!(
536            extract_standalone_attr_list_id("{ #spaced }"),
537            Some("spaced".to_string())
538        );
539        assert_eq!(extract_standalone_attr_list_id("{:#colon}"), Some("colon".to_string()));
540        assert_eq!(extract_standalone_attr_list_id("{: #full }"), Some("full".to_string()));
541
542        // With additional attributes
543        assert_eq!(
544            extract_standalone_attr_list_id("{: #with-class .highlight }"),
545            Some("with-class".to_string())
546        );
547        assert_eq!(
548            extract_standalone_attr_list_id("{: #complex .class1 .class2 data=\"value\" }"),
549            Some("complex".to_string())
550        );
551
552        // Should return None
553        assert_eq!(extract_standalone_attr_list_id("Not an attr-list"), None);
554        assert_eq!(extract_standalone_attr_list_id("Text {#not-standalone}"), None);
555        assert_eq!(extract_standalone_attr_list_id("{: .class-only }"), None);
556        assert_eq!(extract_standalone_attr_list_id(""), None);
557    }
558
559    #[test]
560    fn test_backward_compatibility() {
561        // Ensure all original kramdown formats still work
562        let test_cases = vec![
563            ("# Header {#a}", "# Header", Some("a".to_string())),
564            ("# Header {#simple-id}", "# Header", Some("simple-id".to_string())),
565            ("## Heading {#heading-2}", "## Heading", Some("heading-2".to_string())),
566            (
567                "### With-Hyphens {#with-hyphens}",
568                "### With-Hyphens",
569                Some("with-hyphens".to_string()),
570            ),
571        ];
572
573        for (input, expected_text, expected_id) in test_cases {
574            let (text, id) = extract_header_id(input);
575            assert_eq!(text, expected_text, "Text mismatch for input: {input}");
576            assert_eq!(id, expected_id, "ID mismatch for input: {input}");
577        }
578    }
579
580    #[test]
581    fn test_invalid_id_with_dots() {
582        // IDs with dots should not be extracted (dots are not valid ID characters)
583        let (text, id) = extract_header_id("## Another. {#id.with.dots}");
584        assert_eq!(text, "## Another. {#id.with.dots}"); // Should not strip invalid ID
585        assert_eq!(id, None); // Should not extract invalid ID
586
587        // Test that only the part before the dot would be extracted if it was valid standalone
588        // But since it's in an invalid format, the whole thing should be rejected
589        let (text, id) = extract_header_id("## Another. {#id.more.dots}");
590        assert_eq!(text, "## Another. {#id.more.dots}");
591        assert_eq!(id, None);
592    }
593
594    #[test]
595    fn test_html_anchor_stripping() {
596        // HTML anchor elements should be stripped from heading text
597        // This is used by some authors for custom anchors
598
599        // Basic <a name="..."></a> pattern
600        let (text, id) = extract_header_id("<a name=\"cheatsheets\"></a>Cheat Sheets");
601        assert_eq!(text, "Cheat Sheets");
602        assert_eq!(id, None);
603
604        // <a id="..."></a> pattern
605        let (text, id) = extract_header_id("<a id=\"tools\"></a>Tools and session management");
606        assert_eq!(text, "Tools and session management");
607        assert_eq!(id, None);
608
609        // With spaces around the anchor
610        let (text, id) = extract_header_id("<a name=\"foo\"></a> Heading with space");
611        assert_eq!(text, "Heading with space");
612        assert_eq!(id, None);
613
614        // Combined with kramdown custom ID
615        let (text, id) = extract_header_id("<a name=\"old\"></a>My Section {#my-custom-id}");
616        assert_eq!(text, "My Section");
617        assert_eq!(id, Some("my-custom-id".to_string()));
618    }
619
620    #[test]
621    fn test_html_anchor_ids_are_read_from_empty_anchor_elements_in_order() {
622        assert_eq!(extract_html_anchor_ids(r#"Heading<a id="target"></a>"#), ["target"]);
623        assert_eq!(
624            extract_html_anchor_ids(r#"<A class="legacy" NAME='fallback' ID='preferred'></A>Heading"#),
625            ["preferred"]
626        );
627        assert_eq!(
628            extract_html_anchor_ids(r#"<a name='legacy'></a><a id="newer"></a>Heading"#),
629            ["legacy", "newer"]
630        );
631        assert_eq!(extract_html_anchor_ids("<a id=plain></a>Heading"), ["plain"]);
632        assert!(extract_html_anchor_ids(r##"<a href="#target"></a>Heading"##).is_empty());
633        assert!(extract_html_anchor_ids(r#"<span id="target"></span>Heading"#).is_empty());
634        assert!(extract_html_anchor_ids(r#"<a id="target">text</a>Heading"#).is_empty());
635    }
636
637    #[test]
638    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
639        assert!(extract_html_anchor_ids(r#"Foo<a data-id="tracking" data-name="pixel"></a>"#).is_empty());
640        assert!(extract_html_anchor_ids(r#"Foo<a id=""></a>"#).is_empty());
641    }
642
643    #[test]
644    fn test_a_quoted_attribute_value_may_contain_a_closing_angle_bracket() {
645        let raw = r#"<a title="a > b" id="target"></a>Heading"#;
646        assert_eq!(extract_html_anchor_ids(raw), ["target"]);
647        assert_eq!(extract_header_id(raw), ("Heading".to_string(), None));
648    }
649
650    #[test]
651    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
652        let raw = "Showing `<a id=\"literal\"></a>` syntax";
653        assert!(extract_html_anchor_ids(raw).is_empty());
654        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
655
656        // A real anchor beside the code span is still found and stripped.
657        let raw = "Showing `<a id=\"literal\"></a>` syntax<a id=\"real\"></a>";
658        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
659        assert_eq!(extract_header_id(raw).0, "Showing `<a id=\"literal\"></a>` syntax");
660    }
661
662    #[test]
663    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
664        let raw = "Foo <!-- <a id=\"hidden\"></a> -->";
665        assert!(extract_html_anchor_ids(raw).is_empty());
666        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
667    }
668
669    #[test]
670    fn test_html_tag_attribute_matches_whole_names_case_insensitively() {
671        assert_eq!(html_tag_attribute(r#"<div data-id="x" ID="y">"#, "id"), Some("y"));
672        assert_eq!(html_tag_attribute("<a name = 'legacy' >", "name"), Some("legacy"));
673        assert_eq!(html_tag_attribute("<a id=plain>", "id"), Some("plain"));
674        assert_eq!(html_tag_attribute(r#"<a id="first" id="second">"#, "id"), Some("first"));
675        assert_eq!(html_tag_attribute(r#"<a id="">"#, "id"), None);
676        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "hidden"), None);
677        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "id"), Some("x"));
678        assert_eq!(html_tag_attribute(r#"<a title="a > b" id="x">"#, "id"), Some("x"));
679        assert_eq!(html_tag_attribute(r#"<video title="id=fake">"#, "id"), None);
680        assert_eq!(html_tag_attribute("<br/>", "id"), None);
681    }
682
683    #[test]
684    fn test_html_anchor_stripping_handles_attribute_variations() {
685        let (text, id) = extract_header_id(r#"<A class="legacy" ID='target'></A>Heading"#);
686        assert_eq!(text, "Heading");
687        assert_eq!(id, None);
688    }
689
690    #[test]
691    fn test_a_backslash_escaped_anchor_element_is_heading_text() {
692        // `\<` is a literal `<`, so the markup renders as text and defines no
693        // target. An escaped backslash before the `<` leaves it a tag.
694        let raw = r#"Show \<a id="example"></a> syntax"#;
695        assert!(extract_html_anchor_ids(raw).is_empty());
696        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
697
698        let raw = r#"Show \\<a id="example"></a> syntax"#;
699        assert_eq!(extract_html_anchor_ids(raw), ["example"]);
700        assert_eq!(extract_header_id(raw).0, r"Show \\ syntax");
701    }
702
703    #[test]
704    fn test_stripping_an_anchor_element_keeps_the_whitespace_beside_it() {
705        // The element renders nothing, so removing exactly its bytes leaves the
706        // text the browser shows: the space between the words stays. Whitespace
707        // left at either end goes, as CommonMark strips it from a heading.
708        assert_eq!(extract_header_id(r#"Foo<a id="alias"></a> Bar"#).0, "Foo Bar");
709        assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>Bar"#).0, "Foo Bar");
710        assert_eq!(extract_header_id(r#"<a id="alias"></a> Foo"#).0, "Foo");
711        assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>"#).0, "Foo");
712    }
713
714    #[test]
715    fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
716        // The `<a>` is part of the span's `title` value, so the browser creates
717        // no element from it and the heading keeps its bytes.
718        let raw = r#"<span title='<a id="fake"></a>'>Foo</span>"#;
719        assert!(extract_html_anchor_ids(raw).is_empty());
720        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
721
722        let raw = r#"<span title='x'><a id="real"></a>Foo</span>"#;
723        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
724        assert_eq!(extract_header_id(raw).0, "<span title='x'>Foo</span>");
725    }
726
727    #[test]
728    fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
729        // `\<span` is text, so the `<a>` where its attribute value would be is a
730        // real element.
731        let raw = r#"\<span title='<a id="real"></a>'>Foo"#;
732        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
733        assert_eq!(extract_header_id(raw).0, r#"\<span title=''>Foo"#);
734    }
735
736    #[test]
737    fn test_a_degenerate_comment_ends_at_its_own_closer() {
738        // `<!-->` and `<!--->` are complete comments, so the anchor after them is
739        // an element and the later `-->` is text.
740        assert_eq!(extract_html_anchor_ids(r#"<!--> <a id="x"></a> --> Foo"#), ["x"]);
741        assert_eq!(extract_html_anchor_ids(r#"<!---> <a id="y"></a> --> Foo"#), ["y"]);
742        assert!(extract_html_anchor_ids(r#"<!-- <a id="z"></a> --> Foo"#).is_empty());
743    }
744
745    #[test]
746    fn test_is_backslash_escaped_counts_the_run_of_backslashes() {
747        assert!(!is_backslash_escaped("<a>", 0));
748        assert!(is_backslash_escaped(r"\<a>", 1));
749        assert!(!is_backslash_escaped(r"\\<a>", 2));
750        assert!(is_backslash_escaped(r"\\\<a>", 3));
751        assert!(!is_backslash_escaped(r"x<a>", 1));
752    }
753}