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