1use regex::Regex;
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::sync::LazyLock;
42
43pub const HTML_TAG_NAME_PATTERN: &str = "[A-Za-z][A-Za-z0-9-]*";
45
46pub const HTML_TAG_ATTRIBUTES_PATTERN: &str = r#"(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*"#;
51
52pub 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
60pub const HTML_BLOCK_TAG_NAME_PATTERN: &str = r"[A-Za-z][^\s/>]*";
63
64pub 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
74static HTML_ANCHOR_CLOSING_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*</a\s*>").unwrap());
77
78static HEADER_ID_PATTERN: LazyLock<Regex> =
83 LazyLock::new(|| Regex::new(r"\s*\{\s*:?\s*([^}]*?#[^}]*?)\s*\}\s*$").unwrap());
84
85static ID_VALIDATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-:]+$").unwrap());
87
88static STANDALONE_ATTR_LIST_PATTERN: LazyLock<Regex> =
91 LazyLock::new(|| Regex::new(r"^\s*\{\s*:?\s*([^}]*#[a-zA-Z0-9_\-:]+[^}]*)\s*\}\s*$").unwrap());
92
93pub 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#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct HeadingText {
122 pub text: String,
126 pub slug_text: String,
132 pub custom_id: Option<String>,
134}
135
136pub fn extract_heading_text(line: &str) -> HeadingText {
141 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
157fn 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 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
187pub 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 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 end = range.start;
217 } else {
218 return end;
219 }
220 }
221}
222
223fn 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
248fn 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 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 String::from_utf8(reversed)
279 .expect("removing ASCII blanks preserves UTF-8")
280 .trim()
281 .to_string()
282}
283
284pub 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
297fn 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 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
345fn 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
394fn 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#[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
457fn 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#[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
517fn 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
534pub 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
543pub 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("e @ (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
601pub 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
622pub 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 if let Some(hash_pos) = attr_str.find('#') {
645 let after_hash = &attr_str[hash_pos + 1..];
646
647 let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
649
650 if is_simple_format {
651 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 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 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", "`", "``", "```", "\\", "é", "<!--", "-->", ""];
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=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=live></a>",
772 " <a id=live></a>",
773 "![outer][ ![<a id=hidden></a>][label] <a id=live></a>",
774 " <a id=live></a>]",
775 r"![outer\]  <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 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 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 "",
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></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 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 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 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 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 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 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 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 let (text, id) = extract_header_id("# Header {: .class-only }");
968 assert_eq!(text, "# Header {: .class-only }");
969 assert_eq!(id, None);
970
971 let (text, id) = extract_header_id("# Header { no-hash }");
973 assert_eq!(text, "# Header { no-hash }");
974 assert_eq!(id, None);
975
976 let (text, id) = extract_header_id("# Header {: # }");
978 assert_eq!(text, "# Header {: # }");
979 assert_eq!(id, None);
980
981 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 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 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 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(" ")); assert!(!is_standalone_attr_list("{: .class-only }")); }
1008
1009 #[test]
1010 fn test_standalone_attr_list_id_extraction() {
1011 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 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 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 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 let (text, id) = extract_header_id("## Another. {#id.with.dots}");
1062 assert_eq!(text, "## Another. {#id.with.dots}"); assert_eq!(id, None); 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 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 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 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 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 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 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 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 for raw in [
1214 r#""#,
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 let raw = r#"\"#;
1224 assert_eq!(extract_html_anchor_ids(raw), ["x"]);
1225 assert_eq!(extract_header_id(raw).0, r"\");
1226
1227 let raw = r#" <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, "");
1232 assert_eq!(heading.slug_text, " ");
1233 }
1234
1235 #[test]
1236 fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
1237 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 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 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}