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    let heading = extract_heading_text(line);
114    (heading.text, heading.custom_id)
115}
116
117/// The content of a heading split into what a reader sees, what a renderer
118/// slugs, and the custom ID its attribute list declares.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct HeadingText {
121    /// The text as a reader sees it: anchor elements and the attribute list
122    /// removed, one of the two spaces an element sat between gone with it, and
123    /// both ends trimmed.
124    pub text: String,
125    /// The text a slug is generated from: the same as `text`, except that every
126    /// space an anchor element leaves behind stays. A browser shows
127    /// `## Alpha <a id="x"></a>` as "Alpha" and `## Foo <a id="x"></a> Bar` as
128    /// "Foo Bar", but GitHub and kramdown slug them to `alpha-` and `foo--bar`;
129    /// each anchor style decides for itself whether to trim and collapse.
130    pub slug_text: String,
131    /// The ID from a `{#id}` or `{: #id}` attribute list, if any.
132    pub custom_id: Option<String>,
133}
134
135/// Split heading content into display text, slug text and custom ID.
136///
137/// See [`HeadingText`] for what each part holds. The attribute-list formats are
138/// those of [`extract_header_id`], which returns the display text and ID only.
139pub fn extract_heading_text(line: &str) -> HeadingText {
140    // An empty anchor element beside the text (`## <a name="foo"></a>Heading`) is
141    // markup, not heading text.
142    let (line, seams) = strip_html_anchor_elements(line);
143    let line = line.as_ref();
144
145    let (slug_text, custom_id) = match custom_id_at_end(line) {
146        Some((attr_list_start, id)) => (line[..attr_list_start].trim_end(), Some(id)),
147        None => (line, None),
148    };
149    HeadingText {
150        text: display_text(slug_text, &seams),
151        slug_text: slug_text.to_string(),
152        custom_id,
153    }
154}
155
156/// The ID declared by an attribute list that ends `line`, with the byte offset
157/// where the attribute list starts. An attribute list whose ID fails validation
158/// declares nothing and stays heading text.
159fn custom_id_at_end(line: &str) -> Option<(usize, String)> {
160    if !line.contains('{') {
161        return None;
162    }
163    let captures = HEADER_ID_PATTERN.captures(line)?;
164    let attr_list_start = captures.get(0)?.start();
165    let attr_str = captures.get(1)?.as_str().trim();
166    let hash_pos = attr_str.find('#')?;
167    let after_hash = &attr_str[hash_pos + 1..];
168
169    // In the simple kramdown form `{#id}` the ID runs to the end. In the full
170    // attr-list form `{: #id .class key="value"}` it ends at the next attribute
171    // (whitespace), class (dot) or value (equals sign).
172    let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
173    let potential_id = if is_simple_format {
174        after_hash
175    } else {
176        match after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
177            Some(delimiter_pos) => &after_hash[..delimiter_pos],
178            None => after_hash,
179        }
180    };
181
182    (!potential_id.is_empty() && ID_VALIDATE_PATTERN.is_match(potential_id))
183        .then(|| (attr_list_start, potential_id.to_string()))
184}
185
186/// Where the display text of `line` ends: before its trailing whitespace, the
187/// attribute list a custom ID sits in and an empty anchor element at the end,
188/// each stripped as the one after it exposes it. Zero when the line holds no
189/// display text.
190pub fn heading_text_end(line: &str) -> usize {
191    let mut end = line.len();
192    loop {
193        end = line[..end].trim_end().len();
194        if let Some((attr_list_start, _)) = custom_id_at_end(&line[..end]) {
195            end = attr_list_start;
196        } else if let Some((range, _)) = empty_anchor_elements(&line[..end]).pop()
197            && range.end == end
198        {
199            end = range.start;
200        } else {
201            return end;
202        }
203    }
204}
205
206/// Remove the empty `<a>` elements that give a heading its anchors.
207///
208/// Exactly the element's bytes go, so the whitespace beside it stays part of
209/// the text: `Foo<a id="x"></a> Bar` reads "Foo Bar", and whitespace left at
210/// either end stays for the slug, since GitHub and kramdown slug
211/// `Alpha <a id="x"></a>` as `#alpha-`. The offsets returned beside the text
212/// are where each element sat, for [`display_text`].
213fn strip_html_anchor_elements(text: &str) -> (Cow<'_, str>, Vec<usize>) {
214    let anchors = empty_anchor_elements(text);
215    if anchors.is_empty() {
216        return (Cow::Borrowed(text), Vec::new());
217    }
218
219    let mut stripped = String::with_capacity(text.len());
220    let mut seams = Vec::with_capacity(anchors.len());
221    let mut copied_up_to = 0;
222    for (range, _) in anchors {
223        stripped.push_str(&text[copied_up_to..range.start]);
224        seams.push(stripped.len());
225        copied_up_to = range.end;
226    }
227    stripped.push_str(&text[copied_up_to..]);
228    (Cow::Owned(stripped), seams)
229}
230
231/// The text a reader sees once the elements that sat at `seams` are gone.
232///
233/// An element written between two spaces (`Foo <a id="x"></a> Bar`) leaves
234/// them adjacent, and a browser shows adjacent spaces as one, so one of each
235/// such pair goes with the element. Spaces the author wrote in a run stay, and
236/// whitespace at either end is trimmed as CommonMark trims a heading.
237fn display_text(slug_text: &str, seams: &[usize]) -> String {
238    if seams.is_empty() {
239        return slug_text.trim().to_string();
240    }
241    let mut text = slug_text.to_string();
242    for &seam in seams.iter().rev() {
243        if seam == 0 || seam >= text.len() {
244            continue;
245        }
246        let is_blank = |byte: u8| matches!(byte, b' ' | b'\t');
247        if is_blank(text.as_bytes()[seam - 1]) && is_blank(text.as_bytes()[seam]) {
248            text.remove(seam);
249        }
250    }
251    text.trim().to_string()
252}
253
254/// The targets of the empty `<a>` elements in `text`, in source order.
255///
256/// Each element contributes its `id`, or its legacy `name` when it has no `id`,
257/// so `<a name="old"></a><a id="new"></a>Heading` yields both. An element with
258/// neither attribute set is not a target.
259pub fn extract_html_anchor_ids(text: &str) -> Vec<String> {
260    empty_anchor_elements(text)
261        .into_iter()
262        .filter_map(|(_, open_tag)| html_tag_attribute(open_tag, "id").or_else(|| html_tag_attribute(open_tag, "name")))
263        .map(str::to_string)
264        .collect()
265}
266
267/// The empty `<a>` elements of `text` in source order, each as the byte range
268/// of the whole element and its open tag.
269///
270/// Tags are read the way a browser tokenizes them, one after another, so an
271/// `<a>` written inside another tag's attribute value is part of that value
272/// and no element. A tag inside a code span or an HTML comment, or whose `<`
273/// is backslash-escaped, is heading text; the scan resumes just past its `<`,
274/// since the text it was taken for may hold a real tag of its own.
275fn empty_anchor_elements(text: &str) -> Vec<(std::ops::Range<usize>, &str)> {
276    if !text.contains('<') {
277        return Vec::new();
278    }
279
280    let opaque = opaque_ranges(text);
281    let mut anchors = Vec::new();
282    let mut pos = 0;
283    while let Some(tag) = HTML_OPEN_TAG.captures_at(text, pos) {
284        let open_tag = tag.get(0).unwrap();
285        if is_within(&opaque, open_tag.start()) || is_backslash_escaped(text, open_tag.start()) {
286            pos = open_tag.start() + 1;
287            continue;
288        }
289        pos = open_tag.end();
290
291        if !tag[1].eq_ignore_ascii_case("a") {
292            continue;
293        }
294        if let Some(closing_tag) = HTML_ANCHOR_CLOSING_TAG.find(&text[open_tag.end()..]) {
295            pos = open_tag.end() + closing_tag.end();
296            anchors.push((open_tag.start()..pos, open_tag.as_str()));
297        }
298    }
299
300    anchors
301}
302
303/// Byte ranges of `text` in which a tag is not an element: code spans, HTML
304/// comments and images.
305///
306/// A code span opens with a run of backticks and closes with a run of exactly
307/// the same length; an opener that never closes is literal backticks. A comment
308/// runs from `<!--` to the next `-->`, or to the end of the text; `<!-->` and
309/// `<!--->` are complete comments, so the search for the closer starts right
310/// after `<!`. An image's description becomes its alt text, in which markup is
311/// escaped, so `![<a id="x"></a>](img.png)` renders no element and no target.
312fn opaque_ranges(text: &str) -> Vec<(usize, usize)> {
313    let bytes = text.as_bytes();
314    let mut ranges = Vec::new();
315    let mut pos = 0;
316
317    while pos < bytes.len() {
318        if bytes[pos] == b'`' {
319            let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
320            let run_len = run_end - pos;
321            match closing_backtick_run(bytes, run_end, run_len) {
322                Some(close_start) => {
323                    ranges.push((pos, close_start + run_len));
324                    pos = close_start + run_len;
325                }
326                None => pos = run_end,
327            }
328        } else if bytes[pos..].starts_with(b"<!--") {
329            let end = text[pos + 2..]
330                .find("-->")
331                .map_or(bytes.len(), |offset| pos + 2 + offset + 3);
332            ranges.push((pos, end));
333            pos = end;
334        } else if bytes[pos..].starts_with(b"![")
335            && !is_backslash_escaped(text, pos)
336            && !is_backslash_escaped(text, pos + 1)
337            && let Some(end) = image_end(text, pos)
338        {
339            ranges.push((pos, end));
340            pos = end;
341        } else {
342            pos += 1;
343        }
344    }
345
346    ranges
347}
348
349/// Offset just past the inline image whose `![` starts at `start`, if its
350/// description closes and a destination follows.
351///
352/// The description may nest brackets and escape them with a backslash. The
353/// destination is `(...)` with balanced parentheses or a `[label]`. A bare
354/// `![text]` is a collapsed or shortcut reference image only if the document
355/// defines the label, which this text-level scan cannot see, so it stays text.
356fn image_end(text: &str, start: usize) -> Option<usize> {
357    let bytes = text.as_bytes();
358    let description_end = balanced_end(bytes, start + 2, b'[', b']')?;
359    match bytes.get(description_end) {
360        Some(b'(') => balanced_end(bytes, description_end + 1, b'(', b')'),
361        Some(b'[') => balanced_end(bytes, description_end + 1, b'[', b']'),
362        _ => None,
363    }
364}
365
366/// Offset just past the `close` byte that balances an `open` consumed before
367/// `from`, honouring backslash escapes; `None` when it never closes.
368fn balanced_end(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
369    let mut depth = 1usize;
370    let mut pos = from;
371    while pos < bytes.len() {
372        match bytes[pos] {
373            b'\\' => pos += 1,
374            byte if byte == open => depth += 1,
375            byte if byte == close => {
376                depth -= 1;
377                if depth == 0 {
378                    return Some(pos + 1);
379                }
380            }
381            _ => {}
382        }
383        pos += 1;
384    }
385    None
386}
387
388/// Start of the first backtick run of exactly `run_len` after `from`, if any.
389fn closing_backtick_run(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
390    let mut pos = from;
391    while pos < bytes.len() {
392        if bytes[pos] != b'`' {
393            pos += 1;
394            continue;
395        }
396        let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
397        if run_end - pos == run_len {
398            return Some(pos);
399        }
400        pos = run_end;
401    }
402    None
403}
404
405fn is_within(ranges: &[(usize, usize)], pos: usize) -> bool {
406    ranges.iter().any(|&(start, end)| start <= pos && pos < end)
407}
408
409/// Whether the character at byte `pos` of `text` is backslash-escaped.
410///
411/// CommonMark reads `\<` as a literal `<` and `\\<` as a literal backslash
412/// followed by a tag, so the character is escaped when an odd number of
413/// backslashes precede it.
414pub fn is_backslash_escaped(text: &str, pos: usize) -> bool {
415    text.as_bytes()[..pos].iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 1
416}
417
418/// The value of attribute `name` in the HTML open tag `tag`.
419///
420/// Attribute names are compared whole and without regard to case, so `data-id`
421/// never stands in for `id`. As in HTML, the first occurrence decides. An
422/// attribute that is absent, has no value or has an empty value yields `None`.
423pub fn html_tag_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
424    let bytes = tag.as_bytes();
425    if bytes.first() != Some(&b'<') {
426        return None;
427    }
428
429    let ends_name = |b: u8| b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/');
430    let mut pos = 1 + bytes[1..].iter().take_while(|&&b| !ends_name(b)).count();
431
432    loop {
433        pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
434        if pos >= bytes.len() || matches!(bytes[pos], b'>' | b'/') {
435            return None;
436        }
437
438        let name_start = pos;
439        pos += bytes[pos..].iter().take_while(|&&b| !ends_name(b)).count();
440        let attribute = &tag[name_start..pos];
441
442        let after_name = pos + bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
443        let value = if bytes.get(after_name) == Some(&b'=') {
444            let value_start = after_name
445                + 1
446                + bytes[after_name + 1..]
447                    .iter()
448                    .take_while(|b| b.is_ascii_whitespace())
449                    .count();
450            match bytes.get(value_start) {
451                Some(&quote @ (b'"' | b'\'')) => {
452                    let value_end = value_start + 1 + tag[value_start + 1..].find(quote as char)?;
453                    pos = value_end + 1;
454                    &tag[value_start + 1..value_end]
455                }
456                _ => {
457                    let value_end = value_start
458                        + bytes[value_start..]
459                            .iter()
460                            .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
461                            .count();
462                    pos = value_end;
463                    &tag[value_start..value_end]
464                }
465            }
466        } else {
467            ""
468        };
469
470        if attribute.eq_ignore_ascii_case(name) {
471            return (!value.is_empty()).then_some(value);
472        }
473    }
474}
475
476/// Check if a line is a standalone attr-list (Jekyll/kramdown style)
477///
478/// This detects attr-list syntax that appears on its own line, typically
479/// the line after a header to provide additional attributes.
480///
481/// # Examples
482/// ```
483/// use rumdl_lib::utils::header_id_utils::is_standalone_attr_list;
484///
485/// assert!(is_standalone_attr_list("{#custom-id}"));
486/// assert!(is_standalone_attr_list("{: #spaced .class }"));
487/// assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
488/// assert!(!is_standalone_attr_list(""));
489/// ```
490pub fn is_standalone_attr_list(line: &str) -> bool {
491    if !line.contains('{') {
492        return false;
493    }
494    STANDALONE_ATTR_LIST_PATTERN.is_match(line)
495}
496
497/// Extract ID from a standalone attr-list line
498///
499/// Returns the ID if the line is a valid standalone attr-list with an ID.
500///
501/// # Examples
502/// ```
503/// use rumdl_lib::utils::header_id_utils::extract_standalone_attr_list_id;
504///
505/// assert_eq!(extract_standalone_attr_list_id("{#custom-id}"), Some("custom-id".to_string()));
506/// assert_eq!(extract_standalone_attr_list_id("{: #spaced .class }"), Some("spaced".to_string()));
507/// assert_eq!(extract_standalone_attr_list_id("not an attr-list"), None);
508/// ```
509pub fn extract_standalone_attr_list_id(line: &str) -> Option<String> {
510    if !line.contains('{') {
511        return None;
512    }
513    if let Some(captures) = STANDALONE_ATTR_LIST_PATTERN.captures(line)
514        && let Some(attr_content) = captures.get(1)
515    {
516        let attr_str = attr_content.as_str().trim();
517
518        // Use the same logic as extract_header_id for consistency
519        if let Some(hash_pos) = attr_str.find('#') {
520            let after_hash = &attr_str[hash_pos + 1..];
521
522            // Check if this looks like a simple kramdown ID: {#id} with no spaces or attributes
523            let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
524
525            if is_simple_format {
526                // Simple format: entire content after # should be the ID
527                let potential_id = after_hash;
528                if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
529                    return Some(potential_id.to_string());
530                }
531            } else {
532                // Complex format: find proper delimiters (space for next attribute, dot for class)
533                if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
534                    let potential_id = &after_hash[..delimiter_pos];
535                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
536                        return Some(potential_id.to_string());
537                    }
538                } else {
539                    // No delimiter found in complex format, ID goes to end
540                    let potential_id = after_hash;
541                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
542                        return Some(potential_id.to_string());
543                    }
544                }
545            }
546        }
547    }
548    None
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_kramdown_format_extraction() {
557        // Simple kramdown format
558        let (text, id) = extract_header_id("# Header {#simple}");
559        assert_eq!(text, "# Header");
560        assert_eq!(id, Some("simple".to_string()));
561
562        let (text, id) = extract_header_id("## Section {#section-id}");
563        assert_eq!(text, "## Section");
564        assert_eq!(id, Some("section-id".to_string()));
565    }
566
567    #[test]
568    fn test_python_markdown_attr_list_extraction() {
569        // Python-markdown formats
570        let (text, id) = extract_header_id("# Header {:#colon-id}");
571        assert_eq!(text, "# Header");
572        assert_eq!(id, Some("colon-id".to_string()));
573
574        let (text, id) = extract_header_id("# Header {: #spaced-id }");
575        assert_eq!(text, "# Header");
576        assert_eq!(id, Some("spaced-id".to_string()));
577    }
578
579    #[test]
580    fn test_extended_attr_list_extraction() {
581        // ID with single class
582        let (text, id) = extract_header_id("# Header {: #with-class .highlight }");
583        assert_eq!(text, "# Header");
584        assert_eq!(id, Some("with-class".to_string()));
585
586        // ID with multiple classes
587        let (text, id) = extract_header_id("## Section {: #multi .class1 .class2 }");
588        assert_eq!(text, "## Section");
589        assert_eq!(id, Some("multi".to_string()));
590
591        // ID with key-value attributes
592        let (text, id) = extract_header_id("### Subsection {: #with-attrs data-test=\"value\" style=\"color: red\" }");
593        assert_eq!(text, "### Subsection");
594        assert_eq!(id, Some("with-attrs".to_string()));
595
596        // Complex combination
597        let (text, id) = extract_header_id("#### Complex {: #complex .highlight data-role=\"button\" title=\"Test\" }");
598        assert_eq!(text, "#### Complex");
599        assert_eq!(id, Some("complex".to_string()));
600
601        // ID with quotes in attributes
602        let (text, id) = extract_header_id("##### Quotes {: #quotes title=\"Has \\\"nested\\\" quotes\" }");
603        assert_eq!(text, "##### Quotes");
604        assert_eq!(id, Some("quotes".to_string()));
605    }
606
607    #[test]
608    fn test_attr_list_detection_edge_cases() {
609        // Attr-list without ID should not match
610        let (text, id) = extract_header_id("# Header {: .class-only }");
611        assert_eq!(text, "# Header {: .class-only }");
612        assert_eq!(id, None);
613
614        // Malformed attr-list should not match
615        let (text, id) = extract_header_id("# Header { no-hash }");
616        assert_eq!(text, "# Header { no-hash }");
617        assert_eq!(id, None);
618
619        // Empty ID should not match
620        let (text, id) = extract_header_id("# Header {: # }");
621        assert_eq!(text, "# Header {: # }");
622        assert_eq!(id, None);
623
624        // ID in middle (not at end) should not match
625        let (text, id) = extract_header_id("# Header {: #middle } with more text");
626        assert_eq!(text, "# Header {: #middle } with more text");
627        assert_eq!(id, None);
628    }
629
630    #[test]
631    fn test_standalone_attr_list_detection() {
632        // Simple ID formats
633        assert!(is_standalone_attr_list("{#custom-id}"));
634        assert!(is_standalone_attr_list("{ #spaced-id }"));
635        assert!(is_standalone_attr_list("{:#colon-id}"));
636        assert!(is_standalone_attr_list("{: #full-format }"));
637
638        // With classes and attributes
639        assert!(is_standalone_attr_list("{: #with-class .highlight }"));
640        assert!(is_standalone_attr_list("{: #multi .class1 .class2 }"));
641        assert!(is_standalone_attr_list("{: #complex .highlight data-test=\"value\" }"));
642
643        // Should not match
644        assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
645        assert!(!is_standalone_attr_list("Text before {#id}"));
646        assert!(!is_standalone_attr_list("{#id} text after"));
647        assert!(!is_standalone_attr_list(""));
648        assert!(!is_standalone_attr_list("   ")); // just spaces
649        assert!(!is_standalone_attr_list("{: .class-only }")); // no ID
650    }
651
652    #[test]
653    fn test_standalone_attr_list_id_extraction() {
654        // Basic formats
655        assert_eq!(extract_standalone_attr_list_id("{#simple}"), Some("simple".to_string()));
656        assert_eq!(
657            extract_standalone_attr_list_id("{ #spaced }"),
658            Some("spaced".to_string())
659        );
660        assert_eq!(extract_standalone_attr_list_id("{:#colon}"), Some("colon".to_string()));
661        assert_eq!(extract_standalone_attr_list_id("{: #full }"), Some("full".to_string()));
662
663        // With additional attributes
664        assert_eq!(
665            extract_standalone_attr_list_id("{: #with-class .highlight }"),
666            Some("with-class".to_string())
667        );
668        assert_eq!(
669            extract_standalone_attr_list_id("{: #complex .class1 .class2 data=\"value\" }"),
670            Some("complex".to_string())
671        );
672
673        // Should return None
674        assert_eq!(extract_standalone_attr_list_id("Not an attr-list"), None);
675        assert_eq!(extract_standalone_attr_list_id("Text {#not-standalone}"), None);
676        assert_eq!(extract_standalone_attr_list_id("{: .class-only }"), None);
677        assert_eq!(extract_standalone_attr_list_id(""), None);
678    }
679
680    #[test]
681    fn test_backward_compatibility() {
682        // Ensure all original kramdown formats still work
683        let test_cases = vec![
684            ("# Header {#a}", "# Header", Some("a".to_string())),
685            ("# Header {#simple-id}", "# Header", Some("simple-id".to_string())),
686            ("## Heading {#heading-2}", "## Heading", Some("heading-2".to_string())),
687            (
688                "### With-Hyphens {#with-hyphens}",
689                "### With-Hyphens",
690                Some("with-hyphens".to_string()),
691            ),
692        ];
693
694        for (input, expected_text, expected_id) in test_cases {
695            let (text, id) = extract_header_id(input);
696            assert_eq!(text, expected_text, "Text mismatch for input: {input}");
697            assert_eq!(id, expected_id, "ID mismatch for input: {input}");
698        }
699    }
700
701    #[test]
702    fn test_invalid_id_with_dots() {
703        // IDs with dots should not be extracted (dots are not valid ID characters)
704        let (text, id) = extract_header_id("## Another. {#id.with.dots}");
705        assert_eq!(text, "## Another. {#id.with.dots}"); // Should not strip invalid ID
706        assert_eq!(id, None); // Should not extract invalid ID
707
708        // Test that only the part before the dot would be extracted if it was valid standalone
709        // But since it's in an invalid format, the whole thing should be rejected
710        let (text, id) = extract_header_id("## Another. {#id.more.dots}");
711        assert_eq!(text, "## Another. {#id.more.dots}");
712        assert_eq!(id, None);
713    }
714
715    #[test]
716    fn test_html_anchor_stripping() {
717        // HTML anchor elements should be stripped from heading text
718        // This is used by some authors for custom anchors
719
720        // Basic <a name="..."></a> pattern
721        let (text, id) = extract_header_id("<a name=\"cheatsheets\"></a>Cheat Sheets");
722        assert_eq!(text, "Cheat Sheets");
723        assert_eq!(id, None);
724
725        // <a id="..."></a> pattern
726        let (text, id) = extract_header_id("<a id=\"tools\"></a>Tools and session management");
727        assert_eq!(text, "Tools and session management");
728        assert_eq!(id, None);
729
730        // With spaces around the anchor
731        let (text, id) = extract_header_id("<a name=\"foo\"></a> Heading with space");
732        assert_eq!(text, "Heading with space");
733        assert_eq!(id, None);
734
735        // Combined with kramdown custom ID
736        let (text, id) = extract_header_id("<a name=\"old\"></a>My Section {#my-custom-id}");
737        assert_eq!(text, "My Section");
738        assert_eq!(id, Some("my-custom-id".to_string()));
739    }
740
741    #[test]
742    fn test_html_anchor_ids_are_read_from_empty_anchor_elements_in_order() {
743        assert_eq!(extract_html_anchor_ids(r#"Heading<a id="target"></a>"#), ["target"]);
744        assert_eq!(
745            extract_html_anchor_ids(r#"<A class="legacy" NAME='fallback' ID='preferred'></A>Heading"#),
746            ["preferred"]
747        );
748        assert_eq!(
749            extract_html_anchor_ids(r#"<a name='legacy'></a><a id="newer"></a>Heading"#),
750            ["legacy", "newer"]
751        );
752        assert_eq!(extract_html_anchor_ids("<a id=plain></a>Heading"), ["plain"]);
753        assert!(extract_html_anchor_ids(r##"<a href="#target"></a>Heading"##).is_empty());
754        assert!(extract_html_anchor_ids(r#"<span id="target"></span>Heading"#).is_empty());
755        assert!(extract_html_anchor_ids(r#"<a id="target">text</a>Heading"#).is_empty());
756    }
757
758    #[test]
759    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
760        assert!(extract_html_anchor_ids(r#"Foo<a data-id="tracking" data-name="pixel"></a>"#).is_empty());
761        assert!(extract_html_anchor_ids(r#"Foo<a id=""></a>"#).is_empty());
762    }
763
764    #[test]
765    fn test_a_quoted_attribute_value_may_contain_a_closing_angle_bracket() {
766        let raw = r#"<a title="a > b" id="target"></a>Heading"#;
767        assert_eq!(extract_html_anchor_ids(raw), ["target"]);
768        assert_eq!(extract_header_id(raw), ("Heading".to_string(), None));
769    }
770
771    #[test]
772    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
773        let raw = "Showing `<a id=\"literal\"></a>` syntax";
774        assert!(extract_html_anchor_ids(raw).is_empty());
775        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
776
777        // A real anchor beside the code span is still found and stripped.
778        let raw = "Showing `<a id=\"literal\"></a>` syntax<a id=\"real\"></a>";
779        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
780        assert_eq!(extract_header_id(raw).0, "Showing `<a id=\"literal\"></a>` syntax");
781    }
782
783    #[test]
784    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
785        let raw = "Foo <!-- <a id=\"hidden\"></a> -->";
786        assert!(extract_html_anchor_ids(raw).is_empty());
787        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
788    }
789
790    #[test]
791    fn test_html_tag_attribute_matches_whole_names_case_insensitively() {
792        assert_eq!(html_tag_attribute(r#"<div data-id="x" ID="y">"#, "id"), Some("y"));
793        assert_eq!(html_tag_attribute("<a name = 'legacy' >", "name"), Some("legacy"));
794        assert_eq!(html_tag_attribute("<a id=plain>", "id"), Some("plain"));
795        assert_eq!(html_tag_attribute(r#"<a id="first" id="second">"#, "id"), Some("first"));
796        assert_eq!(html_tag_attribute(r#"<a id="">"#, "id"), None);
797        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "hidden"), None);
798        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "id"), Some("x"));
799        assert_eq!(html_tag_attribute(r#"<a title="a > b" id="x">"#, "id"), Some("x"));
800        assert_eq!(html_tag_attribute(r#"<video title="id=fake">"#, "id"), None);
801        assert_eq!(html_tag_attribute("<br/>", "id"), None);
802    }
803
804    #[test]
805    fn test_html_anchor_stripping_handles_attribute_variations() {
806        let (text, id) = extract_header_id(r#"<A class="legacy" ID='target'></A>Heading"#);
807        assert_eq!(text, "Heading");
808        assert_eq!(id, None);
809    }
810
811    #[test]
812    fn test_a_backslash_escaped_anchor_element_is_heading_text() {
813        // `\<` is a literal `<`, so the markup renders as text and defines no
814        // target. An escaped backslash before the `<` leaves it a tag.
815        let raw = r#"Show \<a id="example"></a> syntax"#;
816        assert!(extract_html_anchor_ids(raw).is_empty());
817        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
818
819        let raw = r#"Show \\<a id="example"></a> syntax"#;
820        assert_eq!(extract_html_anchor_ids(raw), ["example"]);
821        assert_eq!(extract_header_id(raw).0, r"Show \\ syntax");
822    }
823
824    #[test]
825    fn test_stripping_an_anchor_element_keeps_the_whitespace_beside_it() {
826        // The element renders nothing, so removing exactly its bytes leaves the
827        // text the browser shows: the space between the words stays. The display
828        // text is trimmed at either end, as CommonMark trims a heading, and an
829        // element between two spaces takes one of them with it, as a browser
830        // shows adjacent spaces as one. The slug text keeps every space, since
831        // GitHub and kramdown turn each into a hyphen.
832        let cases = [
833            (r#"Foo<a id="alias"></a> Bar"#, "Foo Bar", "Foo Bar"),
834            (r#"Foo <a id="alias"></a>Bar"#, "Foo Bar", "Foo Bar"),
835            (r#"Foo <a id="alias"></a> Bar"#, "Foo Bar", "Foo  Bar"),
836            (r#"Foo <a id="a"></a> <a id="b"></a> Bar"#, "Foo Bar", "Foo   Bar"),
837            (r#"Foo  <a id="alias"></a>Bar"#, "Foo  Bar", "Foo  Bar"),
838            (r#"<a id="alias"></a> Foo"#, "Foo", " Foo"),
839            (r#"Foo <a id="alias"></a>"#, "Foo", "Foo "),
840            (r#"<a id="a"></a> Foo <a id="b"></a>"#, "Foo", " Foo "),
841        ];
842        for (raw, text, slug_text) in cases {
843            let heading = extract_heading_text(raw);
844            assert_eq!(heading.text, text, "display text of {raw:?}");
845            assert_eq!(heading.slug_text, slug_text, "slug text of {raw:?}");
846            assert_eq!(heading.custom_id, None, "custom ID of {raw:?}");
847        }
848        assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>"#).0, "Foo");
849    }
850
851    #[test]
852    fn test_an_anchor_element_inside_an_image_description_is_text() {
853        // The description becomes the image's alt text, in which markup is
854        // escaped, so the page has no element and no target. Nested brackets
855        // and a reference destination still make an image.
856        for raw in [
857            r#"![<a id="x"></a>](img.png)"#,
858            r#"![see [docs] <a id="x"></a>](img.png "title")"#,
859            r#"![<a id="x"></a>][ref]"#,
860        ] {
861            assert!(extract_html_anchor_ids(raw).is_empty(), "{raw}");
862            assert_eq!(extract_header_id(raw), (raw.to_string(), None), "{raw}");
863        }
864
865        // A backslash before the `!` leaves a link, whose text does render markup.
866        let raw = r#"\![<a id="x"></a>](img.png)"#;
867        assert_eq!(extract_html_anchor_ids(raw), ["x"]);
868        assert_eq!(extract_header_id(raw).0, r"\![](img.png)");
869
870        // An element beside the image is still one.
871        let raw = r#"![alt](img.png) <a id="after"></a>"#;
872        assert_eq!(extract_html_anchor_ids(raw), ["after"]);
873        let heading = extract_heading_text(raw);
874        assert_eq!(heading.text, "![alt](img.png)");
875        assert_eq!(heading.slug_text, "![alt](img.png) ");
876    }
877
878    #[test]
879    fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
880        // The `<a>` is part of the span's `title` value, so the browser creates
881        // no element from it and the heading keeps its bytes.
882        let raw = r#"<span title='<a id="fake"></a>'>Foo</span>"#;
883        assert!(extract_html_anchor_ids(raw).is_empty());
884        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
885
886        let raw = r#"<span title='x'><a id="real"></a>Foo</span>"#;
887        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
888        assert_eq!(extract_header_id(raw).0, "<span title='x'>Foo</span>");
889    }
890
891    #[test]
892    fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
893        // `\<span` is text, so the `<a>` where its attribute value would be is a
894        // real element.
895        let raw = r#"\<span title='<a id="real"></a>'>Foo"#;
896        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
897        assert_eq!(extract_header_id(raw).0, r#"\<span title=''>Foo"#);
898    }
899
900    #[test]
901    fn test_a_degenerate_comment_ends_at_its_own_closer() {
902        // `<!-->` and `<!--->` are complete comments, so the anchor after them is
903        // an element and the later `-->` is text.
904        assert_eq!(extract_html_anchor_ids(r#"<!--> <a id="x"></a> --> Foo"#), ["x"]);
905        assert_eq!(extract_html_anchor_ids(r#"<!---> <a id="y"></a> --> Foo"#), ["y"]);
906        assert!(extract_html_anchor_ids(r#"<!-- <a id="z"></a> --> Foo"#).is_empty());
907    }
908
909    #[test]
910    fn test_is_backslash_escaped_counts_the_run_of_backslashes() {
911        assert!(!is_backslash_escaped("<a>", 0));
912        assert!(is_backslash_escaped(r"\<a>", 1));
913        assert!(!is_backslash_escaped(r"\\<a>", 2));
914        assert!(is_backslash_escaped(r"\\\<a>", 3));
915        assert!(!is_backslash_escaped(r"x<a>", 1));
916    }
917}