1use regex::Regex;
20use std::sync::LazyLock;
21
22use crate::utils::skip_context::ByteRange;
23
24static DIV_OPEN_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*(?:\{[^}]+\}|\S+)").unwrap());
28
29static DIV_CLOSE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*$").unwrap());
32
33static CALLOUT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
36 Regex::new(r"^(\s*):::\s*\{[^}]*\.callout-(?:note|warning|tip|important|caution)[^}]*\}").unwrap()
37});
38
39static PANDOC_ATTR_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[^}]+\}").unwrap());
43
44pub fn is_div_open(line: &str) -> bool {
46 DIV_OPEN_PATTERN.is_match(line)
47}
48
49pub fn is_div_close(line: &str) -> bool {
51 DIV_CLOSE_PATTERN.is_match(line)
52}
53
54pub fn is_callout_open(line: &str) -> bool {
56 CALLOUT_PATTERN.is_match(line)
57}
58
59pub fn has_pandoc_attributes(line: &str) -> bool {
61 PANDOC_ATTR_PATTERN.is_match(line)
62}
63
64pub fn is_pandoc_raw_block_lang(lang: &str) -> bool {
68 let l = lang.trim();
69 l.starts_with("{=") && l.ends_with('}') && {
70 let inner = &l[2..l.len() - 1];
71 !inner.trim().is_empty() && inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
72 }
73}
74
75pub fn pandoc_code_class_lang(lang: &str) -> Option<&str> {
84 let l = lang.trim();
85 if !l.starts_with('{') || !l.ends_with('}') || l.len() < 2 {
86 return None;
87 }
88 let inner = &l[1..l.len() - 1];
89 inner
90 .split_whitespace()
91 .filter_map(|tok| tok.strip_prefix('.'))
92 .find(|class| !class.is_empty() && class.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
93}
94
95pub fn is_pandoc_code_class_attr(lang: &str) -> bool {
99 pandoc_code_class_lang(lang).is_some()
100}
101
102pub fn get_div_indent(line: &str) -> usize {
104 let mut indent = 0;
105 for c in line.chars() {
106 match c {
107 ' ' => indent += 1,
108 '\t' => indent += 4, _ => break,
110 }
111 }
112 indent
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct DivTracker {
118 indent_stack: Vec<usize>,
120}
121
122impl DivTracker {
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn process_line(&mut self, line: &str) -> bool {
129 let trimmed = line.trim_start();
130
131 if trimmed.starts_with(":::") {
132 let indent = get_div_indent(line);
133
134 if is_div_close(line) {
135 if let Some(&top_indent) = self.indent_stack.last()
138 && top_indent >= indent
139 {
140 self.indent_stack.pop();
141 }
142 } else if is_div_open(line) {
143 self.indent_stack.push(indent);
145 }
146 }
147
148 !self.indent_stack.is_empty()
149 }
150
151 pub fn is_inside_div(&self) -> bool {
153 !self.indent_stack.is_empty()
154 }
155}
156
157pub fn detect_div_block_ranges(content: &str) -> Vec<ByteRange> {
160 let mut ranges = Vec::new();
161 let mut tracker = DivTracker::new();
162 let mut div_start: Option<usize> = None;
163 let mut byte_offset = 0;
164
165 for line in content.lines() {
166 let line_len = line.len();
167 let was_inside = tracker.is_inside_div();
168 let is_inside = tracker.process_line(line);
169
170 if !was_inside && is_inside {
172 div_start = Some(byte_offset);
173 }
174 else if was_inside
176 && !is_inside
177 && let Some(start) = div_start.take()
178 {
179 ranges.push(ByteRange {
181 start,
182 end: byte_offset + line_len,
183 });
184 }
185
186 byte_offset += line_len + 1;
188 }
189
190 if let Some(start) = div_start {
192 ranges.push(ByteRange {
193 start,
194 end: content.len(),
195 });
196 }
197
198 ranges
199}
200
201pub fn is_within_div_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
203 ranges.iter().any(|r| position >= r.start && position < r.end)
204}
205
206static BRACKETED_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
228 Regex::new(r"\[(?:[^\]@]*[^A-Za-z0-9_])?@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*[^\]]*\]").unwrap()
229});
230
231static INLINE_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
235 Regex::new(r"(?:^|[\s\(\[\{,;:])(@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*)").unwrap()
237});
238
239static LINK_LABEL_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\[[^\]]*\])(?:\(|\[)").unwrap());
246
247#[inline]
249pub fn has_citations(text: &str) -> bool {
250 text.contains('@')
251}
252
253static INLINE_FOOTNOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w!])(\^\[[^\]]*\])").unwrap());
267
268pub fn pandoc_header_slug(text: &str) -> String {
278 let mut s = String::with_capacity(text.len());
279 for c in text.chars() {
280 if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
281 for lc in c.to_lowercase() {
282 s.push(lc);
283 }
284 } else if c.is_whitespace() {
285 if !s.ends_with('-') {
287 s.push('-');
288 }
289 }
290 }
292 let trimmed = s.trim_matches('-').to_string();
293 if trimmed.is_empty() {
294 "section".to_string()
295 } else {
296 trimmed
297 }
298}
299
300pub fn collect_pandoc_header_slugs(content: &str) -> std::collections::HashSet<String> {
315 use std::collections::{HashMap, HashSet};
316 let mut slugs = HashSet::new();
317 let mut base_counts: HashMap<String, usize> = HashMap::new();
318 let mut in_fence = false;
319 let mut fence_marker: Option<char> = None;
320 for line in content.lines() {
321 let trimmed = line.trim_start();
322 if let Some(c) = trimmed.chars().next()
326 && (c == '`' || c == '~')
327 {
328 let count = trimmed.chars().take_while(|&ch| ch == c).count();
329 if count >= 3 {
330 match fence_marker {
331 None => {
332 in_fence = true;
333 fence_marker = Some(c);
334 }
335 Some(m) if m == c => {
336 in_fence = false;
337 fence_marker = None;
338 }
339 _ => {}
340 }
341 continue;
342 }
343 }
344 if in_fence {
345 continue;
346 }
347 if let Some(rest) = trimmed.strip_prefix('#') {
348 let mut text = rest.trim_start_matches('#').trim();
349 if let Some(idx) = text.rfind(" {")
354 && let Some(close_rel) = text[idx + 2..].find('}')
355 && text[idx + 2 + close_rel + 1..].trim().is_empty()
356 {
357 text = &text[..idx];
358 }
359 let base = pandoc_header_slug(text);
360 let count = base_counts.entry(base.clone()).or_insert(0);
361 let slug = if *count == 0 {
362 base.clone()
363 } else {
364 format!("{base}-{count}")
365 };
366 *count += 1;
367 slugs.insert(slug);
368 }
369 }
370 slugs
371}
372
373static SUBSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~[^\s~]+~").unwrap());
388
389static SUPERSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\^[^\s^]+\^").unwrap());
391
392pub fn detect_subscript_superscript_ranges(content: &str) -> Vec<ByteRange> {
401 let bytes = content.as_bytes();
402 let mut ranges = Vec::new();
403
404 for m in SUBSCRIPT_PATTERN.find_iter(content) {
405 let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
407 let next = bytes.get(m.end()).copied().unwrap_or(0);
408 if prev != b'~' && next != b'~' {
409 ranges.push(ByteRange {
410 start: m.start(),
411 end: m.end(),
412 });
413 }
414 }
415 for m in SUPERSCRIPT_PATTERN.find_iter(content) {
416 let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
418 let next = bytes.get(m.end()).copied().unwrap_or(0);
419 if prev != b'^' && next != b'^' {
420 ranges.push(ByteRange {
421 start: m.start(),
422 end: m.end(),
423 });
424 }
425 }
426 ranges.sort_by_key(|r| r.start);
428 ranges
429}
430
431static INLINE_CODE_ATTR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`(\{[^}]+\})").unwrap());
444
445pub fn detect_inline_code_attr_ranges(content: &str) -> Vec<ByteRange> {
451 let mut ranges = Vec::new();
452 for caps in INLINE_CODE_ATTR.captures_iter(content) {
453 let m = caps.get(1).unwrap();
454 ranges.push(ByteRange {
455 start: m.start(),
456 end: m.end(),
457 });
458 }
459 ranges
460}
461
462static EXAMPLE_LIST_MARKER: LazyLock<Regex> =
477 LazyLock::new(|| Regex::new(r"(?m)^[ \t]*(\(@[A-Za-z0-9_-]*\))[ \t]+").unwrap());
478
479static EXAMPLE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\(@[A-Za-z0-9_-]+\))").unwrap());
482
483pub fn detect_example_list_marker_ranges(content: &str) -> Vec<ByteRange> {
488 let mut ranges = Vec::new();
489 for caps in EXAMPLE_LIST_MARKER.captures_iter(content) {
490 let m = caps.get(1).unwrap();
491 ranges.push(ByteRange {
492 start: m.start(),
493 end: m.end(),
494 });
495 }
496 ranges
497}
498
499pub fn detect_example_reference_ranges(content: &str, marker_ranges: &[ByteRange]) -> Vec<ByteRange> {
506 let mut ranges = Vec::new();
507 let marker_starts: std::collections::HashSet<usize> = marker_ranges.iter().map(|r| r.start).collect();
508 for caps in EXAMPLE_REFERENCE.captures_iter(content) {
509 let m = caps.get(1).unwrap();
510 if !marker_starts.contains(&m.start()) {
511 ranges.push(ByteRange {
512 start: m.start(),
513 end: m.end(),
514 });
515 }
516 }
517 ranges
518}
519
520static BRACKETED_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[^\]]+\]\{[^}]+\}").unwrap());
536
537pub fn detect_bracketed_span_ranges(content: &str) -> Vec<ByteRange> {
542 let mut ranges = Vec::new();
543 for m in BRACKETED_SPAN.find_iter(content) {
544 ranges.push(ByteRange {
545 start: m.start(),
546 end: m.end(),
547 });
548 }
549 ranges
550}
551
552pub fn detect_line_block_ranges(content: &str) -> Vec<ByteRange> {
574 let mut ranges = Vec::new();
575 let mut in_block = false;
576 let mut block_start = 0usize;
577 let mut block_end = 0usize;
578 let mut byte_offset = 0usize;
579
580 for line in content.split_inclusive('\n') {
581 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
582 let is_line_block_line = trimmed.starts_with("| ") && !trimmed.trim_end().ends_with('|');
583 let is_continuation = in_block
584 && !trimmed.is_empty()
585 && trimmed.starts_with(|c: char| c.is_whitespace())
586 && !trimmed.trim_start().starts_with('|');
587
588 if is_line_block_line || is_continuation {
589 if !in_block {
590 block_start = byte_offset;
591 in_block = true;
592 }
593 block_end = byte_offset + line.len();
594 } else if in_block {
595 ranges.push(ByteRange {
596 start: block_start,
597 end: block_end,
598 });
599 in_block = false;
600 }
601 byte_offset += line.len();
602 }
603 if in_block {
604 ranges.push(ByteRange {
605 start: block_start,
606 end: block_end,
607 });
608 }
609 ranges
610}
611
612pub fn detect_pipe_table_caption_ranges(content: &str) -> Vec<ByteRange> {
633 let mut lines: Vec<&str> = Vec::new();
634 let mut line_offsets: Vec<usize> = Vec::new();
635 let mut offset = 0usize;
636 for line in content.split_inclusive('\n') {
637 line_offsets.push(offset);
638 lines.push(line);
639 offset += line.len();
640 }
641 line_offsets.push(offset);
642
643 fn line_body(line: &str) -> &str {
644 line.trim_end_matches('\n').trim_end_matches('\r')
645 }
646 fn is_pipe_table_row(line: &str) -> bool {
647 let t = line_body(line).trim();
648 t.starts_with('|') && t.ends_with('|') && t.len() >= 3
649 }
650 fn is_caption_line(line: &str) -> bool {
651 line_body(line).trim_start().starts_with(": ")
652 }
653 fn is_blank(line: &str) -> bool {
654 line_body(line).trim().is_empty()
655 }
656
657 let mut ranges = Vec::new();
658 for (i, line) in lines.iter().enumerate() {
659 if !is_caption_line(line) {
660 continue;
661 }
662 let table_below = i + 2 < lines.len() && is_blank(lines[i + 1]) && is_pipe_table_row(lines[i + 2]);
663 let table_above = i >= 2 && is_blank(lines[i - 1]) && is_pipe_table_row(lines[i - 2]);
664 if table_below || table_above {
665 ranges.push(ByteRange {
666 start: line_offsets[i],
667 end: line_offsets[i + 1],
668 });
669 }
670 }
671 ranges
672}
673
674pub fn detect_yaml_metadata_block_ranges(content: &str) -> Vec<ByteRange> {
691 let mut lines: Vec<&str> = Vec::new();
692 let mut line_offsets: Vec<usize> = Vec::new();
693 let mut offset = 0usize;
694 for line in content.split_inclusive('\n') {
695 line_offsets.push(offset);
696 lines.push(line);
697 offset += line.len();
698 }
699 line_offsets.push(offset);
700
701 fn line_body(line: &str) -> &str {
702 line.trim_end_matches('\n').trim_end_matches('\r')
703 }
704 fn is_blank(line: &str) -> bool {
705 line_body(line).trim().is_empty()
706 }
707 fn is_opener(line: &str) -> bool {
708 line_body(line).trim_end() == "---"
709 }
710 fn is_closer(line: &str) -> bool {
711 let t = line_body(line).trim_end();
712 t == "---" || t == "..."
713 }
714
715 let mut ranges = Vec::new();
716 let mut i = 0;
717 while i < lines.len() {
718 let preceded_by_blank = i == 0 || is_blank(lines[i - 1]);
719 if preceded_by_blank && is_opener(lines[i]) {
720 let mut j = i + 1;
721 let mut found_closer = false;
722 while j < lines.len() {
723 if is_closer(lines[j]) {
724 ranges.push(ByteRange {
725 start: line_offsets[i],
726 end: line_offsets[j + 1],
727 });
728 i = j + 1;
729 found_closer = true;
730 break;
731 }
732 j += 1;
733 }
734 if !found_closer {
735 i += 1;
737 }
738 } else {
739 i += 1;
740 }
741 }
742 ranges
743}
744
745static GRID_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\+(?:[-=]+\+)+\s*$").unwrap());
757
758static GRID_CONTENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|.*\|\s*$").unwrap());
760
761pub fn detect_grid_table_ranges(content: &str) -> Vec<ByteRange> {
769 let mut lines: Vec<&str> = Vec::new();
770 let mut line_offsets: Vec<usize> = Vec::new();
771 let mut offset = 0usize;
772 for line in content.split_inclusive('\n') {
773 line_offsets.push(offset);
774 lines.push(line);
775 offset += line.len();
776 }
777 line_offsets.push(offset);
778
779 fn line_body(line: &str) -> &str {
780 line.trim_end_matches('\n').trim_end_matches('\r')
781 }
782 fn is_border(line: &str) -> bool {
783 GRID_BORDER.is_match(line_body(line))
784 }
785 fn is_content(line: &str) -> bool {
786 GRID_CONTENT.is_match(line_body(line))
787 }
788
789 let mut ranges = Vec::new();
790 let mut i = 0;
791 while i < lines.len() {
792 if is_border(lines[i]) {
793 let start_line = i;
794 let mut j = i + 1;
795 let mut last_border = i;
796 let mut saw_content = false;
797 while j < lines.len() {
798 if is_border(lines[j]) {
799 last_border = j;
800 j += 1;
801 } else if is_content(lines[j]) {
802 saw_content = true;
803 j += 1;
804 } else {
805 break;
806 }
807 }
808 if saw_content && last_border > start_line {
811 ranges.push(ByteRange {
812 start: line_offsets[start_line],
813 end: line_offsets[last_border + 1],
814 });
815 i = last_border + 1;
816 continue;
817 }
818 }
819 i += 1;
820 }
821 ranges
822}
823
824static MULTI_LINE_UNDERLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{2,}(?:\s+-{2,})+\s*$").unwrap());
837
838static MULTI_LINE_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{10,}\s*$").unwrap());
841
842pub fn detect_multi_line_table_ranges(content: &str) -> Vec<ByteRange> {
852 let mut lines: Vec<&str> = Vec::new();
853 let mut line_offsets: Vec<usize> = Vec::new();
854 let mut offset = 0usize;
855 for line in content.split_inclusive('\n') {
856 line_offsets.push(offset);
857 lines.push(line);
858 offset += line.len();
859 }
860 line_offsets.push(offset);
861
862 fn line_body(line: &str) -> &str {
863 line.trim_end_matches('\n').trim_end_matches('\r')
864 }
865 fn is_underline(line: &str) -> bool {
866 MULTI_LINE_UNDERLINE.is_match(line_body(line))
867 }
868 fn is_border(line: &str) -> bool {
869 MULTI_LINE_BORDER.is_match(line_body(line))
870 }
871
872 let mut ranges = Vec::new();
873 let mut i = 0;
874 while i < lines.len() {
875 if i >= 1 && is_underline(lines[i]) && !line_body(lines[i - 1]).is_empty() {
877 let mut header_start = i - 1;
881 while header_start > 0
882 && !line_body(lines[header_start - 1]).is_empty()
883 && !is_border(lines[header_start - 1])
884 && !is_underline(lines[header_start - 1])
885 {
886 header_start -= 1;
887 }
888
889 let start_line = if header_start > 0 && is_border(lines[header_start - 1]) {
891 header_start - 1
892 } else {
893 header_start
894 };
895
896 let mut j = i + 1;
898 let mut end_line: Option<usize> = None;
899 while j < lines.len() {
900 if is_border(lines[j]) {
901 end_line = Some(j);
903 break;
904 } else if j > i + 1 && is_underline(lines[j]) {
905 end_line = Some(j - 1);
908 break;
909 }
910 j += 1;
911 }
912
913 if let Some(end) = end_line {
914 ranges.push(ByteRange {
915 start: line_offsets[start_line],
916 end: line_offsets[end + 1],
917 });
918 i = end + 1;
919 continue;
920 }
921 }
923 i += 1;
924 }
925 ranges
926}
927
928pub fn detect_inline_footnote_ranges(content: &str) -> Vec<ByteRange> {
933 let mut ranges = Vec::new();
934 for caps in INLINE_FOOTNOTE_PATTERN.captures_iter(content) {
935 let m = caps.get(1).unwrap();
936 ranges.push(ByteRange {
937 start: m.start(),
938 end: m.end(),
939 });
940 }
941 ranges
942}
943
944pub fn find_citation_ranges(content: &str) -> Vec<ByteRange> {
953 let mut ranges = Vec::new();
954
955 let link_label_ranges: Vec<(usize, usize)> = LINK_LABEL_PATTERN
958 .captures_iter(content)
959 .filter_map(|c| c.get(1).map(|m| (m.start(), m.end())))
960 .collect();
961
962 let in_link_label = |pos: usize| -> bool { link_label_ranges.iter().any(|&(s, e)| pos >= s && pos < e) };
963
964 for mat in BRACKETED_CITATION_PATTERN.find_iter(content) {
966 if in_link_label(mat.start()) {
967 continue;
968 }
969 ranges.push(ByteRange {
970 start: mat.start(),
971 end: mat.end(),
972 });
973 }
974
975 for cap in INLINE_CITATION_PATTERN.captures_iter(content) {
977 if let Some(mat) = cap.get(1) {
978 let start = mat.start();
979 if in_link_label(start) {
980 continue;
981 }
982 if !ranges.iter().any(|r| start >= r.start && start < r.end) {
984 ranges.push(ByteRange { start, end: mat.end() });
985 }
986 }
987 }
988
989 ranges.sort_by_key(|r| r.start);
991 ranges
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997
998 #[test]
999 fn test_div_open_detection() {
1000 assert!(is_div_open("::: {.callout-note}"));
1002 assert!(is_div_open("::: {.callout-warning}"));
1003 assert!(is_div_open("::: {#myid .class}"));
1004 assert!(is_div_open("::: bordered"));
1005 assert!(is_div_open(" ::: {.note}")); assert!(is_div_open("::: {.callout-tip title=\"My Title\"}"));
1007
1008 assert!(!is_div_open(":::")); assert!(!is_div_open("::: ")); assert!(!is_div_open("Regular text"));
1012 assert!(!is_div_open("# Heading"));
1013 assert!(!is_div_open("```python")); }
1015
1016 #[test]
1017 fn test_div_close_detection() {
1018 assert!(is_div_close(":::"));
1019 assert!(is_div_close("::: "));
1020 assert!(is_div_close(" :::"));
1021 assert!(is_div_close(" ::: "));
1022
1023 assert!(!is_div_close("::: {.note}"));
1024 assert!(!is_div_close("::: class"));
1025 assert!(!is_div_close(":::note"));
1026 }
1027
1028 #[test]
1029 fn test_callout_detection() {
1030 assert!(is_callout_open("::: {.callout-note}"));
1031 assert!(is_callout_open("::: {.callout-warning}"));
1032 assert!(is_callout_open("::: {.callout-tip}"));
1033 assert!(is_callout_open("::: {.callout-important}"));
1034 assert!(is_callout_open("::: {.callout-caution}"));
1035 assert!(is_callout_open("::: {#myid .callout-note}"));
1036 assert!(is_callout_open("::: {.callout-note title=\"Title\"}"));
1037
1038 assert!(!is_callout_open("::: {.note}")); assert!(!is_callout_open("::: {.bordered}")); assert!(!is_callout_open("::: callout-note")); }
1042
1043 #[test]
1044 fn test_div_tracker() {
1045 let mut tracker = DivTracker::new();
1046
1047 assert!(tracker.process_line("::: {.callout-note}"));
1049 assert!(tracker.is_inside_div());
1050
1051 assert!(tracker.process_line("This is content."));
1053 assert!(tracker.is_inside_div());
1054
1055 assert!(!tracker.process_line(":::"));
1057 assert!(!tracker.is_inside_div());
1058 }
1059
1060 #[test]
1061 fn test_nested_divs() {
1062 let mut tracker = DivTracker::new();
1063
1064 assert!(tracker.process_line("::: {.outer}"));
1066 assert!(tracker.is_inside_div());
1067
1068 assert!(tracker.process_line(" ::: {.inner}"));
1070 assert!(tracker.is_inside_div());
1071
1072 assert!(tracker.process_line(" Content"));
1074 assert!(tracker.is_inside_div());
1075
1076 assert!(tracker.process_line(" :::"));
1078 assert!(tracker.is_inside_div());
1079
1080 assert!(!tracker.process_line(":::"));
1082 assert!(!tracker.is_inside_div());
1083 }
1084
1085 #[test]
1086 fn test_detect_div_block_ranges() {
1087 let content = r#"# Heading
1088
1089::: {.callout-note}
1090This is a note.
1091:::
1092
1093Regular text.
1094
1095::: {.bordered}
1096Content here.
1097:::
1098"#;
1099 let ranges = detect_div_block_ranges(content);
1100 assert_eq!(ranges.len(), 2);
1101
1102 let first_div_content = &content[ranges[0].start..ranges[0].end];
1104 assert!(first_div_content.contains("callout-note"));
1105 assert!(first_div_content.contains("This is a note"));
1106
1107 let second_div_content = &content[ranges[1].start..ranges[1].end];
1109 assert!(second_div_content.contains("bordered"));
1110 assert!(second_div_content.contains("Content here"));
1111 }
1112
1113 #[test]
1114 fn test_pandoc_attributes() {
1115 assert!(has_pandoc_attributes("# Heading {#custom-id}"));
1116 assert!(has_pandoc_attributes("# Heading {.unnumbered}"));
1117 assert!(has_pandoc_attributes("{#fig-1 width=\"50%\"}"));
1118 assert!(has_pandoc_attributes("{#id .class key=\"value\"}"));
1119
1120 assert!(!has_pandoc_attributes("# Heading"));
1121 assert!(!has_pandoc_attributes("Regular text"));
1122 assert!(!has_pandoc_attributes("{}"));
1123 }
1124
1125 #[test]
1126 fn test_div_with_title_attribute() {
1127 let content = r#"::: {.callout-note title="Important Note"}
1128This is the content of the note.
1129It can span multiple lines.
1130:::
1131"#;
1132 let ranges = detect_div_block_ranges(content);
1133 assert_eq!(ranges.len(), 1);
1134 assert!(is_callout_open("::: {.callout-note title=\"Important Note\"}"));
1135 }
1136
1137 #[test]
1138 fn test_unclosed_div() {
1139 let content = r#"::: {.callout-note}
1140This note is never closed.
1141"#;
1142 let ranges = detect_div_block_ranges(content);
1143 assert_eq!(ranges.len(), 1);
1144 assert_eq!(ranges[0].end, content.len());
1146 }
1147
1148 #[test]
1149 fn test_heading_inside_callout() {
1150 let content = r#"::: {.callout-warning}
1151## Warning Title
1152
1153Warning content here.
1154:::
1155"#;
1156 let ranges = detect_div_block_ranges(content);
1157 assert_eq!(ranges.len(), 1);
1158
1159 let div_content = &content[ranges[0].start..ranges[0].end];
1160 assert!(div_content.contains("## Warning Title"));
1161 }
1162
1163 #[test]
1165 fn test_has_citations() {
1166 assert!(has_citations("See @smith2020 for details."));
1167 assert!(has_citations("[@smith2020]"));
1168 assert!(has_citations("Multiple [@a; @b] citations"));
1169 assert!(!has_citations("No citations here"));
1170 assert!(has_citations("Email: user@example.com"));
1172 }
1173
1174 #[test]
1175 fn test_bracketed_citation_detection() {
1176 let content = "See [@smith2020] for more info.";
1177 let ranges = find_citation_ranges(content);
1178 assert_eq!(ranges.len(), 1);
1179 assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020]");
1180 }
1181
1182 #[test]
1183 fn test_inline_citation_detection() {
1184 let content = "As @smith2020 argues, this is true.";
1185 let ranges = find_citation_ranges(content);
1186 assert_eq!(ranges.len(), 1);
1187 assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1188 }
1189
1190 #[test]
1191 fn test_multiple_citations_in_brackets() {
1192 let content = "See [@smith2020; @jones2021] for details.";
1193 let ranges = find_citation_ranges(content);
1194 assert_eq!(ranges.len(), 1);
1195 assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020; @jones2021]");
1196 }
1197
1198 #[test]
1199 fn test_citation_with_prefix() {
1200 let content = "[see @smith2020, p. 10]";
1201 let ranges = find_citation_ranges(content);
1202 assert_eq!(ranges.len(), 1);
1203 assert_eq!(&content[ranges[0].start..ranges[0].end], "[see @smith2020, p. 10]");
1204 }
1205
1206 #[test]
1207 fn test_suppress_author_citation() {
1208 let content = "The theory [-@smith2020] states that...";
1209 let ranges = find_citation_ranges(content);
1210 assert_eq!(ranges.len(), 1);
1211 assert_eq!(&content[ranges[0].start..ranges[0].end], "[-@smith2020]");
1212 }
1213
1214 #[test]
1215 fn test_mixed_citations() {
1216 let content = "@smith2020 argues that [@jones2021] is wrong.";
1217 let ranges = find_citation_ranges(content);
1218 assert_eq!(ranges.len(), 2);
1219 assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1221 assert_eq!(&content[ranges[1].start..ranges[1].end], "[@jones2021]");
1223 }
1224
1225 #[test]
1226 fn test_email_not_confused_with_citation() {
1227 let content = "Contact user@example.com for help.";
1230 let ranges = find_citation_ranges(content);
1231 assert!(
1233 ranges.is_empty()
1234 || !ranges.iter().any(|r| {
1235 let s = &content[r.start..r.end];
1236 s.contains("example.com")
1237 })
1238 );
1239 }
1240
1241 #[test]
1246 fn test_bracketed_link_text_with_email_not_citation() {
1247 let content = "[contact user@example.com](#missing)";
1248 let ranges = find_citation_ranges(content);
1249 assert!(
1250 ranges.is_empty(),
1251 "Bracketed link text with embedded email must not be detected as a Pandoc citation: {ranges:?}"
1252 );
1253 }
1254
1255 #[test]
1257 fn test_bracketed_link_text_with_email_empty_href_not_citation() {
1258 let content = "[contact user@example.com]()";
1259 let ranges = find_citation_ranges(content);
1260 assert!(
1261 ranges.is_empty(),
1262 "Bracketed link text with embedded email and empty href must not be a Pandoc citation: {ranges:?}"
1263 );
1264 }
1265
1266 #[test]
1271 fn test_bracketed_text_followed_by_inline_link_not_citation() {
1272 let content = "[see @smith2020](#missing)";
1273 let ranges = find_citation_ranges(content);
1274 assert!(
1275 ranges.is_empty(),
1276 "Bracketed text followed by `(...)` is a link, not a citation: {ranges:?}"
1277 );
1278 }
1279
1280 #[test]
1282 fn test_bracketed_text_followed_by_empty_inline_link_not_citation() {
1283 let content = "[see @smith2020]()";
1284 let ranges = find_citation_ranges(content);
1285 assert!(
1286 ranges.is_empty(),
1287 "Bracketed text followed by `()` is a link with empty href, not a citation: {ranges:?}"
1288 );
1289 }
1290
1291 #[test]
1295 fn test_bracketed_text_followed_by_reference_link_not_citation() {
1296 let content = "[see @smith2020][ref]";
1297 let ranges = find_citation_ranges(content);
1298 assert!(
1299 ranges.is_empty(),
1300 "Bracketed text followed by `[ref]` is a reference link, not a citation: {ranges:?}"
1301 );
1302 }
1303
1304 #[test]
1307 fn test_standalone_bracketed_citation_still_detected() {
1308 let content = "See [see @smith2020] for details.";
1309 let ranges = find_citation_ranges(content);
1310 assert!(
1311 ranges.iter().any(|r| &content[r.start..r.end] == "[see @smith2020]"),
1312 "Standalone bracketed citation must still be detected: {ranges:?}"
1313 );
1314 }
1315
1316 #[test]
1318 fn test_bracketed_citation_followed_by_punctuation_still_detected() {
1319 let content = "Note [@smith2020].";
1320 let ranges = find_citation_ranges(content);
1321 assert!(
1322 ranges.iter().any(|r| &content[r.start..r.end] == "[@smith2020]"),
1323 "Bracketed citation followed by `.` must still be detected: {ranges:?}"
1324 );
1325 }
1326
1327 #[test]
1328 fn test_detect_inline_footnotes() {
1329 let content = "See ^[a quick note] here.\nAnd ^[another one] too.\n";
1330 let ranges = detect_inline_footnote_ranges(content);
1331 assert_eq!(ranges.len(), 2);
1332 let first_start = content.find("^[").unwrap();
1334 let first_end = content[first_start..].find(']').unwrap() + first_start + 1;
1335 assert_eq!(ranges[0].start, first_start);
1336 assert_eq!(ranges[0].end, first_end);
1337 let second_start = content[first_end..].find("^[").unwrap() + first_end;
1339 let second_end = content[second_start..].find(']').unwrap() + second_start + 1;
1340 assert_eq!(ranges[1].start, second_start);
1341 assert_eq!(ranges[1].end, second_end);
1342 }
1343
1344 #[test]
1345 fn test_inline_footnote_with_brackets_inside() {
1346 let content = "Note ^[ref to [other] thing] here.\n";
1351 let ranges = detect_inline_footnote_ranges(content);
1352 assert_eq!(ranges.len(), 1);
1353 }
1354
1355 #[test]
1356 fn test_inline_footnote_does_not_match_image_or_link() {
1357 let content = "An image  and a link [txt](url).\n";
1359 let ranges = detect_inline_footnote_ranges(content);
1360 assert_eq!(ranges.len(), 0);
1361 }
1362
1363 #[test]
1364 fn test_implicit_header_reference_slug() {
1365 assert_eq!(pandoc_header_slug("My Section"), "my-section");
1368 assert_eq!(pandoc_header_slug("API: v2!"), "api-v2");
1369 assert_eq!(pandoc_header_slug(" Trim Me "), "trim-me");
1370 assert_eq!(pandoc_header_slug("Multiple Spaces"), "multiple-spaces");
1371 }
1372
1373 #[test]
1374 fn test_collect_pandoc_header_slugs() {
1375 let content = "# My Section\n\n## Sub-section\n\nbody\n";
1376 let slugs = collect_pandoc_header_slugs(content);
1377 assert!(slugs.contains("my-section"));
1378 assert!(slugs.contains("sub-section"));
1379 }
1380
1381 #[test]
1382 fn test_collect_pandoc_header_slugs_strips_attribute_block() {
1383 let content = "# My Section {#custom-id .red}\n## Plain Section\n";
1384 let slugs = collect_pandoc_header_slugs(content);
1385 assert!(slugs.contains("my-section"));
1386 assert!(slugs.contains("plain-section"));
1387 assert!(!slugs.iter().any(|s| s.contains("custom-id")));
1389 }
1390
1391 #[test]
1392 fn test_collect_pandoc_header_slugs_preserves_body_braces() {
1393 let content = "# Some {curly} word in title\n";
1395 let slugs = collect_pandoc_header_slugs(content);
1396 assert!(slugs.contains("some-curly-word-in-title"));
1397 }
1398
1399 #[test]
1400 fn test_collect_pandoc_header_slugs_disambiguates_duplicates() {
1401 let content = "# A.\n\nbody\n\n# A.\n";
1405 let slugs = collect_pandoc_header_slugs(content);
1406 assert!(slugs.contains("a."), "first occurrence should expose base slug `a.`");
1407 assert!(
1408 slugs.contains("a.-1"),
1409 "second occurrence should expose `a.-1`: got {slugs:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_collect_pandoc_header_slugs_three_duplicates_get_two_suffixes() {
1415 let content = "# Intro\n\n# Intro\n\n# Intro\n";
1416 let slugs = collect_pandoc_header_slugs(content);
1417 assert!(slugs.contains("intro"));
1418 assert!(slugs.contains("intro-1"));
1419 assert!(slugs.contains("intro-2"));
1420 assert!(
1421 !slugs.contains("intro-3"),
1422 "three occurrences must produce only -1 and -2 suffixes, not -3: got {slugs:?}"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_collect_pandoc_header_slugs_unique_headings_get_no_suffix() {
1428 let content = "# Foo\n\n# Bar\n\n# Baz\n";
1429 let slugs = collect_pandoc_header_slugs(content);
1430 assert!(slugs.contains("foo"));
1431 assert!(slugs.contains("bar"));
1432 assert!(slugs.contains("baz"));
1433 assert!(!slugs.contains("foo-1"));
1435 assert!(!slugs.contains("bar-1"));
1436 assert!(!slugs.contains("baz-1"));
1437 }
1438
1439 #[test]
1440 fn test_detect_example_list_markers() {
1441 let content = "(@) First item.\n(@good) Second item.\n(@) Third item.\n";
1442 let ranges = detect_example_list_marker_ranges(content);
1443 assert_eq!(ranges.len(), 3);
1444 assert_eq!(ranges[0].start, 0);
1445 assert_eq!(&content[ranges[0].start..ranges[0].end], "(@)");
1446 let second_start = content.find("(@good)").unwrap();
1447 assert_eq!(ranges[1].start, second_start);
1448 assert_eq!(&content[ranges[1].start..ranges[1].end], "(@good)");
1449 }
1450
1451 #[test]
1452 fn test_detect_example_references() {
1453 let content = "As shown in (@good), this works.\n";
1455 let marker_ranges = detect_example_list_marker_ranges(content);
1456 let ranges = detect_example_reference_ranges(content, &marker_ranges);
1457 assert_eq!(ranges.len(), 1);
1458 }
1459
1460 #[test]
1461 fn test_example_marker_must_be_at_line_start() {
1462 let content = "Inline (@) is not a marker.\n";
1463 let ranges = detect_example_list_marker_ranges(content);
1464 assert_eq!(ranges.len(), 0);
1465 }
1466
1467 #[test]
1468 fn test_detect_subscript() {
1469 let content = "H~2~O is water.\n";
1470 let ranges = detect_subscript_superscript_ranges(content);
1471 assert_eq!(ranges.len(), 1);
1472 assert_eq!(&content[ranges[0].start..ranges[0].end], "~2~");
1473 }
1474
1475 #[test]
1476 fn test_detect_superscript() {
1477 let content = "2^10^ is 1024.\n";
1478 let ranges = detect_subscript_superscript_ranges(content);
1479 assert_eq!(ranges.len(), 1);
1480 assert_eq!(&content[ranges[0].start..ranges[0].end], "^10^");
1481 }
1482
1483 #[test]
1484 fn test_subscript_does_not_match_strikethrough() {
1485 let content = "This is ~~struck~~.\n";
1487 let ranges = detect_subscript_superscript_ranges(content);
1488 assert_eq!(ranges.len(), 0);
1489 }
1490
1491 #[test]
1492 fn test_superscript_with_internal_space_is_not_matched() {
1493 let content = "x^a b^ y\n";
1495 let ranges = detect_subscript_superscript_ranges(content);
1496 assert_eq!(ranges.len(), 0);
1497 }
1498
1499 #[test]
1500 fn test_subscript_at_start_of_input() {
1501 let content = "~x~ rest of line\n";
1503 let ranges = detect_subscript_superscript_ranges(content);
1504 assert_eq!(ranges.len(), 1);
1505 assert_eq!(&content[ranges[0].start..ranges[0].end], "~x~");
1506 }
1507
1508 #[test]
1509 fn test_superscript_at_end_of_input_no_newline() {
1510 let content = "text ^x^";
1512 let ranges = detect_subscript_superscript_ranges(content);
1513 assert_eq!(ranges.len(), 1);
1514 assert_eq!(&content[ranges[0].start..ranges[0].end], "^x^");
1515 }
1516
1517 #[test]
1518 fn test_detect_inline_code_attribute() {
1519 let content = "Use `print()`{.python} for output.\n";
1521 let ranges = detect_inline_code_attr_ranges(content);
1522 assert_eq!(ranges.len(), 1);
1523 let r = &ranges[0];
1524 assert_eq!(&content[r.start..r.end], "{.python}");
1525 }
1526
1527 #[test]
1528 fn test_inline_code_attribute_only_after_backtick() {
1529 let content = "Use {.example} for the class.\n";
1531 let ranges = detect_inline_code_attr_ranges(content);
1532 assert_eq!(ranges.len(), 0);
1533 }
1534
1535 #[test]
1536 fn test_inline_code_attribute_multiple_on_one_line() {
1537 let content = "Use `a`{.x} and `b`{.y} here.\n";
1538 let ranges = detect_inline_code_attr_ranges(content);
1539 assert_eq!(ranges.len(), 2);
1540 assert_eq!(&content[ranges[0].start..ranges[0].end], "{.x}");
1541 assert_eq!(&content[ranges[1].start..ranges[1].end], "{.y}");
1542 }
1543
1544 #[test]
1545 fn test_inline_code_attribute_compound_attributes() {
1546 let content = "Use `code`{.lang #id key=value} here.\n";
1548 let ranges = detect_inline_code_attr_ranges(content);
1549 assert_eq!(ranges.len(), 1);
1550 assert_eq!(&content[ranges[0].start..ranges[0].end], "{.lang #id key=value}");
1551 }
1552
1553 #[test]
1554 fn test_detect_bracketed_span() {
1555 let content = "This is [some text]{.smallcaps} here.\n";
1556 let ranges = detect_bracketed_span_ranges(content);
1557 assert_eq!(ranges.len(), 1);
1558 let r = &ranges[0];
1559 assert_eq!(&content[r.start..r.end], "[some text]{.smallcaps}");
1560 }
1561
1562 #[test]
1563 fn test_bracketed_span_does_not_match_link() {
1564 let content = "A [link](http://example.com) here.\n";
1566 let ranges = detect_bracketed_span_ranges(content);
1567 assert_eq!(ranges.len(), 0);
1568 }
1569
1570 #[test]
1571 fn test_bracketed_span_does_not_match_reference_link() {
1572 let content = "A [ref][def] here.\n[def]: http://example.com\n";
1574 let ranges = detect_bracketed_span_ranges(content);
1575 assert_eq!(ranges.len(), 0);
1576 }
1577
1578 #[test]
1579 fn test_bracketed_span_multiple_on_one_line() {
1580 let content = "[one]{.a} and [two]{.b} together.\n";
1581 let ranges = detect_bracketed_span_ranges(content);
1582 assert_eq!(ranges.len(), 2);
1583 assert_eq!(&content[ranges[0].start..ranges[0].end], "[one]{.a}");
1584 assert_eq!(&content[ranges[1].start..ranges[1].end], "[two]{.b}");
1585 }
1586
1587 #[test]
1588 fn test_bracketed_span_rejects_empty_content() {
1589 let content = "[]{.x} and [x]{} here.\n";
1591 let ranges = detect_bracketed_span_ranges(content);
1592 assert_eq!(ranges.len(), 0);
1593 }
1594
1595 #[test]
1596 fn test_bracketed_span_at_start_of_line() {
1597 let content = "[head]{.intro} starts the line.\n";
1598 let ranges = detect_bracketed_span_ranges(content);
1599 assert_eq!(ranges.len(), 1);
1600 assert_eq!(ranges[0].start, 0);
1601 assert_eq!(&content[ranges[0].start..ranges[0].end], "[head]{.intro}");
1602 }
1603
1604 #[test]
1605 fn test_detect_line_block_single() {
1606 let content = "| The Lord of the Rings\n| by J.R.R. Tolkien\n";
1607 let ranges = detect_line_block_ranges(content);
1608 assert_eq!(ranges.len(), 1);
1609 assert_eq!(ranges[0].start, 0);
1610 assert_eq!(ranges[0].end, content.len());
1611 }
1612
1613 #[test]
1614 fn test_line_block_no_trailing_newline() {
1615 let content = "| Only line";
1617 let ranges = detect_line_block_ranges(content);
1618 assert_eq!(ranges.len(), 1);
1619 assert_eq!(ranges[0].start, 0);
1620 assert_eq!(ranges[0].end, content.len());
1621 }
1622
1623 #[test]
1624 fn test_line_block_indented_pipe_is_not_continuation() {
1625 let content = "| First\n | indented\n";
1628 let ranges = detect_line_block_ranges(content);
1629 assert_eq!(ranges.len(), 1);
1630 assert_eq!(ranges[0].end, "| First\n".len());
1631 }
1632
1633 #[test]
1634 fn test_line_block_continuation_with_indent() {
1635 let content = "| First line\n continuation\n| Second\n";
1638 let ranges = detect_line_block_ranges(content);
1639 assert_eq!(ranges.len(), 1);
1640 }
1641
1642 #[test]
1643 fn test_line_block_separated_by_blank() {
1644 let content = "| Block A\n\n| Block B\n";
1645 let ranges = detect_line_block_ranges(content);
1646 assert_eq!(ranges.len(), 2);
1647 }
1648
1649 #[test]
1650 fn test_line_block_does_not_match_pipe_table() {
1651 let content = "| col1 | col2 |\n|------|------|\n";
1653 let ranges = detect_line_block_ranges(content);
1654 assert_eq!(ranges.len(), 0);
1655 }
1656
1657 #[test]
1658 fn test_detect_pipe_table_caption_below() {
1659 let content = "\
1660| col1 | col2 |
1661|------|------|
1662| a | b |
1663
1664: My caption
1665";
1666 let ranges = detect_pipe_table_caption_ranges(content);
1667 assert_eq!(ranges.len(), 1);
1668 let cap = &content[ranges[0].start..ranges[0].end];
1669 assert!(cap.starts_with(": My caption"));
1670 }
1671
1672 #[test]
1673 fn test_detect_pipe_table_caption_above() {
1674 let content = "\
1675: Caption first
1676
1677| col1 | col2 |
1678|------|------|
1679| a | b |
1680";
1681 let ranges = detect_pipe_table_caption_ranges(content);
1682 assert_eq!(ranges.len(), 1);
1683 }
1684
1685 #[test]
1686 fn test_colon_line_without_adjacent_table_is_definition_term() {
1687 let content = "Term\n: definition\n";
1689 let ranges = detect_pipe_table_caption_ranges(content);
1690 assert_eq!(ranges.len(), 0);
1691 }
1692
1693 #[test]
1694 fn test_pipe_table_caption_two_blank_lines_does_not_match() {
1695 let content = "\
1697| a | b |
1698|---|---|
1699| 1 | 2 |
1700
1701
1702: Caption
1703";
1704 let ranges = detect_pipe_table_caption_ranges(content);
1705 assert_eq!(ranges.len(), 0);
1706 }
1707
1708 #[test]
1709 fn test_pipe_table_caption_no_blank_line_does_not_match() {
1710 let content = "\
1712| a | b |
1713|---|---|
1714| 1 | 2 |
1715: Caption
1716";
1717 let ranges = detect_pipe_table_caption_ranges(content);
1718 assert_eq!(ranges.len(), 0);
1719 }
1720
1721 #[test]
1722 fn test_pipe_table_caption_no_trailing_newline() {
1723 let content = "\
1726| a | b |
1727|---|---|
1728| 1 | 2 |
1729
1730: Trailing caption";
1731 let ranges = detect_pipe_table_caption_ranges(content);
1732 assert_eq!(ranges.len(), 1);
1733 assert_eq!(ranges[0].end, content.len());
1734 assert_eq!(&content[ranges[0].start..ranges[0].end], ": Trailing caption");
1735 }
1736
1737 #[test]
1738 fn test_pipe_table_caption_handles_crlf() {
1739 let content = "| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\n\r\n: CRLF caption\r\n";
1741 let ranges = detect_pipe_table_caption_ranges(content);
1742 assert_eq!(ranges.len(), 1);
1743 let cap = &content[ranges[0].start..ranges[0].end];
1744 assert!(cap.starts_with(": CRLF caption"));
1745 }
1746
1747 #[test]
1748 fn test_pipe_table_caption_lone_colon_does_not_match() {
1749 let content = "\
1751| a | b |
1752|---|---|
1753| 1 | 2 |
1754
1755:
1756";
1757 let ranges = detect_pipe_table_caption_ranges(content);
1758 assert_eq!(ranges.len(), 0);
1759 }
1760
1761 #[test]
1762 fn test_detect_metadata_block_at_start() {
1763 let content = "---\ntitle: Doc\n---\n\nBody.\n";
1765 let ranges = detect_yaml_metadata_block_ranges(content);
1766 assert_eq!(ranges.len(), 1);
1767 assert_eq!(ranges[0].start, 0);
1768 }
1769
1770 #[test]
1771 fn test_detect_metadata_block_mid_document() {
1772 let content = "---\ntitle: Doc\n---\n\n# Heading\n\n---\nauthor: X\n---\n\nBody.\n";
1774 let ranges = detect_yaml_metadata_block_ranges(content);
1775 assert_eq!(ranges.len(), 2);
1776 }
1777
1778 #[test]
1779 fn test_metadata_block_uses_dot_terminator() {
1780 let content = "---\ntitle: Doc\n...\n\nBody.\n";
1782 let ranges = detect_yaml_metadata_block_ranges(content);
1783 assert_eq!(ranges.len(), 1);
1784 }
1785
1786 #[test]
1787 fn test_metadata_block_unterminated_opener_skipped() {
1788 let content = "---\ntitle: Doc\nbody continues forever\n";
1790 let ranges = detect_yaml_metadata_block_ranges(content);
1791 assert_eq!(ranges.len(), 0);
1792 }
1793
1794 #[test]
1795 fn test_metadata_block_dashes_after_text_are_not_opener() {
1796 let content = "Some prose paragraph.\n---\nbody: not-metadata\n---\n";
1799 let ranges = detect_yaml_metadata_block_ranges(content);
1800 assert_eq!(ranges.len(), 0);
1801 }
1802
1803 #[test]
1804 fn test_metadata_block_no_trailing_newline() {
1805 let content = "---\ntitle: Doc\n---";
1808 let ranges = detect_yaml_metadata_block_ranges(content);
1809 assert_eq!(ranges.len(), 1);
1810 assert_eq!(ranges[0].start, 0);
1811 assert_eq!(ranges[0].end, content.len());
1812 }
1813
1814 #[test]
1815 fn test_metadata_block_handles_crlf() {
1816 let content = "---\r\ntitle: Doc\r\n---\r\n\r\nBody.\r\n";
1818 let ranges = detect_yaml_metadata_block_ranges(content);
1819 assert_eq!(ranges.len(), 1);
1820 let block = &content[ranges[0].start..ranges[0].end];
1821 assert!(block.starts_with("---\r\n"));
1822 assert!(block.ends_with("---\r\n"));
1823 }
1824
1825 #[test]
1826 fn test_collect_pandoc_header_slugs_skips_code_blocks() {
1827 let content = "\
1828# Real Heading
1829
1830```bash
1831# This is a bash comment
1832#!/usr/bin/env bash
1833```
1834
1835# Another Heading
1836";
1837 let slugs = collect_pandoc_header_slugs(content);
1838 assert!(slugs.contains("real-heading"));
1839 assert!(slugs.contains("another-heading"));
1840 assert!(!slugs.contains("this-is-a-bash-comment"));
1841 assert!(!slugs.iter().any(|s| s.contains("usr-bin")));
1842 }
1843
1844 #[test]
1845 fn test_detect_simple_grid_table() {
1846 let content = "\
1847+---------+---------+
1848| col1 | col2 |
1849+=========+=========+
1850| a | b |
1851+---------+---------+
1852";
1853 let ranges = detect_grid_table_ranges(content);
1854 assert_eq!(ranges.len(), 1);
1855 assert_eq!(ranges[0].start, 0);
1856 assert_eq!(ranges[0].end, content.len());
1857 }
1858
1859 #[test]
1860 fn test_grid_table_with_surrounding_text() {
1861 let content = "\
1862Before.
1863
1864+---+---+
1865| a | b |
1866+---+---+
1867| 1 | 2 |
1868+---+---+
1869
1870After.
1871";
1872 let ranges = detect_grid_table_ranges(content);
1873 assert_eq!(ranges.len(), 1);
1874 let region = &content[ranges[0].start..ranges[0].end];
1875 assert!(region.contains("+---+---+"));
1876 assert!(!region.contains("Before"));
1877 assert!(!region.contains("After"));
1878 }
1879
1880 #[test]
1881 fn test_lone_plus_dash_line_is_not_a_table() {
1882 let content = "Just a +---+ in prose.\n";
1883 let ranges = detect_grid_table_ranges(content);
1884 assert_eq!(ranges.len(), 0);
1885 }
1886
1887 #[test]
1888 fn test_grid_table_no_trailing_newline() {
1889 let content = "+---+---+\n| a | b |\n+---+---+\n| 1 | 2 |\n+---+---+";
1892 let ranges = detect_grid_table_ranges(content);
1893 assert_eq!(ranges.len(), 1);
1894 assert_eq!(ranges[0].start, 0);
1895 assert_eq!(ranges[0].end, content.len());
1896 }
1897
1898 #[test]
1899 fn test_grid_table_crlf() {
1900 let content = "+---+---+\r\n| a | b |\r\n+---+---+\r\n| 1 | 2 |\r\n+---+---+\r\n";
1902 let ranges = detect_grid_table_ranges(content);
1903 assert_eq!(ranges.len(), 1);
1904 assert_eq!(ranges[0].start, 0);
1905 assert_eq!(ranges[0].end, content.len());
1906 }
1907
1908 #[test]
1909 fn test_grid_table_borders_only_no_content_row_rejected() {
1910 let content = "+---+\n+---+\n";
1912 let ranges = detect_grid_table_ranges(content);
1913 assert_eq!(ranges.len(), 0);
1914 }
1915
1916 #[test]
1921 fn test_detect_multi_line_table() {
1922 let content = "\
1923-------------------------------------------------------------
1924 Centered Default Right Left
1925 Header Aligned Aligned Aligned
1926----------- ------- --------------- -------------------------
1927 First row 12.0 Example of a row that
1928 spans multiple lines.
1929
1930 Second row 5.0 Here's another one. Note
1931 the blank line between
1932 rows.
1933-------------------------------------------------------------
1934";
1935 let ranges = detect_multi_line_table_ranges(content);
1936 assert_eq!(ranges.len(), 1);
1937 assert_eq!(ranges[0].start, 0);
1938 assert_eq!(ranges[0].end, content.len());
1939 }
1940
1941 #[test]
1942 fn test_simple_dash_header_underline_only_does_not_match() {
1943 let content = "Some text\n--------\nMore text\n";
1946 let ranges = detect_multi_line_table_ranges(content);
1947 assert_eq!(ranges.len(), 0);
1948 }
1949
1950 #[test]
1951 fn test_multi_line_table_no_trailing_newline() {
1952 let content = "\
1954-------------------------------------------------------------
1955 Centered Default Right Left
1956 Header Aligned Aligned Aligned
1957----------- ------- --------------- -------------------------
1958 First row 12.0 Example of a row that
1959 spans multiple lines.
1960
1961 Second row 5.0 Here's another one. Note
1962 the blank line between
1963 rows.
1964-------------------------------------------------------------";
1965 let ranges = detect_multi_line_table_ranges(content);
1966 assert_eq!(ranges.len(), 1);
1967 assert_eq!(ranges[0].end, content.len());
1968 }
1969
1970 #[test]
1971 fn test_multi_line_table_crlf() {
1972 let content = "\
1974-------------------------------------------------------------\r\n\
1975 Centered Default Right Left\r\n\
1976 Header Aligned Aligned Aligned\r\n\
1977----------- ------- --------------- -------------------------\r\n\
1978 First row 12.0 Example of a row that\r\n\
1979 spans multiple lines.\r\n\
1980\r\n\
1981 Second row 5.0 Here's another one. Note\r\n\
1982 the blank line between\r\n\
1983 rows.\r\n\
1984-------------------------------------------------------------\r\n";
1985 let ranges = detect_multi_line_table_ranges(content);
1986 assert_eq!(ranges.len(), 1);
1987 assert_eq!(ranges[0].start, 0);
1988 assert_eq!(ranges[0].end, content.len());
1989 }
1990
1991 #[test]
1992 fn test_multi_line_table_unterminated_skipped() {
1993 let content = "\
1995 Centered Default
1996 Header Aligned
1997----------- -------
1998 First row
1999 Second row
2000";
2001 let ranges = detect_multi_line_table_ranges(content);
2002 assert_eq!(ranges.len(), 0);
2003 }
2004
2005 #[test]
2006 fn test_multi_line_table_no_top_border() {
2007 let content = "\
2010 Centered Default Right Left
2011----------- ------- --------------- -------------------------
2012 First row 12.0 Example
2013 Second row 5.0 Another
2014-------------------------------------------------------------
2015";
2016 let ranges = detect_multi_line_table_ranges(content);
2017 assert_eq!(ranges.len(), 1);
2018 assert_eq!(ranges[0].start, 0);
2019 assert_eq!(ranges[0].end, content.len());
2020 }
2021
2022 #[test]
2023 fn test_is_pandoc_raw_block_lang() {
2024 assert!(is_pandoc_raw_block_lang("{=html}"));
2025 assert!(is_pandoc_raw_block_lang("{=latex}"));
2026 assert!(is_pandoc_raw_block_lang("{=docx}"));
2027 assert!(is_pandoc_raw_block_lang("{=rst}"));
2028 assert!(is_pandoc_raw_block_lang("{=open-document}"));
2030 assert!(is_pandoc_raw_block_lang("{=my_format}"));
2031 assert!(is_pandoc_raw_block_lang("{=HTML}"));
2034 assert!(!is_pandoc_raw_block_lang("{r}"));
2036 assert!(!is_pandoc_raw_block_lang("{python}"));
2037 assert!(!is_pandoc_raw_block_lang("{=}"));
2039 assert!(!is_pandoc_raw_block_lang("{= }"));
2040 assert!(!is_pandoc_raw_block_lang("=html"));
2041 assert!(!is_pandoc_raw_block_lang("{=html }"));
2043 assert!(!is_pandoc_raw_block_lang("{=ht ml}"));
2044 }
2045
2046 #[test]
2047 fn test_is_pandoc_code_class_attr() {
2048 assert!(is_pandoc_code_class_attr("{.python}"));
2050 assert!(is_pandoc_code_class_attr("{.haskell}"));
2051 assert!(is_pandoc_code_class_attr("{.rust}"));
2052 assert!(is_pandoc_code_class_attr("{.haskell .numberLines}"));
2054 assert!(is_pandoc_code_class_attr("{#myid .python}"));
2056 assert!(is_pandoc_code_class_attr("{.python startFrom=\"10\"}"));
2058 assert!(is_pandoc_code_class_attr("{#snippet .python startFrom=\"10\"}"));
2060 assert!(is_pandoc_code_class_attr("{.objective-c}"));
2062 assert!(is_pandoc_code_class_attr("{.my_lang}"));
2063
2064 assert!(!is_pandoc_code_class_attr("{}"));
2066 assert!(!is_pandoc_code_class_attr("{#myid}"));
2067 assert!(!is_pandoc_code_class_attr("{startFrom=\"10\"}"));
2068 assert!(!is_pandoc_code_class_attr("{=html}"));
2070 assert!(!is_pandoc_code_class_attr("{r}"));
2072 assert!(!is_pandoc_code_class_attr("{python}"));
2073 assert!(!is_pandoc_code_class_attr("{.}"));
2075 assert!(!is_pandoc_code_class_attr(".python"));
2077 assert!(!is_pandoc_code_class_attr("python"));
2078 }
2079}