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::collections::HashMap;
41use std::sync::LazyLock;
42
43/// The name of an HTML tag: a letter, then letters, digits and hyphens.
44pub const HTML_TAG_NAME_PATTERN: &str = "[A-Za-z][A-Za-z0-9-]*";
45
46/// Attribute list of an inline HTML open tag as CommonMark defines it.
47///
48/// A quoted value may contain `>` without ending the tag, which is why a tag
49/// cannot be delimited by searching for the next `>`.
50pub const HTML_TAG_ATTRIBUTES_PATTERN: &str = r#"(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*"#;
51
52/// An inline HTML open tag, self-closing or not, with its name captured.
53pub static HTML_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
54    Regex::new(&format!(
55        r"<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
56    ))
57    .unwrap()
58});
59
60/// The name of a tag as a browser reads it inside raw HTML: a letter, then
61/// anything up to whitespace, `/` or `>`.
62pub const HTML_BLOCK_TAG_NAME_PATTERN: &str = r"[A-Za-z][^\s/>]*";
63
64/// An open tag inside an HTML block, where the browser's tokenizer rather than
65/// CommonMark's inline grammar decides what a tag is. Only the tag name is read
66/// the browser's way; attributes still follow the CommonMark grammar.
67pub static HTML_BLOCK_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
68    Regex::new(&format!(
69        r"<({HTML_BLOCK_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
70    ))
71    .unwrap()
72});
73
74/// The closing tag of an `<a>` element at the start of the text, with any
75/// whitespace before it.
76static HTML_ANCHOR_CLOSING_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*</a\s*>").unwrap());
77
78/// Pattern for custom header IDs supporting both kramdown and python-markdown attr-list formats
79/// Supports: {#id}, { #id }, {:#id}, {: #id } and full attr-list with classes/attributes
80/// Must contain #id but can have other attributes: {: #id .class data="value" }
81/// More conservative: only matches when there's actually a hash followed by valid ID characters
82static HEADER_ID_PATTERN: LazyLock<Regex> =
83    LazyLock::new(|| Regex::new(r"\s*\{\s*:?\s*([^}]*?#[^}]*?)\s*\}\s*$").unwrap());
84
85/// Pattern to validate that an ID contains only valid characters
86static ID_VALIDATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-:]+$").unwrap());
87
88/// Pattern for standalone attr-list lines (Jekyll/kramdown style on line after heading)
89/// Matches lines that are just attr-list syntax: {#id}, {: #id .class }, etc.
90static STANDALONE_ATTR_LIST_PATTERN: LazyLock<Regex> =
91    LazyLock::new(|| Regex::new(r"^\s*\{\s*:?\s*([^}]*#[a-zA-Z0-9_\-:]+[^}]*)\s*\}\s*$").unwrap());
92
93/// Extract custom header ID from a line if present, returning clean text and ID
94///
95/// Supports multiple formats:
96/// - Kramdown: `{#id}`
97/// - Python-markdown: `{:#id}`, `{: #id}`, `{: #id .class}`
98///
99/// # Examples
100/// ```
101/// use rumdl_lib::utils::header_id_utils::extract_header_id;
102///
103/// // Kramdown format
104/// let (text, id) = extract_header_id("# Header {#custom-id}");
105/// assert_eq!(text, "# Header");
106/// assert_eq!(id, Some("custom-id".to_string()));
107///
108/// // Python-markdown attr-list format
109/// let (text, id) = extract_header_id("# Header {: #my-id .highlight}");
110/// assert_eq!(text, "# Header");
111/// assert_eq!(id, Some("my-id".to_string()));
112/// ```
113pub fn extract_header_id(line: &str) -> (String, Option<String>) {
114    let heading = extract_heading_text(line);
115    (heading.text, heading.custom_id)
116}
117
118/// The content of a heading split into what a reader sees, what a renderer
119/// slugs, and the custom ID its attribute list declares.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct HeadingText {
122    /// The text as a reader sees it: anchor elements and the attribute list
123    /// removed, one of the two spaces an element sat between gone with it, and
124    /// both ends trimmed.
125    pub text: String,
126    /// The text a slug is generated from: the same as `text`, except that every
127    /// space an anchor element leaves behind stays. A browser shows
128    /// `## Alpha <a id="x"></a>` as "Alpha" and `## Foo <a id="x"></a> Bar` as
129    /// "Foo Bar", but GitHub and kramdown slug them to `alpha-` and `foo--bar`;
130    /// each anchor style decides for itself whether to trim and collapse.
131    pub slug_text: String,
132    /// The ID from a `{#id}` or `{: #id}` attribute list, if any.
133    pub custom_id: Option<String>,
134}
135
136/// Split heading content into display text, slug text and custom ID.
137///
138/// See [`HeadingText`] for what each part holds. The attribute-list formats are
139/// those of [`extract_header_id`], which returns the display text and ID only.
140pub fn extract_heading_text(line: &str) -> HeadingText {
141    // An empty anchor element beside the text (`## <a name="foo"></a>Heading`) is
142    // markup, not heading text.
143    let (line, seams) = strip_html_anchor_elements(line);
144    let line = line.as_ref();
145
146    let (slug_text, custom_id) = match custom_id_at_end(line) {
147        Some((attr_list_start, id)) => (line[..attr_list_start].trim_end(), Some(id)),
148        None => (line, None),
149    };
150    HeadingText {
151        text: display_text(slug_text, &seams),
152        slug_text: slug_text.to_string(),
153        custom_id,
154    }
155}
156
157/// The ID declared by an attribute list that ends `line`, with the byte offset
158/// where the attribute list starts. An attribute list whose ID fails validation
159/// declares nothing and stays heading text.
160fn custom_id_at_end(line: &str) -> Option<(usize, String)> {
161    if !line.contains('{') {
162        return None;
163    }
164    let captures = HEADER_ID_PATTERN.captures(line)?;
165    let attr_list_start = captures.get(0)?.start();
166    let attr_str = captures.get(1)?.as_str().trim();
167    let hash_pos = attr_str.find('#')?;
168    let after_hash = &attr_str[hash_pos + 1..];
169
170    // In the simple kramdown form `{#id}` the ID runs to the end. In the full
171    // attr-list form `{: #id .class key="value"}` it ends at the next attribute
172    // (whitespace), class (dot) or value (equals sign).
173    let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
174    let potential_id = if is_simple_format {
175        after_hash
176    } else {
177        match after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
178            Some(delimiter_pos) => &after_hash[..delimiter_pos],
179            None => after_hash,
180        }
181    };
182
183    (!potential_id.is_empty() && ID_VALIDATE_PATTERN.is_match(potential_id))
184        .then(|| (attr_list_start, potential_id.to_string()))
185}
186
187/// Where the display text of `line` ends: before its trailing whitespace, the
188/// attribute list a custom ID sits in and an empty anchor element at the end,
189/// each stripped as the one after it exposes it. Zero when the line holds no
190/// display text.
191pub fn heading_text_end(line: &str) -> usize {
192    let mut end = line.len();
193    let mut anchors = None;
194    loop {
195        end = line[..end].trim_end().len();
196        if line[..end].ends_with('}')
197            && let Some((attr_list_start, _)) = custom_id_at_end(&line[..end])
198        {
199            // Removing a closer can expose anchors inside an earlier code span,
200            // image, comment or tag. Plain attribute lists cannot change those
201            // ranges, so keep the cache when no such delimiter was removed.
202            if anchors.is_some()
203                && line.as_bytes()[attr_list_start..end]
204                    .iter()
205                    .any(|byte| matches!(byte, b'`' | b'>' | b']' | b')'))
206            {
207                anchors = None;
208            }
209            end = attr_list_start;
210        } else if let Some((range, _)) = anchors.get_or_insert_with(|| empty_anchor_elements(&line[..end])).pop()
211            && range.end == end
212        {
213            // A recognized trailing anchor cannot close an opaque range that
214            // starts before it: its opening tag would have been inside that range.
215            // Earlier anchors therefore remain valid when this one is removed.
216            end = range.start;
217        } else {
218            return end;
219        }
220    }
221}
222
223/// Remove the empty `<a>` elements that give a heading its anchors.
224///
225/// Exactly the element's bytes go, so the whitespace beside it stays part of
226/// the text: `Foo<a id="x"></a> Bar` reads "Foo Bar", and whitespace left at
227/// either end stays for the slug, since GitHub and kramdown slug
228/// `Alpha <a id="x"></a>` as `#alpha-`. The offsets returned beside the text
229/// are where each element sat, for [`display_text`].
230fn strip_html_anchor_elements(text: &str) -> (Cow<'_, str>, Vec<usize>) {
231    let anchors = empty_anchor_elements(text);
232    if anchors.is_empty() {
233        return (Cow::Borrowed(text), Vec::new());
234    }
235
236    let mut stripped = String::with_capacity(text.len());
237    let mut seams = Vec::with_capacity(anchors.len());
238    let mut copied_up_to = 0;
239    for (range, _) in anchors {
240        stripped.push_str(&text[copied_up_to..range.start]);
241        seams.push(stripped.len());
242        copied_up_to = range.end;
243    }
244    stripped.push_str(&text[copied_up_to..]);
245    (Cow::Owned(stripped), seams)
246}
247
248/// The text a reader sees once the elements that sat at `seams` are gone.
249///
250/// An element written between two spaces (`Foo <a id="x"></a> Bar`) leaves
251/// them adjacent, and a browser shows adjacent spaces as one, so one of each
252/// such pair goes with the element. Spaces the author wrote in a run stay, and
253/// whitespace at either end is trimmed as CommonMark trims a heading.
254fn display_text(slug_text: &str, seams: &[usize]) -> String {
255    if seams.is_empty() {
256        return slug_text.trim().to_string();
257    }
258    let bytes = slug_text.as_bytes();
259    let mut reversed = Vec::with_capacity(bytes.len());
260    let mut end = bytes.len();
261    // Seams are source-ordered and may repeat for adjacent anchors. Build from
262    // the right so each seam sees the suffix left by the seams after it, without
263    // shifting that suffix every time a blank is removed.
264    for &seam in seams.iter().rev() {
265        if seam == 0 || seam >= bytes.len() {
266            continue;
267        }
268        reversed.extend(bytes[seam..end].iter().rev());
269        end = seam;
270        let is_blank = |byte: u8| matches!(byte, b' ' | b'\t');
271        if is_blank(bytes[seam - 1]) && reversed.last().is_some_and(|&byte| is_blank(byte)) {
272            reversed.pop();
273        }
274    }
275    reversed.extend(bytes[..end].iter().rev());
276    reversed.reverse();
277    // Only ASCII blanks were removed, so restoring byte order preserves UTF-8.
278    String::from_utf8(reversed)
279        .expect("removing ASCII blanks preserves UTF-8")
280        .trim()
281        .to_string()
282}
283
284/// The targets of the empty `<a>` elements in `text`, in source order.
285///
286/// Each element contributes its `id`, or its legacy `name` when it has no `id`,
287/// so `<a name="old"></a><a id="new"></a>Heading` yields both. An element with
288/// neither attribute set is not a target.
289pub fn extract_html_anchor_ids(text: &str) -> Vec<String> {
290    empty_anchor_elements(text)
291        .into_iter()
292        .filter_map(|(_, open_tag)| html_tag_attribute(open_tag, "id").or_else(|| html_tag_attribute(open_tag, "name")))
293        .map(str::to_string)
294        .collect()
295}
296
297/// The empty `<a>` elements of `text` in source order, each as the byte range
298/// of the whole element and its open tag.
299///
300/// Tags are read the way a browser tokenizes them, one after another, so an
301/// `<a>` written inside another tag's attribute value is part of that value
302/// and no element. A tag inside a code span or an HTML comment, or whose `<`
303/// is backslash-escaped, is heading text; the scan resumes just past its `<`,
304/// since the text it was taken for may hold a real tag of its own.
305fn empty_anchor_elements(text: &str) -> Vec<(std::ops::Range<usize>, &str)> {
306    if !text.contains('<') {
307        return Vec::new();
308    }
309
310    let opaque = opaque_ranges(text);
311    // Opaque ranges are disjoint and source-ordered, as are the tag matches.
312    let mut opaque_index = 0;
313    let mut anchors = Vec::new();
314    let mut pos = 0;
315    while let Some(tag) = HTML_OPEN_TAG.captures_at(text, pos) {
316        let open_tag = tag.get(0).unwrap();
317        while opaque
318            .get(opaque_index)
319            .is_some_and(|&(_, end)| end <= open_tag.start())
320        {
321            opaque_index += 1;
322        }
323        if opaque
324            .get(opaque_index)
325            .is_some_and(|&(start, _)| start <= open_tag.start())
326            || is_backslash_escaped(text, open_tag.start())
327        {
328            pos = open_tag.start() + 1;
329            continue;
330        }
331        pos = open_tag.end();
332
333        if !tag[1].eq_ignore_ascii_case("a") {
334            continue;
335        }
336        if let Some(closing_tag) = HTML_ANCHOR_CLOSING_TAG.find(&text[open_tag.end()..]) {
337            pos = open_tag.end() + closing_tag.end();
338            anchors.push((open_tag.start()..pos, open_tag.as_str()));
339        }
340    }
341
342    anchors
343}
344
345/// Byte ranges of `text` in which a tag is not an element: code spans, HTML
346/// comments and images.
347///
348/// A code span opens with a run of backticks and closes with a run of exactly
349/// the same length; an opener that never closes is literal backticks. A comment
350/// runs from `<!--` to the next `-->`, or to the end of the text; `<!-->` and
351/// `<!--->` are complete comments, so the search for the closer starts right
352/// after `<!`. An image's description becomes its alt text, in which markup is
353/// escaped, so `![<a id="x"></a>](img.png)` renders no element and no target.
354fn opaque_ranges(text: &str) -> Vec<(usize, usize)> {
355    let bytes = text.as_bytes();
356    let mut ranges = Vec::new();
357    let mut backticks = BacktickLookup::default();
358    let mut brackets = DelimiterLookup::default();
359    let mut parentheses = DelimiterLookup::default();
360    let mut pos = 0;
361
362    while pos < bytes.len() {
363        if bytes[pos] == b'`' {
364            let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
365            let run_len = run_end - pos;
366            match backticks.find(bytes, run_end, run_len) {
367                Some(close_start) => {
368                    ranges.push((pos, close_start + run_len));
369                    pos = close_start + run_len;
370                }
371                None => pos = run_end,
372            }
373        } else if bytes[pos..].starts_with(b"<!--") {
374            let end = text[pos + 2..]
375                .find("-->")
376                .map_or(bytes.len(), |offset| pos + 2 + offset + 3);
377            ranges.push((pos, end));
378            pos = end;
379        } else if bytes[pos..].starts_with(b"![")
380            && !is_backslash_escaped(text, pos)
381            && !is_backslash_escaped(text, pos + 1)
382            && let Some(end) = image_end(text, pos, &mut brackets, &mut parentheses)
383        {
384            ranges.push((pos, end));
385            pos = end;
386        } else {
387            pos += 1;
388        }
389    }
390
391    ranges
392}
393
394/// Offset just past the inline image whose `![` starts at `start`, if its
395/// description closes and a destination follows.
396///
397/// The description may nest brackets and escape them with a backslash. The
398/// destination is `(...)` with balanced parentheses or a `[label]`. A bare
399/// `![text]` is a collapsed or shortcut reference image only if the document
400/// defines the label, which this text-level scan cannot see, so it stays text.
401fn image_end(
402    text: &str,
403    start: usize,
404    brackets: &mut DelimiterLookup,
405    parentheses: &mut DelimiterLookup,
406) -> Option<usize> {
407    let bytes = text.as_bytes();
408    let description_end = brackets.find(bytes, start + 2, b'[', b']')?;
409    match bytes.get(description_end) {
410        Some(b'(') => parentheses.find(bytes, description_end + 1, b'(', b')'),
411        Some(b'[') => brackets.find(bytes, description_end + 1, b'[', b']'),
412        _ => None,
413    }
414}
415
416/// Reuse one instance for one input and delimiter pair. Ordinary image ranges
417/// do not overlap, so they need no index. Malformed images can expose nested
418/// openers inside an already searched range, including successfully closed
419/// descriptions with no destination. Allow two input lengths of scanning before
420/// indexing: isolated malformed openers stay cheap, and total scanning is linear.
421#[derive(Default)]
422struct DelimiterLookup {
423    scanned_bytes: usize,
424    pairs: Option<HashMap<usize, usize>>,
425}
426
427impl DelimiterLookup {
428    fn find(&mut self, bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
429        if self.pairs.is_none() && self.scanned_bytes >= bytes.len().saturating_mul(2) {
430            let mut pairs = HashMap::new();
431            let mut stack = Vec::new();
432            let mut pos = 0;
433            while pos < bytes.len() {
434                match bytes[pos] {
435                    b'\\' => pos += 1,
436                    byte if byte == open => stack.push(pos + 1),
437                    byte if byte == close => {
438                        if let Some(start) = stack.pop() {
439                            pairs.insert(start, pos + 1);
440                        }
441                    }
442                    _ => {}
443                }
444                pos += 1;
445            }
446            self.pairs = Some(pairs);
447        }
448        if let Some(pairs) = &self.pairs {
449            return pairs.get(&from).copied();
450        }
451        let end = balanced_end(bytes, from, open, close);
452        self.scanned_bytes = self.scanned_bytes.saturating_add(end.unwrap_or(bytes.len()) - from);
453        end
454    }
455}
456
457/// Offset just past the `close` byte that balances an `open` consumed before
458/// `from`, honouring backslash escapes; `None` when it never closes.
459fn balanced_end(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
460    let mut depth = 1usize;
461    let mut pos = from;
462    while pos < bytes.len() {
463        match bytes[pos] {
464            b'\\' => pos += 1,
465            byte if byte == open => depth += 1,
466            byte if byte == close => {
467                depth -= 1;
468                if depth == 0 {
469                    return Some(pos + 1);
470                }
471            }
472            _ => {}
473        }
474        pos += 1;
475    }
476    None
477}
478
479/// Look up successors of complete backtick runs in one input. Like image
480/// delimiters, ordinary code spans need no index. Distinct unmatched lengths
481/// can repeatedly search the suffix, so bound direct scanning before indexing.
482#[derive(Default)]
483struct BacktickLookup {
484    scanned_bytes: usize,
485    successors: Option<HashMap<usize, usize>>,
486}
487
488impl BacktickLookup {
489    fn find(&mut self, bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
490        if self.successors.is_none() && self.scanned_bytes >= bytes.len().saturating_mul(2) {
491            let mut successors = HashMap::new();
492            let mut previous_by_length = HashMap::new();
493            let mut pos = 0;
494            while pos < bytes.len() {
495                if bytes[pos] != b'`' {
496                    pos += 1;
497                    continue;
498                }
499                let start = pos;
500                pos += bytes[pos..].iter().take_while(|&&byte| byte == b'`').count();
501                if let Some(previous_end) = previous_by_length.insert(pos - start, pos) {
502                    successors.insert(previous_end, start);
503                }
504            }
505            self.successors = Some(successors);
506        }
507        if let Some(successors) = &self.successors {
508            return successors.get(&from).copied();
509        }
510        let close = closing_backtick_run(bytes, from, run_len);
511        let scanned_to = close.map_or(bytes.len(), |start| start + run_len);
512        self.scanned_bytes = self.scanned_bytes.saturating_add(scanned_to - from);
513        close
514    }
515}
516
517/// Start of the first backtick run of exactly `run_len` after `from`, if any.
518fn closing_backtick_run(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
519    let mut pos = from;
520    while pos < bytes.len() {
521        if bytes[pos] != b'`' {
522            pos += 1;
523            continue;
524        }
525        let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
526        if run_end - pos == run_len {
527            return Some(pos);
528        }
529        pos = run_end;
530    }
531    None
532}
533
534/// Whether the character at byte `pos` of `text` is backslash-escaped.
535///
536/// CommonMark reads `\<` as a literal `<` and `\\<` as a literal backslash
537/// followed by a tag, so the character is escaped when an odd number of
538/// backslashes precede it.
539pub fn is_backslash_escaped(text: &str, pos: usize) -> bool {
540    text.as_bytes()[..pos].iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 1
541}
542
543/// The value of attribute `name` in the HTML open tag `tag`.
544///
545/// Attribute names are compared whole and without regard to case, so `data-id`
546/// never stands in for `id`. As in HTML, the first occurrence decides. An
547/// attribute that is absent, has no value or has an empty value yields `None`.
548pub fn html_tag_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
549    let bytes = tag.as_bytes();
550    if bytes.first() != Some(&b'<') {
551        return None;
552    }
553
554    let ends_name = |b: u8| b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/');
555    let mut pos = 1 + bytes[1..].iter().take_while(|&&b| !ends_name(b)).count();
556
557    loop {
558        pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
559        if pos >= bytes.len() || matches!(bytes[pos], b'>' | b'/') {
560            return None;
561        }
562
563        let name_start = pos;
564        pos += bytes[pos..].iter().take_while(|&&b| !ends_name(b)).count();
565        let attribute = &tag[name_start..pos];
566
567        let after_name = pos + bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
568        let value = if bytes.get(after_name) == Some(&b'=') {
569            let value_start = after_name
570                + 1
571                + bytes[after_name + 1..]
572                    .iter()
573                    .take_while(|b| b.is_ascii_whitespace())
574                    .count();
575            match bytes.get(value_start) {
576                Some(&quote @ (b'"' | b'\'')) => {
577                    let value_end = value_start + 1 + tag[value_start + 1..].find(quote as char)?;
578                    pos = value_end + 1;
579                    &tag[value_start + 1..value_end]
580                }
581                _ => {
582                    let value_end = value_start
583                        + bytes[value_start..]
584                            .iter()
585                            .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
586                            .count();
587                    pos = value_end;
588                    &tag[value_start..value_end]
589                }
590            }
591        } else {
592            ""
593        };
594
595        if attribute.eq_ignore_ascii_case(name) {
596            return (!value.is_empty()).then_some(value);
597        }
598    }
599}
600
601/// Check if a line is a standalone attr-list (Jekyll/kramdown style)
602///
603/// This detects attr-list syntax that appears on its own line, typically
604/// the line after a header to provide additional attributes.
605///
606/// # Examples
607/// ```
608/// use rumdl_lib::utils::header_id_utils::is_standalone_attr_list;
609///
610/// assert!(is_standalone_attr_list("{#custom-id}"));
611/// assert!(is_standalone_attr_list("{: #spaced .class }"));
612/// assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
613/// assert!(!is_standalone_attr_list(""));
614/// ```
615pub fn is_standalone_attr_list(line: &str) -> bool {
616    if !line.contains('{') {
617        return false;
618    }
619    STANDALONE_ATTR_LIST_PATTERN.is_match(line)
620}
621
622/// Extract ID from a standalone attr-list line
623///
624/// Returns the ID if the line is a valid standalone attr-list with an ID.
625///
626/// # Examples
627/// ```
628/// use rumdl_lib::utils::header_id_utils::extract_standalone_attr_list_id;
629///
630/// assert_eq!(extract_standalone_attr_list_id("{#custom-id}"), Some("custom-id".to_string()));
631/// assert_eq!(extract_standalone_attr_list_id("{: #spaced .class }"), Some("spaced".to_string()));
632/// assert_eq!(extract_standalone_attr_list_id("not an attr-list"), None);
633/// ```
634pub fn extract_standalone_attr_list_id(line: &str) -> Option<String> {
635    if !line.contains('{') {
636        return None;
637    }
638    if let Some(captures) = STANDALONE_ATTR_LIST_PATTERN.captures(line)
639        && let Some(attr_content) = captures.get(1)
640    {
641        let attr_str = attr_content.as_str().trim();
642
643        // Use the same logic as extract_header_id for consistency
644        if let Some(hash_pos) = attr_str.find('#') {
645            let after_hash = &attr_str[hash_pos + 1..];
646
647            // Check if this looks like a simple kramdown ID: {#id} with no spaces or attributes
648            let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
649
650            if is_simple_format {
651                // Simple format: entire content after # should be the ID
652                let potential_id = after_hash;
653                if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
654                    return Some(potential_id.to_string());
655                }
656            } else {
657                // Complex format: find proper delimiters (space for next attribute, dot for class)
658                if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
659                    let potential_id = &after_hash[..delimiter_pos];
660                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
661                        return Some(potential_id.to_string());
662                    }
663                } else {
664                    // No delimiter found in complex format, ID goes to end
665                    let potential_id = after_hash;
666                    if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
667                        return Some(potential_id.to_string());
668                    }
669                }
670            }
671        }
672    }
673    None
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn backtick_lookup_matches_independent_scans() {
682        let tokens = ["x", "`", "``", "```", "\\", "é", "<!--", "-->", "![", "](x)"];
683        for pattern in 0..tokens.len().pow(4) {
684            let mut digits = pattern;
685            let mut text = String::new();
686            for _ in 0..4 {
687                text.push_str(tokens[digits % tokens.len()]);
688                digits /= tokens.len();
689            }
690            let bytes = text.as_bytes();
691            let mut runs = Vec::new();
692            let mut pos = 0;
693            while pos < bytes.len() {
694                if bytes[pos] == b'`' {
695                    let end = pos + bytes[pos..].iter().take_while(|&&byte| byte == b'`').count();
696                    runs.push((end, end - pos));
697                    pos = end;
698                } else {
699                    pos += 1;
700                }
701            }
702            for reverse in [false, true] {
703                let mut queries = runs.clone();
704                if reverse {
705                    queries.reverse();
706                }
707                let mut lookup = BacktickLookup::default();
708                for &(from, len) in queries.iter().cycle().take(queries.len() * 8) {
709                    assert_eq!(
710                        lookup.find(bytes, from, len),
711                        closing_backtick_run(bytes, from, len),
712                        "{text:?}, {from}, {len}"
713                    );
714                }
715            }
716        }
717    }
718
719    #[test]
720    fn indexed_backticks_preserve_opaque_anchor_ranges() {
721        let prefix = "`x ``x ```x ````x ";
722        for suffix in [
723            "`````<a id=hidden></a>````` <a id=live></a>",
724            "<!-- `````<a id=hidden></a> --> <a id=live></a>",
725            "![`````<a id=hidden></a>](image) <a id=live></a>",
726            r"\`````<a id=hidden></a>````` <a id=live></a>",
727        ] {
728            let text = format!("{prefix}{suffix}");
729            assert_eq!(extract_html_anchor_ids(&text), ["live"], "{text:?}");
730        }
731    }
732
733    #[test]
734    fn delimiter_lookup_matches_independent_scans() {
735        let tokens = ["[", "]", "(", ")", "\\", "é"];
736        for pattern in 0..tokens.len().pow(6) {
737            let mut digits = pattern;
738            let mut text = String::new();
739            for _ in 0..6 {
740                text.push_str(tokens[digits % tokens.len()]);
741                digits /= tokens.len();
742            }
743            for (open, close) in [(b'[', b']'), (b'(', b')')] {
744                let offsets: Vec<_> = text
745                    .bytes()
746                    .enumerate()
747                    .filter(|&(pos, byte)| byte == open && !is_backslash_escaped(&text, pos))
748                    .map(|(pos, _)| pos + 1)
749                    .collect();
750                for reverse in [false, true] {
751                    let mut queries = offsets.clone();
752                    if reverse {
753                        queries.reverse();
754                    }
755                    let mut lookup = DelimiterLookup::default();
756                    for &from in queries.iter().cycle().take(queries.len() * 3) {
757                        assert_eq!(
758                            lookup.find(text.as_bytes(), from, open, close),
759                            balanced_end(text.as_bytes(), from, open, close),
760                            "{text:?}, {from}"
761                        );
762                    }
763                }
764            }
765        }
766    }
767
768    #[test]
769    fn malformed_outer_images_preserve_inner_images_and_live_anchors() {
770        for text in [
771            "![![<a id=hidden></a>](image) <a id=live></a>",
772            "![outer]( ![<a id=hidden></a>](image) <a id=live></a>",
773            "![outer][ ![<a id=hidden></a>][label] <a id=live></a>",
774            "![![<a id=hidden></a>](image) <a id=live></a>]",
775            r"![outer\] ![<a id=hidden></a>](image) <a id=live></a>",
776        ] {
777            assert_eq!(extract_html_anchor_ids(text), ["live"], "{text:?}");
778        }
779    }
780
781    #[test]
782    fn display_text_matches_sequential_removal() {
783        fn sequential(slug_text: &str, seams: &[usize]) -> String {
784            let mut text = slug_text.to_string();
785            for &seam in seams.iter().rev() {
786                if seam == 0 || seam >= text.len() {
787                    continue;
788                }
789                let is_blank = |byte: u8| matches!(byte, b' ' | b'\t');
790                if is_blank(text.as_bytes()[seam - 1]) && is_blank(text.as_bytes()[seam]) {
791                    text.remove(seam);
792                }
793            }
794            text.trim().to_string()
795        }
796
797        let tokens = ["a", "é🙂", " ", "\t", "\u{2003}"];
798        for first in tokens {
799            for second in tokens {
800                for third in tokens {
801                    let text = format!("{first}{second}{third}");
802                    let offsets = [0, first.len(), first.len() + second.len(), text.len(), text.len() + 10];
803                    // Zero, one or two anchors at every boundary, including seams
804                    // beyond the text after a trailing attribute list is removed.
805                    for pattern in 0..3_usize.pow(offsets.len() as u32) {
806                        let mut digits = pattern;
807                        let mut seams = Vec::new();
808                        for offset in offsets {
809                            seams.extend(std::iter::repeat_n(offset, digits % 3));
810                            digits /= 3;
811                        }
812                        assert_eq!(
813                            display_text(&text, &seams),
814                            sequential(&text, &seams),
815                            "{text:?} {seams:?}"
816                        );
817                    }
818                }
819            }
820        }
821    }
822
823    #[test]
824    fn display_text_preserves_anchor_spacing_and_slug() {
825        for (source, text, slug) in [
826            ("é <a></a> 🙂", "é 🙂", "é  🙂"),
827            ("a <a></a><a></a>  b", "a b", "a   b"),
828            ("a\t<a></a>\tb", "a\tb", "a\t\tb"),
829            ("a\u{2003}<a></a>\u{2003}b", "a\u{2003}\u{2003}b", "a\u{2003}\u{2003}b"),
830            ("a  <a></a>   b", "a    b", "a     b"),
831            ("a <a></a> {#id <a></a>}", "a", "a"),
832            ("a<a></a>b", "ab", "ab"),
833        ] {
834            let heading = extract_heading_text(source);
835            assert_eq!(heading.text, text, "{source:?}");
836            assert_eq!(heading.slug_text, slug, "{source:?}");
837        }
838    }
839
840    #[test]
841    fn heading_text_end_matches_rescanning() {
842        fn rescanning(line: &str) -> usize {
843            let mut end = line.len();
844            loop {
845                end = line[..end].trim_end().len();
846                if let Some((start, _)) = custom_id_at_end(&line[..end]) {
847                    end = start;
848                } else if let Some((range, _)) = empty_anchor_elements(&line[..end]).pop()
849                    && range.end == end
850                {
851                    end = range.start;
852                } else {
853                    return end;
854                }
855            }
856        }
857
858        // Complete and incomplete constructs deliberately meet at token boundaries.
859        let tokens = [
860            "",
861            "é🙂\u{2003}",
862            " ",
863            "\t",
864            "<a id=x></a>",
865            "<A name='y'> </A>",
866            "<a></a><a></a>",
867            "\\<a></a>",
868            "`",
869            "``",
870            "<!--",
871            "-->",
872            "![",
873            "](x)",
874            "<span title=\"",
875            "\">",
876            "{#id}",
877            "{#bad!}",
878            "{#id x=`}",
879            "{#id x=\">}",
880            "<a></a> {#id} <a></a>",
881            "{#id .class data='value'}",
882            "{#id x=(}",
883            "{#id x=[}",
884            "{#id x=]}",
885            "{#id x=)}",
886            "{#id x=-->}",
887            "{#id x='<'}",
888        ];
889        for first in tokens {
890            for second in tokens {
891                for third in tokens {
892                    let text = format!("# Word {first}{second}{third}");
893                    assert_eq!(heading_text_end(&text), rescanning(&text), "{text:?}");
894                }
895            }
896        }
897    }
898
899    #[test]
900    fn heading_text_end_rescans_after_attribute_list() {
901        for (text, visible) in [
902            ("`<a id=live></a> {#id x=`}", "`"),
903            ("<a></a> `<a></a> {#id x=`} <a></a>", "<a></a> `"),
904            ("<span title=\"<a id=x></a> {#id x=\">} <a></a>", "<span title=\""),
905            ("![<a id=x></a> {#id x=](image)} <a></a>", "!["),
906        ] {
907            let expected = visible.len();
908            assert_eq!(heading_text_end(text), expected, "{text:?}");
909        }
910    }
911
912    #[test]
913    fn test_kramdown_format_extraction() {
914        // Simple kramdown format
915        let (text, id) = extract_header_id("# Header {#simple}");
916        assert_eq!(text, "# Header");
917        assert_eq!(id, Some("simple".to_string()));
918
919        let (text, id) = extract_header_id("## Section {#section-id}");
920        assert_eq!(text, "## Section");
921        assert_eq!(id, Some("section-id".to_string()));
922    }
923
924    #[test]
925    fn test_python_markdown_attr_list_extraction() {
926        // Python-markdown formats
927        let (text, id) = extract_header_id("# Header {:#colon-id}");
928        assert_eq!(text, "# Header");
929        assert_eq!(id, Some("colon-id".to_string()));
930
931        let (text, id) = extract_header_id("# Header {: #spaced-id }");
932        assert_eq!(text, "# Header");
933        assert_eq!(id, Some("spaced-id".to_string()));
934    }
935
936    #[test]
937    fn test_extended_attr_list_extraction() {
938        // ID with single class
939        let (text, id) = extract_header_id("# Header {: #with-class .highlight }");
940        assert_eq!(text, "# Header");
941        assert_eq!(id, Some("with-class".to_string()));
942
943        // ID with multiple classes
944        let (text, id) = extract_header_id("## Section {: #multi .class1 .class2 }");
945        assert_eq!(text, "## Section");
946        assert_eq!(id, Some("multi".to_string()));
947
948        // ID with key-value attributes
949        let (text, id) = extract_header_id("### Subsection {: #with-attrs data-test=\"value\" style=\"color: red\" }");
950        assert_eq!(text, "### Subsection");
951        assert_eq!(id, Some("with-attrs".to_string()));
952
953        // Complex combination
954        let (text, id) = extract_header_id("#### Complex {: #complex .highlight data-role=\"button\" title=\"Test\" }");
955        assert_eq!(text, "#### Complex");
956        assert_eq!(id, Some("complex".to_string()));
957
958        // ID with quotes in attributes
959        let (text, id) = extract_header_id("##### Quotes {: #quotes title=\"Has \\\"nested\\\" quotes\" }");
960        assert_eq!(text, "##### Quotes");
961        assert_eq!(id, Some("quotes".to_string()));
962    }
963
964    #[test]
965    fn test_attr_list_detection_edge_cases() {
966        // Attr-list without ID should not match
967        let (text, id) = extract_header_id("# Header {: .class-only }");
968        assert_eq!(text, "# Header {: .class-only }");
969        assert_eq!(id, None);
970
971        // Malformed attr-list should not match
972        let (text, id) = extract_header_id("# Header { no-hash }");
973        assert_eq!(text, "# Header { no-hash }");
974        assert_eq!(id, None);
975
976        // Empty ID should not match
977        let (text, id) = extract_header_id("# Header {: # }");
978        assert_eq!(text, "# Header {: # }");
979        assert_eq!(id, None);
980
981        // ID in middle (not at end) should not match
982        let (text, id) = extract_header_id("# Header {: #middle } with more text");
983        assert_eq!(text, "# Header {: #middle } with more text");
984        assert_eq!(id, None);
985    }
986
987    #[test]
988    fn test_standalone_attr_list_detection() {
989        // Simple ID formats
990        assert!(is_standalone_attr_list("{#custom-id}"));
991        assert!(is_standalone_attr_list("{ #spaced-id }"));
992        assert!(is_standalone_attr_list("{:#colon-id}"));
993        assert!(is_standalone_attr_list("{: #full-format }"));
994
995        // With classes and attributes
996        assert!(is_standalone_attr_list("{: #with-class .highlight }"));
997        assert!(is_standalone_attr_list("{: #multi .class1 .class2 }"));
998        assert!(is_standalone_attr_list("{: #complex .highlight data-test=\"value\" }"));
999
1000        // Should not match
1001        assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
1002        assert!(!is_standalone_attr_list("Text before {#id}"));
1003        assert!(!is_standalone_attr_list("{#id} text after"));
1004        assert!(!is_standalone_attr_list(""));
1005        assert!(!is_standalone_attr_list("   ")); // just spaces
1006        assert!(!is_standalone_attr_list("{: .class-only }")); // no ID
1007    }
1008
1009    #[test]
1010    fn test_standalone_attr_list_id_extraction() {
1011        // Basic formats
1012        assert_eq!(extract_standalone_attr_list_id("{#simple}"), Some("simple".to_string()));
1013        assert_eq!(
1014            extract_standalone_attr_list_id("{ #spaced }"),
1015            Some("spaced".to_string())
1016        );
1017        assert_eq!(extract_standalone_attr_list_id("{:#colon}"), Some("colon".to_string()));
1018        assert_eq!(extract_standalone_attr_list_id("{: #full }"), Some("full".to_string()));
1019
1020        // With additional attributes
1021        assert_eq!(
1022            extract_standalone_attr_list_id("{: #with-class .highlight }"),
1023            Some("with-class".to_string())
1024        );
1025        assert_eq!(
1026            extract_standalone_attr_list_id("{: #complex .class1 .class2 data=\"value\" }"),
1027            Some("complex".to_string())
1028        );
1029
1030        // Should return None
1031        assert_eq!(extract_standalone_attr_list_id("Not an attr-list"), None);
1032        assert_eq!(extract_standalone_attr_list_id("Text {#not-standalone}"), None);
1033        assert_eq!(extract_standalone_attr_list_id("{: .class-only }"), None);
1034        assert_eq!(extract_standalone_attr_list_id(""), None);
1035    }
1036
1037    #[test]
1038    fn test_backward_compatibility() {
1039        // Ensure all original kramdown formats still work
1040        let test_cases = vec![
1041            ("# Header {#a}", "# Header", Some("a".to_string())),
1042            ("# Header {#simple-id}", "# Header", Some("simple-id".to_string())),
1043            ("## Heading {#heading-2}", "## Heading", Some("heading-2".to_string())),
1044            (
1045                "### With-Hyphens {#with-hyphens}",
1046                "### With-Hyphens",
1047                Some("with-hyphens".to_string()),
1048            ),
1049        ];
1050
1051        for (input, expected_text, expected_id) in test_cases {
1052            let (text, id) = extract_header_id(input);
1053            assert_eq!(text, expected_text, "Text mismatch for input: {input}");
1054            assert_eq!(id, expected_id, "ID mismatch for input: {input}");
1055        }
1056    }
1057
1058    #[test]
1059    fn test_invalid_id_with_dots() {
1060        // IDs with dots should not be extracted (dots are not valid ID characters)
1061        let (text, id) = extract_header_id("## Another. {#id.with.dots}");
1062        assert_eq!(text, "## Another. {#id.with.dots}"); // Should not strip invalid ID
1063        assert_eq!(id, None); // Should not extract invalid ID
1064
1065        // Test that only the part before the dot would be extracted if it was valid standalone
1066        // But since it's in an invalid format, the whole thing should be rejected
1067        let (text, id) = extract_header_id("## Another. {#id.more.dots}");
1068        assert_eq!(text, "## Another. {#id.more.dots}");
1069        assert_eq!(id, None);
1070    }
1071
1072    #[test]
1073    fn test_html_anchor_stripping() {
1074        // HTML anchor elements should be stripped from heading text
1075        // This is used by some authors for custom anchors
1076
1077        // Basic <a name="..."></a> pattern
1078        let (text, id) = extract_header_id("<a name=\"cheatsheets\"></a>Cheat Sheets");
1079        assert_eq!(text, "Cheat Sheets");
1080        assert_eq!(id, None);
1081
1082        // <a id="..."></a> pattern
1083        let (text, id) = extract_header_id("<a id=\"tools\"></a>Tools and session management");
1084        assert_eq!(text, "Tools and session management");
1085        assert_eq!(id, None);
1086
1087        // With spaces around the anchor
1088        let (text, id) = extract_header_id("<a name=\"foo\"></a> Heading with space");
1089        assert_eq!(text, "Heading with space");
1090        assert_eq!(id, None);
1091
1092        // Combined with kramdown custom ID
1093        let (text, id) = extract_header_id("<a name=\"old\"></a>My Section {#my-custom-id}");
1094        assert_eq!(text, "My Section");
1095        assert_eq!(id, Some("my-custom-id".to_string()));
1096    }
1097
1098    #[test]
1099    fn test_html_anchor_ids_are_read_from_empty_anchor_elements_in_order() {
1100        assert_eq!(extract_html_anchor_ids(r#"Heading<a id="target"></a>"#), ["target"]);
1101        assert_eq!(
1102            extract_html_anchor_ids(r#"<A class="legacy" NAME='fallback' ID='preferred'></A>Heading"#),
1103            ["preferred"]
1104        );
1105        assert_eq!(
1106            extract_html_anchor_ids(r#"<a name='legacy'></a><a id="newer"></a>Heading"#),
1107            ["legacy", "newer"]
1108        );
1109        assert_eq!(extract_html_anchor_ids("<a id=plain></a>Heading"), ["plain"]);
1110        assert!(extract_html_anchor_ids(r##"<a href="#target"></a>Heading"##).is_empty());
1111        assert!(extract_html_anchor_ids(r#"<span id="target"></span>Heading"#).is_empty());
1112        assert!(extract_html_anchor_ids(r#"<a id="target">text</a>Heading"#).is_empty());
1113    }
1114
1115    #[test]
1116    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
1117        assert!(extract_html_anchor_ids(r#"Foo<a data-id="tracking" data-name="pixel"></a>"#).is_empty());
1118        assert!(extract_html_anchor_ids(r#"Foo<a id=""></a>"#).is_empty());
1119    }
1120
1121    #[test]
1122    fn test_a_quoted_attribute_value_may_contain_a_closing_angle_bracket() {
1123        let raw = r#"<a title="a > b" id="target"></a>Heading"#;
1124        assert_eq!(extract_html_anchor_ids(raw), ["target"]);
1125        assert_eq!(extract_header_id(raw), ("Heading".to_string(), None));
1126    }
1127
1128    #[test]
1129    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
1130        let raw = "Showing `<a id=\"literal\"></a>` syntax";
1131        assert!(extract_html_anchor_ids(raw).is_empty());
1132        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
1133
1134        // A real anchor beside the code span is still found and stripped.
1135        let raw = "Showing `<a id=\"literal\"></a>` syntax<a id=\"real\"></a>";
1136        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
1137        assert_eq!(extract_header_id(raw).0, "Showing `<a id=\"literal\"></a>` syntax");
1138    }
1139
1140    #[test]
1141    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
1142        let raw = "Foo <!-- <a id=\"hidden\"></a> -->";
1143        assert!(extract_html_anchor_ids(raw).is_empty());
1144        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
1145    }
1146
1147    #[test]
1148    fn test_html_tag_attribute_matches_whole_names_case_insensitively() {
1149        assert_eq!(html_tag_attribute(r#"<div data-id="x" ID="y">"#, "id"), Some("y"));
1150        assert_eq!(html_tag_attribute("<a name = 'legacy' >", "name"), Some("legacy"));
1151        assert_eq!(html_tag_attribute("<a id=plain>", "id"), Some("plain"));
1152        assert_eq!(html_tag_attribute(r#"<a id="first" id="second">"#, "id"), Some("first"));
1153        assert_eq!(html_tag_attribute(r#"<a id="">"#, "id"), None);
1154        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "hidden"), None);
1155        assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "id"), Some("x"));
1156        assert_eq!(html_tag_attribute(r#"<a title="a > b" id="x">"#, "id"), Some("x"));
1157        assert_eq!(html_tag_attribute(r#"<video title="id=fake">"#, "id"), None);
1158        assert_eq!(html_tag_attribute("<br/>", "id"), None);
1159    }
1160
1161    #[test]
1162    fn test_html_anchor_stripping_handles_attribute_variations() {
1163        let (text, id) = extract_header_id(r#"<A class="legacy" ID='target'></A>Heading"#);
1164        assert_eq!(text, "Heading");
1165        assert_eq!(id, None);
1166    }
1167
1168    #[test]
1169    fn test_a_backslash_escaped_anchor_element_is_heading_text() {
1170        // `\<` is a literal `<`, so the markup renders as text and defines no
1171        // target. An escaped backslash before the `<` leaves it a tag.
1172        let raw = r#"Show \<a id="example"></a> syntax"#;
1173        assert!(extract_html_anchor_ids(raw).is_empty());
1174        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
1175
1176        let raw = r#"Show \\<a id="example"></a> syntax"#;
1177        assert_eq!(extract_html_anchor_ids(raw), ["example"]);
1178        assert_eq!(extract_header_id(raw).0, r"Show \\ syntax");
1179    }
1180
1181    #[test]
1182    fn test_stripping_an_anchor_element_keeps_the_whitespace_beside_it() {
1183        // The element renders nothing, so removing exactly its bytes leaves the
1184        // text the browser shows: the space between the words stays. The display
1185        // text is trimmed at either end, as CommonMark trims a heading, and an
1186        // element between two spaces takes one of them with it, as a browser
1187        // shows adjacent spaces as one. The slug text keeps every space, since
1188        // GitHub and kramdown turn each into a hyphen.
1189        let cases = [
1190            (r#"Foo<a id="alias"></a> Bar"#, "Foo Bar", "Foo Bar"),
1191            (r#"Foo <a id="alias"></a>Bar"#, "Foo Bar", "Foo Bar"),
1192            (r#"Foo <a id="alias"></a> Bar"#, "Foo Bar", "Foo  Bar"),
1193            (r#"Foo <a id="a"></a> <a id="b"></a> Bar"#, "Foo Bar", "Foo   Bar"),
1194            (r#"Foo  <a id="alias"></a>Bar"#, "Foo  Bar", "Foo  Bar"),
1195            (r#"<a id="alias"></a> Foo"#, "Foo", " Foo"),
1196            (r#"Foo <a id="alias"></a>"#, "Foo", "Foo "),
1197            (r#"<a id="a"></a> Foo <a id="b"></a>"#, "Foo", " Foo "),
1198        ];
1199        for (raw, text, slug_text) in cases {
1200            let heading = extract_heading_text(raw);
1201            assert_eq!(heading.text, text, "display text of {raw:?}");
1202            assert_eq!(heading.slug_text, slug_text, "slug text of {raw:?}");
1203            assert_eq!(heading.custom_id, None, "custom ID of {raw:?}");
1204        }
1205        assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>"#).0, "Foo");
1206    }
1207
1208    #[test]
1209    fn test_an_anchor_element_inside_an_image_description_is_text() {
1210        // The description becomes the image's alt text, in which markup is
1211        // escaped, so the page has no element and no target. Nested brackets
1212        // and a reference destination still make an image.
1213        for raw in [
1214            r#"![<a id="x"></a>](img.png)"#,
1215            r#"![see [docs] <a id="x"></a>](img.png "title")"#,
1216            r#"![<a id="x"></a>][ref]"#,
1217        ] {
1218            assert!(extract_html_anchor_ids(raw).is_empty(), "{raw}");
1219            assert_eq!(extract_header_id(raw), (raw.to_string(), None), "{raw}");
1220        }
1221
1222        // A backslash before the `!` leaves a link, whose text does render markup.
1223        let raw = r#"\![<a id="x"></a>](img.png)"#;
1224        assert_eq!(extract_html_anchor_ids(raw), ["x"]);
1225        assert_eq!(extract_header_id(raw).0, r"\![](img.png)");
1226
1227        // An element beside the image is still one.
1228        let raw = r#"![alt](img.png) <a id="after"></a>"#;
1229        assert_eq!(extract_html_anchor_ids(raw), ["after"]);
1230        let heading = extract_heading_text(raw);
1231        assert_eq!(heading.text, "![alt](img.png)");
1232        assert_eq!(heading.slug_text, "![alt](img.png) ");
1233    }
1234
1235    #[test]
1236    fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
1237        // The `<a>` is part of the span's `title` value, so the browser creates
1238        // no element from it and the heading keeps its bytes.
1239        let raw = r#"<span title='<a id="fake"></a>'>Foo</span>"#;
1240        assert!(extract_html_anchor_ids(raw).is_empty());
1241        assert_eq!(extract_header_id(raw), (raw.to_string(), None));
1242
1243        let raw = r#"<span title='x'><a id="real"></a>Foo</span>"#;
1244        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
1245        assert_eq!(extract_header_id(raw).0, "<span title='x'>Foo</span>");
1246    }
1247
1248    #[test]
1249    fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
1250        // `\<span` is text, so the `<a>` where its attribute value would be is a
1251        // real element.
1252        let raw = r#"\<span title='<a id="real"></a>'>Foo"#;
1253        assert_eq!(extract_html_anchor_ids(raw), ["real"]);
1254        assert_eq!(extract_header_id(raw).0, r#"\<span title=''>Foo"#);
1255    }
1256
1257    #[test]
1258    fn test_a_degenerate_comment_ends_at_its_own_closer() {
1259        // `<!-->` and `<!--->` are complete comments, so the anchor after them is
1260        // an element and the later `-->` is text.
1261        assert_eq!(extract_html_anchor_ids(r#"<!--> <a id="x"></a> --> Foo"#), ["x"]);
1262        assert_eq!(extract_html_anchor_ids(r#"<!---> <a id="y"></a> --> Foo"#), ["y"]);
1263        assert!(extract_html_anchor_ids(r#"<!-- <a id="z"></a> --> Foo"#).is_empty());
1264    }
1265
1266    #[test]
1267    fn test_is_backslash_escaped_counts_the_run_of_backslashes() {
1268        assert!(!is_backslash_escaped("<a>", 0));
1269        assert!(is_backslash_escaped(r"\<a>", 1));
1270        assert!(!is_backslash_escaped(r"\\<a>", 2));
1271        assert!(is_backslash_escaped(r"\\\<a>", 3));
1272        assert!(!is_backslash_escaped(r"x<a>", 1));
1273    }
1274}