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