1use regex::Regex;
39use std::borrow::Cow;
40use std::sync::LazyLock;
41
42pub const HTML_TAG_NAME_PATTERN: &str = "[A-Za-z][A-Za-z0-9-]*";
44
45pub const HTML_TAG_ATTRIBUTES_PATTERN: &str = r#"(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*"#;
50
51pub 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
59pub const HTML_BLOCK_TAG_NAME_PATTERN: &str = r"[A-Za-z][^\s/>]*";
62
63pub 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
73static HTML_ANCHOR_CLOSING_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*</a\s*>").unwrap());
76
77static HEADER_ID_PATTERN: LazyLock<Regex> =
82 LazyLock::new(|| Regex::new(r"\s*\{\s*:?\s*([^}]*?#[^}]*?)\s*\}\s*$").unwrap());
83
84static ID_VALIDATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-:]+$").unwrap());
86
87static STANDALONE_ATTR_LIST_PATTERN: LazyLock<Regex> =
90 LazyLock::new(|| Regex::new(r"^\s*\{\s*:?\s*([^}]*#[a-zA-Z0-9_\-:]+[^}]*)\s*\}\s*$").unwrap());
91
92pub 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct HeadingText {
121 pub text: String,
125 pub slug_text: String,
131 pub custom_id: Option<String>,
133}
134
135pub fn extract_heading_text(line: &str) -> HeadingText {
140 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
156fn custom_id_at_end(line: &str) -> Option<(usize, String)> {
160 if !line.contains('{') {
161 return None;
162 }
163 let captures = HEADER_ID_PATTERN.captures(line)?;
164 let attr_list_start = captures.get(0)?.start();
165 let attr_str = captures.get(1)?.as_str().trim();
166 let hash_pos = attr_str.find('#')?;
167 let after_hash = &attr_str[hash_pos + 1..];
168
169 let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
173 let potential_id = if is_simple_format {
174 after_hash
175 } else {
176 match after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
177 Some(delimiter_pos) => &after_hash[..delimiter_pos],
178 None => after_hash,
179 }
180 };
181
182 (!potential_id.is_empty() && ID_VALIDATE_PATTERN.is_match(potential_id))
183 .then(|| (attr_list_start, potential_id.to_string()))
184}
185
186pub fn heading_text_end(line: &str) -> usize {
191 let mut end = line.len();
192 loop {
193 end = line[..end].trim_end().len();
194 if let Some((attr_list_start, _)) = custom_id_at_end(&line[..end]) {
195 end = attr_list_start;
196 } else if let Some((range, _)) = empty_anchor_elements(&line[..end]).pop()
197 && range.end == end
198 {
199 end = range.start;
200 } else {
201 return end;
202 }
203 }
204}
205
206fn strip_html_anchor_elements(text: &str) -> (Cow<'_, str>, Vec<usize>) {
214 let anchors = empty_anchor_elements(text);
215 if anchors.is_empty() {
216 return (Cow::Borrowed(text), Vec::new());
217 }
218
219 let mut stripped = String::with_capacity(text.len());
220 let mut seams = Vec::with_capacity(anchors.len());
221 let mut copied_up_to = 0;
222 for (range, _) in anchors {
223 stripped.push_str(&text[copied_up_to..range.start]);
224 seams.push(stripped.len());
225 copied_up_to = range.end;
226 }
227 stripped.push_str(&text[copied_up_to..]);
228 (Cow::Owned(stripped), seams)
229}
230
231fn display_text(slug_text: &str, seams: &[usize]) -> String {
238 if seams.is_empty() {
239 return slug_text.trim().to_string();
240 }
241 let mut text = slug_text.to_string();
242 for &seam in seams.iter().rev() {
243 if seam == 0 || seam >= text.len() {
244 continue;
245 }
246 let is_blank = |byte: u8| matches!(byte, b' ' | b'\t');
247 if is_blank(text.as_bytes()[seam - 1]) && is_blank(text.as_bytes()[seam]) {
248 text.remove(seam);
249 }
250 }
251 text.trim().to_string()
252}
253
254pub fn extract_html_anchor_ids(text: &str) -> Vec<String> {
260 empty_anchor_elements(text)
261 .into_iter()
262 .filter_map(|(_, open_tag)| html_tag_attribute(open_tag, "id").or_else(|| html_tag_attribute(open_tag, "name")))
263 .map(str::to_string)
264 .collect()
265}
266
267fn empty_anchor_elements(text: &str) -> Vec<(std::ops::Range<usize>, &str)> {
276 if !text.contains('<') {
277 return Vec::new();
278 }
279
280 let opaque = opaque_ranges(text);
281 let mut anchors = Vec::new();
282 let mut pos = 0;
283 while let Some(tag) = HTML_OPEN_TAG.captures_at(text, pos) {
284 let open_tag = tag.get(0).unwrap();
285 if is_within(&opaque, open_tag.start()) || is_backslash_escaped(text, open_tag.start()) {
286 pos = open_tag.start() + 1;
287 continue;
288 }
289 pos = open_tag.end();
290
291 if !tag[1].eq_ignore_ascii_case("a") {
292 continue;
293 }
294 if let Some(closing_tag) = HTML_ANCHOR_CLOSING_TAG.find(&text[open_tag.end()..]) {
295 pos = open_tag.end() + closing_tag.end();
296 anchors.push((open_tag.start()..pos, open_tag.as_str()));
297 }
298 }
299
300 anchors
301}
302
303fn opaque_ranges(text: &str) -> Vec<(usize, usize)> {
313 let bytes = text.as_bytes();
314 let mut ranges = Vec::new();
315 let mut pos = 0;
316
317 while pos < bytes.len() {
318 if bytes[pos] == b'`' {
319 let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
320 let run_len = run_end - pos;
321 match closing_backtick_run(bytes, run_end, run_len) {
322 Some(close_start) => {
323 ranges.push((pos, close_start + run_len));
324 pos = close_start + run_len;
325 }
326 None => pos = run_end,
327 }
328 } else if bytes[pos..].starts_with(b"<!--") {
329 let end = text[pos + 2..]
330 .find("-->")
331 .map_or(bytes.len(), |offset| pos + 2 + offset + 3);
332 ranges.push((pos, end));
333 pos = end;
334 } else if bytes[pos..].starts_with(b"![")
335 && !is_backslash_escaped(text, pos)
336 && !is_backslash_escaped(text, pos + 1)
337 && let Some(end) = image_end(text, pos)
338 {
339 ranges.push((pos, end));
340 pos = end;
341 } else {
342 pos += 1;
343 }
344 }
345
346 ranges
347}
348
349fn image_end(text: &str, start: usize) -> Option<usize> {
357 let bytes = text.as_bytes();
358 let description_end = balanced_end(bytes, start + 2, b'[', b']')?;
359 match bytes.get(description_end) {
360 Some(b'(') => balanced_end(bytes, description_end + 1, b'(', b')'),
361 Some(b'[') => balanced_end(bytes, description_end + 1, b'[', b']'),
362 _ => None,
363 }
364}
365
366fn balanced_end(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
369 let mut depth = 1usize;
370 let mut pos = from;
371 while pos < bytes.len() {
372 match bytes[pos] {
373 b'\\' => pos += 1,
374 byte if byte == open => depth += 1,
375 byte if byte == close => {
376 depth -= 1;
377 if depth == 0 {
378 return Some(pos + 1);
379 }
380 }
381 _ => {}
382 }
383 pos += 1;
384 }
385 None
386}
387
388fn closing_backtick_run(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
390 let mut pos = from;
391 while pos < bytes.len() {
392 if bytes[pos] != b'`' {
393 pos += 1;
394 continue;
395 }
396 let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
397 if run_end - pos == run_len {
398 return Some(pos);
399 }
400 pos = run_end;
401 }
402 None
403}
404
405fn is_within(ranges: &[(usize, usize)], pos: usize) -> bool {
406 ranges.iter().any(|&(start, end)| start <= pos && pos < end)
407}
408
409pub fn is_backslash_escaped(text: &str, pos: usize) -> bool {
415 text.as_bytes()[..pos].iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 1
416}
417
418pub fn html_tag_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
424 let bytes = tag.as_bytes();
425 if bytes.first() != Some(&b'<') {
426 return None;
427 }
428
429 let ends_name = |b: u8| b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/');
430 let mut pos = 1 + bytes[1..].iter().take_while(|&&b| !ends_name(b)).count();
431
432 loop {
433 pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
434 if pos >= bytes.len() || matches!(bytes[pos], b'>' | b'/') {
435 return None;
436 }
437
438 let name_start = pos;
439 pos += bytes[pos..].iter().take_while(|&&b| !ends_name(b)).count();
440 let attribute = &tag[name_start..pos];
441
442 let after_name = pos + bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
443 let value = if bytes.get(after_name) == Some(&b'=') {
444 let value_start = after_name
445 + 1
446 + bytes[after_name + 1..]
447 .iter()
448 .take_while(|b| b.is_ascii_whitespace())
449 .count();
450 match bytes.get(value_start) {
451 Some("e @ (b'"' | b'\'')) => {
452 let value_end = value_start + 1 + tag[value_start + 1..].find(quote as char)?;
453 pos = value_end + 1;
454 &tag[value_start + 1..value_end]
455 }
456 _ => {
457 let value_end = value_start
458 + bytes[value_start..]
459 .iter()
460 .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
461 .count();
462 pos = value_end;
463 &tag[value_start..value_end]
464 }
465 }
466 } else {
467 ""
468 };
469
470 if attribute.eq_ignore_ascii_case(name) {
471 return (!value.is_empty()).then_some(value);
472 }
473 }
474}
475
476pub fn is_standalone_attr_list(line: &str) -> bool {
491 if !line.contains('{') {
492 return false;
493 }
494 STANDALONE_ATTR_LIST_PATTERN.is_match(line)
495}
496
497pub fn extract_standalone_attr_list_id(line: &str) -> Option<String> {
510 if !line.contains('{') {
511 return None;
512 }
513 if let Some(captures) = STANDALONE_ATTR_LIST_PATTERN.captures(line)
514 && let Some(attr_content) = captures.get(1)
515 {
516 let attr_str = attr_content.as_str().trim();
517
518 if let Some(hash_pos) = attr_str.find('#') {
520 let after_hash = &attr_str[hash_pos + 1..];
521
522 let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
524
525 if is_simple_format {
526 let potential_id = after_hash;
528 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
529 return Some(potential_id.to_string());
530 }
531 } else {
532 if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
534 let potential_id = &after_hash[..delimiter_pos];
535 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
536 return Some(potential_id.to_string());
537 }
538 } else {
539 let potential_id = after_hash;
541 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
542 return Some(potential_id.to_string());
543 }
544 }
545 }
546 }
547 }
548 None
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn test_kramdown_format_extraction() {
557 let (text, id) = extract_header_id("# Header {#simple}");
559 assert_eq!(text, "# Header");
560 assert_eq!(id, Some("simple".to_string()));
561
562 let (text, id) = extract_header_id("## Section {#section-id}");
563 assert_eq!(text, "## Section");
564 assert_eq!(id, Some("section-id".to_string()));
565 }
566
567 #[test]
568 fn test_python_markdown_attr_list_extraction() {
569 let (text, id) = extract_header_id("# Header {:#colon-id}");
571 assert_eq!(text, "# Header");
572 assert_eq!(id, Some("colon-id".to_string()));
573
574 let (text, id) = extract_header_id("# Header {: #spaced-id }");
575 assert_eq!(text, "# Header");
576 assert_eq!(id, Some("spaced-id".to_string()));
577 }
578
579 #[test]
580 fn test_extended_attr_list_extraction() {
581 let (text, id) = extract_header_id("# Header {: #with-class .highlight }");
583 assert_eq!(text, "# Header");
584 assert_eq!(id, Some("with-class".to_string()));
585
586 let (text, id) = extract_header_id("## Section {: #multi .class1 .class2 }");
588 assert_eq!(text, "## Section");
589 assert_eq!(id, Some("multi".to_string()));
590
591 let (text, id) = extract_header_id("### Subsection {: #with-attrs data-test=\"value\" style=\"color: red\" }");
593 assert_eq!(text, "### Subsection");
594 assert_eq!(id, Some("with-attrs".to_string()));
595
596 let (text, id) = extract_header_id("#### Complex {: #complex .highlight data-role=\"button\" title=\"Test\" }");
598 assert_eq!(text, "#### Complex");
599 assert_eq!(id, Some("complex".to_string()));
600
601 let (text, id) = extract_header_id("##### Quotes {: #quotes title=\"Has \\\"nested\\\" quotes\" }");
603 assert_eq!(text, "##### Quotes");
604 assert_eq!(id, Some("quotes".to_string()));
605 }
606
607 #[test]
608 fn test_attr_list_detection_edge_cases() {
609 let (text, id) = extract_header_id("# Header {: .class-only }");
611 assert_eq!(text, "# Header {: .class-only }");
612 assert_eq!(id, None);
613
614 let (text, id) = extract_header_id("# Header { no-hash }");
616 assert_eq!(text, "# Header { no-hash }");
617 assert_eq!(id, None);
618
619 let (text, id) = extract_header_id("# Header {: # }");
621 assert_eq!(text, "# Header {: # }");
622 assert_eq!(id, None);
623
624 let (text, id) = extract_header_id("# Header {: #middle } with more text");
626 assert_eq!(text, "# Header {: #middle } with more text");
627 assert_eq!(id, None);
628 }
629
630 #[test]
631 fn test_standalone_attr_list_detection() {
632 assert!(is_standalone_attr_list("{#custom-id}"));
634 assert!(is_standalone_attr_list("{ #spaced-id }"));
635 assert!(is_standalone_attr_list("{:#colon-id}"));
636 assert!(is_standalone_attr_list("{: #full-format }"));
637
638 assert!(is_standalone_attr_list("{: #with-class .highlight }"));
640 assert!(is_standalone_attr_list("{: #multi .class1 .class2 }"));
641 assert!(is_standalone_attr_list("{: #complex .highlight data-test=\"value\" }"));
642
643 assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
645 assert!(!is_standalone_attr_list("Text before {#id}"));
646 assert!(!is_standalone_attr_list("{#id} text after"));
647 assert!(!is_standalone_attr_list(""));
648 assert!(!is_standalone_attr_list(" ")); assert!(!is_standalone_attr_list("{: .class-only }")); }
651
652 #[test]
653 fn test_standalone_attr_list_id_extraction() {
654 assert_eq!(extract_standalone_attr_list_id("{#simple}"), Some("simple".to_string()));
656 assert_eq!(
657 extract_standalone_attr_list_id("{ #spaced }"),
658 Some("spaced".to_string())
659 );
660 assert_eq!(extract_standalone_attr_list_id("{:#colon}"), Some("colon".to_string()));
661 assert_eq!(extract_standalone_attr_list_id("{: #full }"), Some("full".to_string()));
662
663 assert_eq!(
665 extract_standalone_attr_list_id("{: #with-class .highlight }"),
666 Some("with-class".to_string())
667 );
668 assert_eq!(
669 extract_standalone_attr_list_id("{: #complex .class1 .class2 data=\"value\" }"),
670 Some("complex".to_string())
671 );
672
673 assert_eq!(extract_standalone_attr_list_id("Not an attr-list"), None);
675 assert_eq!(extract_standalone_attr_list_id("Text {#not-standalone}"), None);
676 assert_eq!(extract_standalone_attr_list_id("{: .class-only }"), None);
677 assert_eq!(extract_standalone_attr_list_id(""), None);
678 }
679
680 #[test]
681 fn test_backward_compatibility() {
682 let test_cases = vec![
684 ("# Header {#a}", "# Header", Some("a".to_string())),
685 ("# Header {#simple-id}", "# Header", Some("simple-id".to_string())),
686 ("## Heading {#heading-2}", "## Heading", Some("heading-2".to_string())),
687 (
688 "### With-Hyphens {#with-hyphens}",
689 "### With-Hyphens",
690 Some("with-hyphens".to_string()),
691 ),
692 ];
693
694 for (input, expected_text, expected_id) in test_cases {
695 let (text, id) = extract_header_id(input);
696 assert_eq!(text, expected_text, "Text mismatch for input: {input}");
697 assert_eq!(id, expected_id, "ID mismatch for input: {input}");
698 }
699 }
700
701 #[test]
702 fn test_invalid_id_with_dots() {
703 let (text, id) = extract_header_id("## Another. {#id.with.dots}");
705 assert_eq!(text, "## Another. {#id.with.dots}"); assert_eq!(id, None); let (text, id) = extract_header_id("## Another. {#id.more.dots}");
711 assert_eq!(text, "## Another. {#id.more.dots}");
712 assert_eq!(id, None);
713 }
714
715 #[test]
716 fn test_html_anchor_stripping() {
717 let (text, id) = extract_header_id("<a name=\"cheatsheets\"></a>Cheat Sheets");
722 assert_eq!(text, "Cheat Sheets");
723 assert_eq!(id, None);
724
725 let (text, id) = extract_header_id("<a id=\"tools\"></a>Tools and session management");
727 assert_eq!(text, "Tools and session management");
728 assert_eq!(id, None);
729
730 let (text, id) = extract_header_id("<a name=\"foo\"></a> Heading with space");
732 assert_eq!(text, "Heading with space");
733 assert_eq!(id, None);
734
735 let (text, id) = extract_header_id("<a name=\"old\"></a>My Section {#my-custom-id}");
737 assert_eq!(text, "My Section");
738 assert_eq!(id, Some("my-custom-id".to_string()));
739 }
740
741 #[test]
742 fn test_html_anchor_ids_are_read_from_empty_anchor_elements_in_order() {
743 assert_eq!(extract_html_anchor_ids(r#"Heading<a id="target"></a>"#), ["target"]);
744 assert_eq!(
745 extract_html_anchor_ids(r#"<A class="legacy" NAME='fallback' ID='preferred'></A>Heading"#),
746 ["preferred"]
747 );
748 assert_eq!(
749 extract_html_anchor_ids(r#"<a name='legacy'></a><a id="newer"></a>Heading"#),
750 ["legacy", "newer"]
751 );
752 assert_eq!(extract_html_anchor_ids("<a id=plain></a>Heading"), ["plain"]);
753 assert!(extract_html_anchor_ids(r##"<a href="#target"></a>Heading"##).is_empty());
754 assert!(extract_html_anchor_ids(r#"<span id="target"></span>Heading"#).is_empty());
755 assert!(extract_html_anchor_ids(r#"<a id="target">text</a>Heading"#).is_empty());
756 }
757
758 #[test]
759 fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
760 assert!(extract_html_anchor_ids(r#"Foo<a data-id="tracking" data-name="pixel"></a>"#).is_empty());
761 assert!(extract_html_anchor_ids(r#"Foo<a id=""></a>"#).is_empty());
762 }
763
764 #[test]
765 fn test_a_quoted_attribute_value_may_contain_a_closing_angle_bracket() {
766 let raw = r#"<a title="a > b" id="target"></a>Heading"#;
767 assert_eq!(extract_html_anchor_ids(raw), ["target"]);
768 assert_eq!(extract_header_id(raw), ("Heading".to_string(), None));
769 }
770
771 #[test]
772 fn test_anchor_markup_inside_a_code_span_is_heading_text() {
773 let raw = "Showing `<a id=\"literal\"></a>` syntax";
774 assert!(extract_html_anchor_ids(raw).is_empty());
775 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
776
777 let raw = "Showing `<a id=\"literal\"></a>` syntax<a id=\"real\"></a>";
779 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
780 assert_eq!(extract_header_id(raw).0, "Showing `<a id=\"literal\"></a>` syntax");
781 }
782
783 #[test]
784 fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
785 let raw = "Foo <!-- <a id=\"hidden\"></a> -->";
786 assert!(extract_html_anchor_ids(raw).is_empty());
787 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
788 }
789
790 #[test]
791 fn test_html_tag_attribute_matches_whole_names_case_insensitively() {
792 assert_eq!(html_tag_attribute(r#"<div data-id="x" ID="y">"#, "id"), Some("y"));
793 assert_eq!(html_tag_attribute("<a name = 'legacy' >", "name"), Some("legacy"));
794 assert_eq!(html_tag_attribute("<a id=plain>", "id"), Some("plain"));
795 assert_eq!(html_tag_attribute(r#"<a id="first" id="second">"#, "id"), Some("first"));
796 assert_eq!(html_tag_attribute(r#"<a id="">"#, "id"), None);
797 assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "hidden"), None);
798 assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "id"), Some("x"));
799 assert_eq!(html_tag_attribute(r#"<a title="a > b" id="x">"#, "id"), Some("x"));
800 assert_eq!(html_tag_attribute(r#"<video title="id=fake">"#, "id"), None);
801 assert_eq!(html_tag_attribute("<br/>", "id"), None);
802 }
803
804 #[test]
805 fn test_html_anchor_stripping_handles_attribute_variations() {
806 let (text, id) = extract_header_id(r#"<A class="legacy" ID='target'></A>Heading"#);
807 assert_eq!(text, "Heading");
808 assert_eq!(id, None);
809 }
810
811 #[test]
812 fn test_a_backslash_escaped_anchor_element_is_heading_text() {
813 let raw = r#"Show \<a id="example"></a> syntax"#;
816 assert!(extract_html_anchor_ids(raw).is_empty());
817 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
818
819 let raw = r#"Show \\<a id="example"></a> syntax"#;
820 assert_eq!(extract_html_anchor_ids(raw), ["example"]);
821 assert_eq!(extract_header_id(raw).0, r"Show \\ syntax");
822 }
823
824 #[test]
825 fn test_stripping_an_anchor_element_keeps_the_whitespace_beside_it() {
826 let cases = [
833 (r#"Foo<a id="alias"></a> Bar"#, "Foo Bar", "Foo Bar"),
834 (r#"Foo <a id="alias"></a>Bar"#, "Foo Bar", "Foo Bar"),
835 (r#"Foo <a id="alias"></a> Bar"#, "Foo Bar", "Foo Bar"),
836 (r#"Foo <a id="a"></a> <a id="b"></a> Bar"#, "Foo Bar", "Foo Bar"),
837 (r#"Foo <a id="alias"></a>Bar"#, "Foo Bar", "Foo Bar"),
838 (r#"<a id="alias"></a> Foo"#, "Foo", " Foo"),
839 (r#"Foo <a id="alias"></a>"#, "Foo", "Foo "),
840 (r#"<a id="a"></a> Foo <a id="b"></a>"#, "Foo", " Foo "),
841 ];
842 for (raw, text, slug_text) in cases {
843 let heading = extract_heading_text(raw);
844 assert_eq!(heading.text, text, "display text of {raw:?}");
845 assert_eq!(heading.slug_text, slug_text, "slug text of {raw:?}");
846 assert_eq!(heading.custom_id, None, "custom ID of {raw:?}");
847 }
848 assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>"#).0, "Foo");
849 }
850
851 #[test]
852 fn test_an_anchor_element_inside_an_image_description_is_text() {
853 for raw in [
857 r#""#,
858 r#"![see [docs] <a id="x"></a>](img.png "title")"#,
859 r#"![<a id="x"></a>][ref]"#,
860 ] {
861 assert!(extract_html_anchor_ids(raw).is_empty(), "{raw}");
862 assert_eq!(extract_header_id(raw), (raw.to_string(), None), "{raw}");
863 }
864
865 let raw = r#"\"#;
867 assert_eq!(extract_html_anchor_ids(raw), ["x"]);
868 assert_eq!(extract_header_id(raw).0, r"\");
869
870 let raw = r#" <a id="after"></a>"#;
872 assert_eq!(extract_html_anchor_ids(raw), ["after"]);
873 let heading = extract_heading_text(raw);
874 assert_eq!(heading.text, "");
875 assert_eq!(heading.slug_text, " ");
876 }
877
878 #[test]
879 fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
880 let raw = r#"<span title='<a id="fake"></a>'>Foo</span>"#;
883 assert!(extract_html_anchor_ids(raw).is_empty());
884 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
885
886 let raw = r#"<span title='x'><a id="real"></a>Foo</span>"#;
887 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
888 assert_eq!(extract_header_id(raw).0, "<span title='x'>Foo</span>");
889 }
890
891 #[test]
892 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
893 let raw = r#"\<span title='<a id="real"></a>'>Foo"#;
896 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
897 assert_eq!(extract_header_id(raw).0, r#"\<span title=''>Foo"#);
898 }
899
900 #[test]
901 fn test_a_degenerate_comment_ends_at_its_own_closer() {
902 assert_eq!(extract_html_anchor_ids(r#"<!--> <a id="x"></a> --> Foo"#), ["x"]);
905 assert_eq!(extract_html_anchor_ids(r#"<!---> <a id="y"></a> --> Foo"#), ["y"]);
906 assert!(extract_html_anchor_ids(r#"<!-- <a id="z"></a> --> Foo"#).is_empty());
907 }
908
909 #[test]
910 fn test_is_backslash_escaped_counts_the_run_of_backslashes() {
911 assert!(!is_backslash_escaped("<a>", 0));
912 assert!(is_backslash_escaped(r"\<a>", 1));
913 assert!(!is_backslash_escaped(r"\\<a>", 2));
914 assert!(is_backslash_escaped(r"\\\<a>", 3));
915 assert!(!is_backslash_escaped(r"x<a>", 1));
916 }
917}