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> {
316 use std::collections::{HashMap, HashSet};
317 let mut slugs = HashSet::new();
318 let mut base_counts: HashMap<String, usize> = HashMap::new();
319 let mut in_fence = false;
320 let mut fence_marker: Option<char> = None;
321 for line in content.lines() {
322 let trimmed = line.trim_start();
323 if let Some(c) = trimmed.chars().next()
327 && (c == '`' || c == '~')
328 {
329 let count = trimmed.chars().take_while(|&ch| ch == c).count();
330 if count >= 3 {
331 match fence_marker {
332 None => {
333 in_fence = true;
334 fence_marker = Some(c);
335 }
336 Some(m) if m == c => {
337 in_fence = false;
338 fence_marker = None;
339 }
340 _ => {}
341 }
342 continue;
343 }
344 }
345 if in_fence {
346 continue;
347 }
348 if let Some(rest) = trimmed.strip_prefix('#') {
349 let mut text = rest.trim_start_matches('#').trim();
350 if let Some(idx) = text.rfind(" {")
355 && let Some(close_rel) = text[idx + 2..].find('}')
356 && text[idx + 2 + close_rel + 1..].trim().is_empty()
357 {
358 text = &text[..idx];
359 }
360 let base = pandoc_header_slug(text);
361 let count = base_counts.entry(base.clone()).or_insert(0);
362 let slug = if *count == 0 {
363 base.clone()
364 } else {
365 format!("{base}-{count}")
366 };
367 *count += 1;
368 slugs.insert(slug);
369 }
370 }
371 slugs
372}
373
374static SUBSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~[^\s~]+~").unwrap());
389
390static SUPERSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\^[^\s^]+\^").unwrap());
392
393pub fn detect_subscript_superscript_ranges(content: &str) -> Vec<ByteRange> {
402 let bytes = content.as_bytes();
403 let mut ranges = Vec::new();
404
405 for m in SUBSCRIPT_PATTERN.find_iter(content) {
406 let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
408 let next = bytes.get(m.end()).copied().unwrap_or(0);
409 if prev != b'~' && next != b'~' {
410 ranges.push(ByteRange {
411 start: m.start(),
412 end: m.end(),
413 });
414 }
415 }
416 for m in SUPERSCRIPT_PATTERN.find_iter(content) {
417 let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
419 let next = bytes.get(m.end()).copied().unwrap_or(0);
420 if prev != b'^' && next != b'^' {
421 ranges.push(ByteRange {
422 start: m.start(),
423 end: m.end(),
424 });
425 }
426 }
427 ranges.sort_by_key(|r| r.start);
429 ranges
430}
431
432static INLINE_CODE_ATTR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`(\{[^}]+\})").unwrap());
445
446pub fn detect_inline_code_attr_ranges(content: &str) -> Vec<ByteRange> {
452 let mut ranges = Vec::new();
453 for caps in INLINE_CODE_ATTR.captures_iter(content) {
454 let m = caps.get(1).unwrap();
455 ranges.push(ByteRange {
456 start: m.start(),
457 end: m.end(),
458 });
459 }
460 ranges
461}
462
463static EXAMPLE_LIST_MARKER: LazyLock<Regex> =
478 LazyLock::new(|| Regex::new(r"(?m)^[ \t]*(\(@[A-Za-z0-9_-]*\))[ \t]+").unwrap());
479
480static EXAMPLE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\(@[A-Za-z0-9_-]+\))").unwrap());
483
484pub fn detect_example_list_marker_ranges(content: &str) -> Vec<ByteRange> {
489 let mut ranges = Vec::new();
490 for caps in EXAMPLE_LIST_MARKER.captures_iter(content) {
491 let m = caps.get(1).unwrap();
492 ranges.push(ByteRange {
493 start: m.start(),
494 end: m.end(),
495 });
496 }
497 ranges
498}
499
500pub fn detect_example_reference_ranges(content: &str, marker_ranges: &[ByteRange]) -> Vec<ByteRange> {
507 let mut ranges = Vec::new();
508 let marker_starts: std::collections::HashSet<usize> = marker_ranges.iter().map(|r| r.start).collect();
509 for caps in EXAMPLE_REFERENCE.captures_iter(content) {
510 let m = caps.get(1).unwrap();
511 if !marker_starts.contains(&m.start()) {
512 ranges.push(ByteRange {
513 start: m.start(),
514 end: m.end(),
515 });
516 }
517 }
518 ranges
519}
520
521static BRACKETED_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[^\]]+\]\{[^}]+\}").unwrap());
537
538pub fn detect_bracketed_span_ranges(content: &str) -> Vec<ByteRange> {
543 let mut ranges = Vec::new();
544 for m in BRACKETED_SPAN.find_iter(content) {
545 ranges.push(ByteRange {
546 start: m.start(),
547 end: m.end(),
548 });
549 }
550 ranges
551}
552
553pub fn detect_line_block_ranges(content: &str) -> Vec<ByteRange> {
575 let mut ranges = Vec::new();
576 let mut in_block = false;
577 let mut block_start = 0usize;
578 let mut block_end = 0usize;
579 let mut byte_offset = 0usize;
580
581 for line in content.split_inclusive('\n') {
582 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
583 let is_line_block_line = trimmed.starts_with("| ") && !trimmed.trim_end().ends_with('|');
584 let is_continuation = in_block
585 && !trimmed.is_empty()
586 && trimmed.starts_with(|c: char| c.is_whitespace())
587 && !trimmed.trim_start().starts_with('|');
588
589 if is_line_block_line || is_continuation {
590 if !in_block {
591 block_start = byte_offset;
592 in_block = true;
593 }
594 block_end = byte_offset + line.len();
595 } else if in_block {
596 ranges.push(ByteRange {
597 start: block_start,
598 end: block_end,
599 });
600 in_block = false;
601 }
602 byte_offset += line.len();
603 }
604 if in_block {
605 ranges.push(ByteRange {
606 start: block_start,
607 end: block_end,
608 });
609 }
610 ranges
611}
612
613pub fn detect_pipe_table_caption_ranges(content: &str) -> Vec<ByteRange> {
634 let mut lines: Vec<&str> = Vec::new();
635 let mut line_offsets: Vec<usize> = Vec::new();
636 let mut offset = 0usize;
637 for line in content.split_inclusive('\n') {
638 line_offsets.push(offset);
639 lines.push(line);
640 offset += line.len();
641 }
642 line_offsets.push(offset);
643
644 fn line_body(line: &str) -> &str {
645 line.trim_end_matches('\n').trim_end_matches('\r')
646 }
647 fn is_pipe_table_row(line: &str) -> bool {
648 let t = line_body(line).trim();
649 t.starts_with('|') && t.ends_with('|') && t.len() >= 3
650 }
651 fn is_caption_line(line: &str) -> bool {
652 line_body(line).trim_start().starts_with(": ")
653 }
654 fn is_blank(line: &str) -> bool {
655 line_body(line).trim().is_empty()
656 }
657
658 let mut ranges = Vec::new();
659 for (i, line) in lines.iter().enumerate() {
660 if !is_caption_line(line) {
661 continue;
662 }
663 let table_below = i + 2 < lines.len() && is_blank(lines[i + 1]) && is_pipe_table_row(lines[i + 2]);
664 let table_above = i >= 2 && is_blank(lines[i - 1]) && is_pipe_table_row(lines[i - 2]);
665 if table_below || table_above {
666 ranges.push(ByteRange {
667 start: line_offsets[i],
668 end: line_offsets[i + 1],
669 });
670 }
671 }
672 ranges
673}
674
675pub fn detect_yaml_metadata_block_ranges(content: &str) -> Vec<ByteRange> {
692 let mut lines: Vec<&str> = Vec::new();
693 let mut line_offsets: Vec<usize> = Vec::new();
694 let mut offset = 0usize;
695 for line in content.split_inclusive('\n') {
696 line_offsets.push(offset);
697 lines.push(line);
698 offset += line.len();
699 }
700 line_offsets.push(offset);
701
702 fn line_body(line: &str) -> &str {
703 line.trim_end_matches('\n').trim_end_matches('\r')
704 }
705 fn is_blank(line: &str) -> bool {
706 line_body(line).trim().is_empty()
707 }
708 fn is_opener(line: &str) -> bool {
709 line_body(line).trim_end() == "---"
710 }
711 fn is_closer(line: &str) -> bool {
712 let t = line_body(line).trim_end();
713 t == "---" || t == "..."
714 }
715
716 let mut ranges = Vec::new();
717 let mut i = 0;
718 while i < lines.len() {
719 let preceded_by_blank = i == 0 || is_blank(lines[i - 1]);
720 if preceded_by_blank && is_opener(lines[i]) {
721 let mut j = i + 1;
722 let mut found_closer = false;
723 while j < lines.len() {
724 if is_closer(lines[j]) {
725 ranges.push(ByteRange {
726 start: line_offsets[i],
727 end: line_offsets[j + 1],
728 });
729 i = j + 1;
730 found_closer = true;
731 break;
732 }
733 j += 1;
734 }
735 if !found_closer {
736 i += 1;
738 }
739 } else {
740 i += 1;
741 }
742 }
743 ranges
744}
745
746static GRID_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\+(?:[-=]+\+)+\s*$").unwrap());
758
759static GRID_CONTENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|.*\|\s*$").unwrap());
761
762pub fn detect_grid_table_ranges(content: &str) -> Vec<ByteRange> {
770 let mut lines: Vec<&str> = Vec::new();
771 let mut line_offsets: Vec<usize> = Vec::new();
772 let mut offset = 0usize;
773 for line in content.split_inclusive('\n') {
774 line_offsets.push(offset);
775 lines.push(line);
776 offset += line.len();
777 }
778 line_offsets.push(offset);
779
780 fn line_body(line: &str) -> &str {
781 line.trim_end_matches('\n').trim_end_matches('\r')
782 }
783 fn is_border(line: &str) -> bool {
784 GRID_BORDER.is_match(line_body(line))
785 }
786 fn is_content(line: &str) -> bool {
787 GRID_CONTENT.is_match(line_body(line))
788 }
789
790 let mut ranges = Vec::new();
791 let mut i = 0;
792 while i < lines.len() {
793 if is_border(lines[i]) {
794 let start_line = i;
795 let mut j = i + 1;
796 let mut last_border = i;
797 let mut saw_content = false;
798 while j < lines.len() {
799 if is_border(lines[j]) {
800 last_border = j;
801 j += 1;
802 } else if is_content(lines[j]) {
803 saw_content = true;
804 j += 1;
805 } else {
806 break;
807 }
808 }
809 if saw_content && last_border > start_line {
812 ranges.push(ByteRange {
813 start: line_offsets[start_line],
814 end: line_offsets[last_border + 1],
815 });
816 i = last_border + 1;
817 continue;
818 }
819 }
820 i += 1;
821 }
822 ranges
823}
824
825static MULTI_LINE_UNDERLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{2,}(?:\s+-{2,})+\s*$").unwrap());
838
839static MULTI_LINE_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{10,}\s*$").unwrap());
842
843pub fn detect_multi_line_table_ranges(content: &str) -> Vec<ByteRange> {
853 let mut lines: Vec<&str> = Vec::new();
854 let mut line_offsets: Vec<usize> = Vec::new();
855 let mut offset = 0usize;
856 for line in content.split_inclusive('\n') {
857 line_offsets.push(offset);
858 lines.push(line);
859 offset += line.len();
860 }
861 line_offsets.push(offset);
862
863 fn line_body(line: &str) -> &str {
864 line.trim_end_matches('\n').trim_end_matches('\r')
865 }
866 fn is_underline(line: &str) -> bool {
867 MULTI_LINE_UNDERLINE.is_match(line_body(line))
868 }
869 fn is_border(line: &str) -> bool {
870 MULTI_LINE_BORDER.is_match(line_body(line))
871 }
872
873 let mut ranges = Vec::new();
874 let mut i = 0;
875 while i < lines.len() {
876 if i >= 1 && is_underline(lines[i]) && !line_body(lines[i - 1]).is_empty() {
878 let mut header_start = i - 1;
882 while header_start > 0
883 && !line_body(lines[header_start - 1]).is_empty()
884 && !is_border(lines[header_start - 1])
885 && !is_underline(lines[header_start - 1])
886 {
887 header_start -= 1;
888 }
889
890 let start_line = if header_start > 0 && is_border(lines[header_start - 1]) {
892 header_start - 1
893 } else {
894 header_start
895 };
896
897 let mut j = i + 1;
899 let mut end_line: Option<usize> = None;
900 while j < lines.len() {
901 if is_border(lines[j]) {
902 end_line = Some(j);
904 break;
905 } else if j > i + 1 && is_underline(lines[j]) {
906 end_line = Some(j - 1);
909 break;
910 }
911 j += 1;
912 }
913
914 if let Some(end) = end_line {
915 ranges.push(ByteRange {
916 start: line_offsets[start_line],
917 end: line_offsets[end + 1],
918 });
919 i = end + 1;
920 continue;
921 }
922 }
924 i += 1;
925 }
926 ranges
927}
928
929pub fn detect_inline_footnote_ranges(content: &str) -> Vec<ByteRange> {
934 let mut ranges = Vec::new();
935 for caps in INLINE_FOOTNOTE_PATTERN.captures_iter(content) {
936 let m = caps.get(1).unwrap();
937 ranges.push(ByteRange {
938 start: m.start(),
939 end: m.end(),
940 });
941 }
942 ranges
943}
944
945pub fn find_citation_ranges(content: &str) -> Vec<ByteRange> {
954 let mut ranges = Vec::new();
955
956 let link_label_ranges: Vec<(usize, usize)> = LINK_LABEL_PATTERN
959 .captures_iter(content)
960 .filter_map(|c| c.get(1).map(|m| (m.start(), m.end())))
961 .collect();
962
963 let in_link_label = |pos: usize| -> bool { link_label_ranges.iter().any(|&(s, e)| pos >= s && pos < e) };
964
965 for mat in BRACKETED_CITATION_PATTERN.find_iter(content) {
967 if in_link_label(mat.start()) {
968 continue;
969 }
970 ranges.push(ByteRange {
971 start: mat.start(),
972 end: mat.end(),
973 });
974 }
975
976 for cap in INLINE_CITATION_PATTERN.captures_iter(content) {
978 if let Some(mat) = cap.get(1) {
979 let start = mat.start();
980 if in_link_label(start) {
981 continue;
982 }
983 if !ranges.iter().any(|r| start >= r.start && start < r.end) {
985 ranges.push(ByteRange { start, end: mat.end() });
986 }
987 }
988 }
989
990 ranges.sort_by_key(|r| r.start);
992 ranges
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 #[test]
1000 fn test_div_open_detection() {
1001 assert!(is_div_open("::: {.callout-note}"));
1003 assert!(is_div_open("::: {.callout-warning}"));
1004 assert!(is_div_open("::: {#myid .class}"));
1005 assert!(is_div_open("::: bordered"));
1006 assert!(is_div_open(" ::: {.note}")); assert!(is_div_open("::: {.callout-tip title=\"My Title\"}"));
1008
1009 assert!(!is_div_open(":::")); assert!(!is_div_open("::: ")); assert!(!is_div_open("Regular text"));
1013 assert!(!is_div_open("# Heading"));
1014 assert!(!is_div_open("```python")); }
1016
1017 #[test]
1018 fn test_div_close_detection() {
1019 assert!(is_div_close(":::"));
1020 assert!(is_div_close("::: "));
1021 assert!(is_div_close(" :::"));
1022 assert!(is_div_close(" ::: "));
1023
1024 assert!(!is_div_close("::: {.note}"));
1025 assert!(!is_div_close("::: class"));
1026 assert!(!is_div_close(":::note"));
1027 }
1028
1029 #[test]
1030 fn test_callout_detection() {
1031 assert!(is_callout_open("::: {.callout-note}"));
1032 assert!(is_callout_open("::: {.callout-warning}"));
1033 assert!(is_callout_open("::: {.callout-tip}"));
1034 assert!(is_callout_open("::: {.callout-important}"));
1035 assert!(is_callout_open("::: {.callout-caution}"));
1036 assert!(is_callout_open("::: {#myid .callout-note}"));
1037 assert!(is_callout_open("::: {.callout-note title=\"Title\"}"));
1038
1039 assert!(!is_callout_open("::: {.note}")); assert!(!is_callout_open("::: {.bordered}")); assert!(!is_callout_open("::: callout-note")); }
1043
1044 #[test]
1045 fn test_div_tracker() {
1046 let mut tracker = DivTracker::new();
1047
1048 assert!(tracker.process_line("::: {.callout-note}"));
1050 assert!(tracker.is_inside_div());
1051
1052 assert!(tracker.process_line("This is content."));
1054 assert!(tracker.is_inside_div());
1055
1056 assert!(!tracker.process_line(":::"));
1058 assert!(!tracker.is_inside_div());
1059 }
1060
1061 #[test]
1062 fn test_nested_divs() {
1063 let mut tracker = DivTracker::new();
1064
1065 assert!(tracker.process_line("::: {.outer}"));
1067 assert!(tracker.is_inside_div());
1068
1069 assert!(tracker.process_line(" ::: {.inner}"));
1071 assert!(tracker.is_inside_div());
1072
1073 assert!(tracker.process_line(" Content"));
1075 assert!(tracker.is_inside_div());
1076
1077 assert!(tracker.process_line(" :::"));
1079 assert!(tracker.is_inside_div());
1080
1081 assert!(!tracker.process_line(":::"));
1083 assert!(!tracker.is_inside_div());
1084 }
1085
1086 #[test]
1087 fn test_detect_div_block_ranges() {
1088 let content = r#"# Heading
1089
1090::: {.callout-note}
1091This is a note.
1092:::
1093
1094Regular text.
1095
1096::: {.bordered}
1097Content here.
1098:::
1099"#;
1100 let ranges = detect_div_block_ranges(content);
1101 assert_eq!(ranges.len(), 2);
1102
1103 let first_div_content = &content[ranges[0].start..ranges[0].end];
1105 assert!(first_div_content.contains("callout-note"));
1106 assert!(first_div_content.contains("This is a note"));
1107
1108 let second_div_content = &content[ranges[1].start..ranges[1].end];
1110 assert!(second_div_content.contains("bordered"));
1111 assert!(second_div_content.contains("Content here"));
1112 }
1113
1114 #[test]
1115 fn test_pandoc_attributes() {
1116 assert!(has_pandoc_attributes("# Heading {#custom-id}"));
1117 assert!(has_pandoc_attributes("# Heading {.unnumbered}"));
1118 assert!(has_pandoc_attributes("{#fig-1 width=\"50%\"}"));
1119 assert!(has_pandoc_attributes("{#id .class key=\"value\"}"));
1120
1121 assert!(!has_pandoc_attributes("# Heading"));
1122 assert!(!has_pandoc_attributes("Regular text"));
1123 assert!(!has_pandoc_attributes("{}"));
1124 }
1125
1126 #[test]
1127 fn test_div_with_title_attribute() {
1128 let content = r#"::: {.callout-note title="Important Note"}
1129This is the content of the note.
1130It can span multiple lines.
1131:::
1132"#;
1133 let ranges = detect_div_block_ranges(content);
1134 assert_eq!(ranges.len(), 1);
1135 assert!(is_callout_open("::: {.callout-note title=\"Important Note\"}"));
1136 }
1137
1138 #[test]
1139 fn test_unclosed_div() {
1140 let content = r#"::: {.callout-note}
1141This note is never closed.
1142"#;
1143 let ranges = detect_div_block_ranges(content);
1144 assert_eq!(ranges.len(), 1);
1145 assert_eq!(ranges[0].end, content.len());
1147 }
1148
1149 #[test]
1150 fn test_heading_inside_callout() {
1151 let content = r#"::: {.callout-warning}
1152## Warning Title
1153
1154Warning content here.
1155:::
1156"#;
1157 let ranges = detect_div_block_ranges(content);
1158 assert_eq!(ranges.len(), 1);
1159
1160 let div_content = &content[ranges[0].start..ranges[0].end];
1161 assert!(div_content.contains("## Warning Title"));
1162 }
1163
1164 #[test]
1166 fn test_has_citations() {
1167 assert!(has_citations("See @smith2020 for details."));
1168 assert!(has_citations("[@smith2020]"));
1169 assert!(has_citations("Multiple [@a; @b] citations"));
1170 assert!(!has_citations("No citations here"));
1171 assert!(has_citations("Email: user@example.com"));
1173 }
1174
1175 #[test]
1176 fn test_bracketed_citation_detection() {
1177 let content = "See [@smith2020] for more info.";
1178 let ranges = find_citation_ranges(content);
1179 assert_eq!(ranges.len(), 1);
1180 assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020]");
1181 }
1182
1183 #[test]
1184 fn test_inline_citation_detection() {
1185 let content = "As @smith2020 argues, this is true.";
1186 let ranges = find_citation_ranges(content);
1187 assert_eq!(ranges.len(), 1);
1188 assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1189 }
1190
1191 #[test]
1192 fn test_multiple_citations_in_brackets() {
1193 let content = "See [@smith2020; @jones2021] for details.";
1194 let ranges = find_citation_ranges(content);
1195 assert_eq!(ranges.len(), 1);
1196 assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020; @jones2021]");
1197 }
1198
1199 #[test]
1200 fn test_citation_with_prefix() {
1201 let content = "[see @smith2020, p. 10]";
1202 let ranges = find_citation_ranges(content);
1203 assert_eq!(ranges.len(), 1);
1204 assert_eq!(&content[ranges[0].start..ranges[0].end], "[see @smith2020, p. 10]");
1205 }
1206
1207 #[test]
1208 fn test_suppress_author_citation() {
1209 let content = "The theory [-@smith2020] states that...";
1210 let ranges = find_citation_ranges(content);
1211 assert_eq!(ranges.len(), 1);
1212 assert_eq!(&content[ranges[0].start..ranges[0].end], "[-@smith2020]");
1213 }
1214
1215 #[test]
1216 fn test_mixed_citations() {
1217 let content = "@smith2020 argues that [@jones2021] is wrong.";
1218 let ranges = find_citation_ranges(content);
1219 assert_eq!(ranges.len(), 2);
1220 assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1222 assert_eq!(&content[ranges[1].start..ranges[1].end], "[@jones2021]");
1224 }
1225
1226 #[test]
1227 fn test_email_not_confused_with_citation() {
1228 let content = "Contact user@example.com for help.";
1231 let ranges = find_citation_ranges(content);
1232 assert!(
1234 ranges.is_empty()
1235 || !ranges.iter().any(|r| {
1236 let s = &content[r.start..r.end];
1237 s.contains("example.com")
1238 })
1239 );
1240 }
1241
1242 #[test]
1247 fn test_bracketed_link_text_with_email_not_citation() {
1248 let content = "[contact user@example.com](#missing)";
1249 let ranges = find_citation_ranges(content);
1250 assert!(
1251 ranges.is_empty(),
1252 "Bracketed link text with embedded email must not be detected as a Pandoc citation: {ranges:?}"
1253 );
1254 }
1255
1256 #[test]
1258 fn test_bracketed_link_text_with_email_empty_href_not_citation() {
1259 let content = "[contact user@example.com]()";
1260 let ranges = find_citation_ranges(content);
1261 assert!(
1262 ranges.is_empty(),
1263 "Bracketed link text with embedded email and empty href must not be a Pandoc citation: {ranges:?}"
1264 );
1265 }
1266
1267 #[test]
1272 fn test_bracketed_text_followed_by_inline_link_not_citation() {
1273 let content = "[see @smith2020](#missing)";
1274 let ranges = find_citation_ranges(content);
1275 assert!(
1276 ranges.is_empty(),
1277 "Bracketed text followed by `(...)` is a link, not a citation: {ranges:?}"
1278 );
1279 }
1280
1281 #[test]
1283 fn test_bracketed_text_followed_by_empty_inline_link_not_citation() {
1284 let content = "[see @smith2020]()";
1285 let ranges = find_citation_ranges(content);
1286 assert!(
1287 ranges.is_empty(),
1288 "Bracketed text followed by `()` is a link with empty href, not a citation: {ranges:?}"
1289 );
1290 }
1291
1292 #[test]
1296 fn test_bracketed_text_followed_by_reference_link_not_citation() {
1297 let content = "[see @smith2020][ref]";
1298 let ranges = find_citation_ranges(content);
1299 assert!(
1300 ranges.is_empty(),
1301 "Bracketed text followed by `[ref]` is a reference link, not a citation: {ranges:?}"
1302 );
1303 }
1304
1305 #[test]
1308 fn test_standalone_bracketed_citation_still_detected() {
1309 let content = "See [see @smith2020] for details.";
1310 let ranges = find_citation_ranges(content);
1311 assert!(
1312 ranges.iter().any(|r| &content[r.start..r.end] == "[see @smith2020]"),
1313 "Standalone bracketed citation must still be detected: {ranges:?}"
1314 );
1315 }
1316
1317 #[test]
1319 fn test_bracketed_citation_followed_by_punctuation_still_detected() {
1320 let content = "Note [@smith2020].";
1321 let ranges = find_citation_ranges(content);
1322 assert!(
1323 ranges.iter().any(|r| &content[r.start..r.end] == "[@smith2020]"),
1324 "Bracketed citation followed by `.` must still be detected: {ranges:?}"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_detect_inline_footnotes() {
1330 let content = "See ^[a quick note] here.\nAnd ^[another one] too.\n";
1331 let ranges = detect_inline_footnote_ranges(content);
1332 assert_eq!(ranges.len(), 2);
1333 let first_start = content.find("^[").unwrap();
1335 let first_end = content[first_start..].find(']').unwrap() + first_start + 1;
1336 assert_eq!(ranges[0].start, first_start);
1337 assert_eq!(ranges[0].end, first_end);
1338 let second_start = content[first_end..].find("^[").unwrap() + first_end;
1340 let second_end = content[second_start..].find(']').unwrap() + second_start + 1;
1341 assert_eq!(ranges[1].start, second_start);
1342 assert_eq!(ranges[1].end, second_end);
1343 }
1344
1345 #[test]
1346 fn test_inline_footnote_with_brackets_inside() {
1347 let content = "Note ^[ref to [other] thing] here.\n";
1352 let ranges = detect_inline_footnote_ranges(content);
1353 assert_eq!(ranges.len(), 1);
1354 }
1355
1356 #[test]
1357 fn test_inline_footnote_does_not_match_image_or_link() {
1358 let content = "An image  and a link [txt](url).\n";
1360 let ranges = detect_inline_footnote_ranges(content);
1361 assert_eq!(ranges.len(), 0);
1362 }
1363
1364 #[test]
1365 fn test_implicit_header_reference_slug() {
1366 assert_eq!(pandoc_header_slug("My Section"), "my-section");
1369 assert_eq!(pandoc_header_slug("API: v2!"), "api-v2");
1370 assert_eq!(pandoc_header_slug(" Trim Me "), "trim-me");
1371 assert_eq!(pandoc_header_slug("Multiple Spaces"), "multiple-spaces");
1372 }
1373
1374 #[test]
1375 fn test_collect_pandoc_header_slugs() {
1376 let content = "# My Section\n\n## Sub-section\n\nbody\n";
1377 let slugs = collect_pandoc_header_slugs(content);
1378 assert!(slugs.contains("my-section"));
1379 assert!(slugs.contains("sub-section"));
1380 }
1381
1382 #[test]
1383 fn test_collect_pandoc_header_slugs_strips_attribute_block() {
1384 let content = "# My Section {#custom-id .red}\n## Plain Section\n";
1385 let slugs = collect_pandoc_header_slugs(content);
1386 assert!(slugs.contains("my-section"));
1387 assert!(slugs.contains("plain-section"));
1388 assert!(!slugs.iter().any(|s| s.contains("custom-id")));
1390 }
1391
1392 #[test]
1393 fn test_collect_pandoc_header_slugs_preserves_body_braces() {
1394 let content = "# Some {curly} word in title\n";
1396 let slugs = collect_pandoc_header_slugs(content);
1397 assert!(slugs.contains("some-curly-word-in-title"));
1398 }
1399
1400 #[test]
1401 fn test_collect_pandoc_header_slugs_disambiguates_duplicates() {
1402 let content = "# A.\n\nbody\n\n# A.\n";
1406 let slugs = collect_pandoc_header_slugs(content);
1407 assert!(slugs.contains("a."), "first occurrence should expose base slug `a.`");
1408 assert!(
1409 slugs.contains("a.-1"),
1410 "second occurrence should expose `a.-1`: got {slugs:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_collect_pandoc_header_slugs_three_duplicates_get_two_suffixes() {
1416 let content = "# Intro\n\n# Intro\n\n# Intro\n";
1417 let slugs = collect_pandoc_header_slugs(content);
1418 assert!(slugs.contains("intro"));
1419 assert!(slugs.contains("intro-1"));
1420 assert!(slugs.contains("intro-2"));
1421 assert!(
1422 !slugs.contains("intro-3"),
1423 "three occurrences must produce only -1 and -2 suffixes, not -3: got {slugs:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_collect_pandoc_header_slugs_unique_headings_get_no_suffix() {
1429 let content = "# Foo\n\n# Bar\n\n# Baz\n";
1430 let slugs = collect_pandoc_header_slugs(content);
1431 assert!(slugs.contains("foo"));
1432 assert!(slugs.contains("bar"));
1433 assert!(slugs.contains("baz"));
1434 assert!(!slugs.contains("foo-1"));
1436 assert!(!slugs.contains("bar-1"));
1437 assert!(!slugs.contains("baz-1"));
1438 }
1439
1440 #[test]
1441 fn test_detect_example_list_markers() {
1442 let content = "(@) First item.\n(@good) Second item.\n(@) Third item.\n";
1443 let ranges = detect_example_list_marker_ranges(content);
1444 assert_eq!(ranges.len(), 3);
1445 assert_eq!(ranges[0].start, 0);
1446 assert_eq!(&content[ranges[0].start..ranges[0].end], "(@)");
1447 let second_start = content.find("(@good)").unwrap();
1448 assert_eq!(ranges[1].start, second_start);
1449 assert_eq!(&content[ranges[1].start..ranges[1].end], "(@good)");
1450 }
1451
1452 #[test]
1453 fn test_detect_example_references() {
1454 let content = "As shown in (@good), this works.\n";
1456 let marker_ranges = detect_example_list_marker_ranges(content);
1457 let ranges = detect_example_reference_ranges(content, &marker_ranges);
1458 assert_eq!(ranges.len(), 1);
1459 }
1460
1461 #[test]
1462 fn test_example_marker_must_be_at_line_start() {
1463 let content = "Inline (@) is not a marker.\n";
1464 let ranges = detect_example_list_marker_ranges(content);
1465 assert_eq!(ranges.len(), 0);
1466 }
1467
1468 #[test]
1469 fn test_detect_subscript() {
1470 let content = "H~2~O is water.\n";
1471 let ranges = detect_subscript_superscript_ranges(content);
1472 assert_eq!(ranges.len(), 1);
1473 assert_eq!(&content[ranges[0].start..ranges[0].end], "~2~");
1474 }
1475
1476 #[test]
1477 fn test_detect_superscript() {
1478 let content = "2^10^ is 1024.\n";
1479 let ranges = detect_subscript_superscript_ranges(content);
1480 assert_eq!(ranges.len(), 1);
1481 assert_eq!(&content[ranges[0].start..ranges[0].end], "^10^");
1482 }
1483
1484 #[test]
1485 fn test_subscript_does_not_match_strikethrough() {
1486 let content = "This is ~~struck~~.\n";
1488 let ranges = detect_subscript_superscript_ranges(content);
1489 assert_eq!(ranges.len(), 0);
1490 }
1491
1492 #[test]
1493 fn test_superscript_with_internal_space_is_not_matched() {
1494 let content = "x^a b^ y\n";
1496 let ranges = detect_subscript_superscript_ranges(content);
1497 assert_eq!(ranges.len(), 0);
1498 }
1499
1500 #[test]
1501 fn test_subscript_at_start_of_input() {
1502 let content = "~x~ rest of line\n";
1504 let ranges = detect_subscript_superscript_ranges(content);
1505 assert_eq!(ranges.len(), 1);
1506 assert_eq!(&content[ranges[0].start..ranges[0].end], "~x~");
1507 }
1508
1509 #[test]
1510 fn test_superscript_at_end_of_input_no_newline() {
1511 let content = "text ^x^";
1513 let ranges = detect_subscript_superscript_ranges(content);
1514 assert_eq!(ranges.len(), 1);
1515 assert_eq!(&content[ranges[0].start..ranges[0].end], "^x^");
1516 }
1517
1518 #[test]
1519 fn test_detect_inline_code_attribute() {
1520 let content = "Use `print()`{.python} for output.\n";
1522 let ranges = detect_inline_code_attr_ranges(content);
1523 assert_eq!(ranges.len(), 1);
1524 let r = &ranges[0];
1525 assert_eq!(&content[r.start..r.end], "{.python}");
1526 }
1527
1528 #[test]
1529 fn test_inline_code_attribute_only_after_backtick() {
1530 let content = "Use {.example} for the class.\n";
1532 let ranges = detect_inline_code_attr_ranges(content);
1533 assert_eq!(ranges.len(), 0);
1534 }
1535
1536 #[test]
1537 fn test_inline_code_attribute_multiple_on_one_line() {
1538 let content = "Use `a`{.x} and `b`{.y} here.\n";
1539 let ranges = detect_inline_code_attr_ranges(content);
1540 assert_eq!(ranges.len(), 2);
1541 assert_eq!(&content[ranges[0].start..ranges[0].end], "{.x}");
1542 assert_eq!(&content[ranges[1].start..ranges[1].end], "{.y}");
1543 }
1544
1545 #[test]
1546 fn test_inline_code_attribute_compound_attributes() {
1547 let content = "Use `code`{.lang #id key=value} here.\n";
1549 let ranges = detect_inline_code_attr_ranges(content);
1550 assert_eq!(ranges.len(), 1);
1551 assert_eq!(&content[ranges[0].start..ranges[0].end], "{.lang #id key=value}");
1552 }
1553
1554 #[test]
1555 fn test_detect_bracketed_span() {
1556 let content = "This is [some text]{.smallcaps} here.\n";
1557 let ranges = detect_bracketed_span_ranges(content);
1558 assert_eq!(ranges.len(), 1);
1559 let r = &ranges[0];
1560 assert_eq!(&content[r.start..r.end], "[some text]{.smallcaps}");
1561 }
1562
1563 #[test]
1564 fn test_bracketed_span_does_not_match_link() {
1565 let content = "A [link](http://example.com) here.\n";
1567 let ranges = detect_bracketed_span_ranges(content);
1568 assert_eq!(ranges.len(), 0);
1569 }
1570
1571 #[test]
1572 fn test_bracketed_span_does_not_match_reference_link() {
1573 let content = "A [ref][def] here.\n[def]: http://example.com\n";
1575 let ranges = detect_bracketed_span_ranges(content);
1576 assert_eq!(ranges.len(), 0);
1577 }
1578
1579 #[test]
1580 fn test_bracketed_span_multiple_on_one_line() {
1581 let content = "[one]{.a} and [two]{.b} together.\n";
1582 let ranges = detect_bracketed_span_ranges(content);
1583 assert_eq!(ranges.len(), 2);
1584 assert_eq!(&content[ranges[0].start..ranges[0].end], "[one]{.a}");
1585 assert_eq!(&content[ranges[1].start..ranges[1].end], "[two]{.b}");
1586 }
1587
1588 #[test]
1589 fn test_bracketed_span_rejects_empty_content() {
1590 let content = "[]{.x} and [x]{} here.\n";
1592 let ranges = detect_bracketed_span_ranges(content);
1593 assert_eq!(ranges.len(), 0);
1594 }
1595
1596 #[test]
1597 fn test_bracketed_span_at_start_of_line() {
1598 let content = "[head]{.intro} starts the line.\n";
1599 let ranges = detect_bracketed_span_ranges(content);
1600 assert_eq!(ranges.len(), 1);
1601 assert_eq!(ranges[0].start, 0);
1602 assert_eq!(&content[ranges[0].start..ranges[0].end], "[head]{.intro}");
1603 }
1604
1605 #[test]
1606 fn test_detect_line_block_single() {
1607 let content = "| The Lord of the Rings\n| by J.R.R. Tolkien\n";
1608 let ranges = detect_line_block_ranges(content);
1609 assert_eq!(ranges.len(), 1);
1610 assert_eq!(ranges[0].start, 0);
1611 assert_eq!(ranges[0].end, content.len());
1612 }
1613
1614 #[test]
1615 fn test_line_block_no_trailing_newline() {
1616 let content = "| Only line";
1618 let ranges = detect_line_block_ranges(content);
1619 assert_eq!(ranges.len(), 1);
1620 assert_eq!(ranges[0].start, 0);
1621 assert_eq!(ranges[0].end, content.len());
1622 }
1623
1624 #[test]
1625 fn test_line_block_indented_pipe_is_not_continuation() {
1626 let content = "| First\n | indented\n";
1629 let ranges = detect_line_block_ranges(content);
1630 assert_eq!(ranges.len(), 1);
1631 assert_eq!(ranges[0].end, "| First\n".len());
1632 }
1633
1634 #[test]
1635 fn test_line_block_continuation_with_indent() {
1636 let content = "| First line\n continuation\n| Second\n";
1639 let ranges = detect_line_block_ranges(content);
1640 assert_eq!(ranges.len(), 1);
1641 }
1642
1643 #[test]
1644 fn test_line_block_separated_by_blank() {
1645 let content = "| Block A\n\n| Block B\n";
1646 let ranges = detect_line_block_ranges(content);
1647 assert_eq!(ranges.len(), 2);
1648 }
1649
1650 #[test]
1651 fn test_line_block_does_not_match_pipe_table() {
1652 let content = "| col1 | col2 |\n|------|------|\n";
1654 let ranges = detect_line_block_ranges(content);
1655 assert_eq!(ranges.len(), 0);
1656 }
1657
1658 #[test]
1659 fn test_detect_pipe_table_caption_below() {
1660 let content = "\
1661| col1 | col2 |
1662|------|------|
1663| a | b |
1664
1665: My caption
1666";
1667 let ranges = detect_pipe_table_caption_ranges(content);
1668 assert_eq!(ranges.len(), 1);
1669 let cap = &content[ranges[0].start..ranges[0].end];
1670 assert!(cap.starts_with(": My caption"));
1671 }
1672
1673 #[test]
1674 fn test_detect_pipe_table_caption_above() {
1675 let content = "\
1676: Caption first
1677
1678| col1 | col2 |
1679|------|------|
1680| a | b |
1681";
1682 let ranges = detect_pipe_table_caption_ranges(content);
1683 assert_eq!(ranges.len(), 1);
1684 }
1685
1686 #[test]
1687 fn test_colon_line_without_adjacent_table_is_definition_term() {
1688 let content = "Term\n: definition\n";
1690 let ranges = detect_pipe_table_caption_ranges(content);
1691 assert_eq!(ranges.len(), 0);
1692 }
1693
1694 #[test]
1695 fn test_pipe_table_caption_two_blank_lines_does_not_match() {
1696 let content = "\
1698| a | b |
1699|---|---|
1700| 1 | 2 |
1701
1702
1703: Caption
1704";
1705 let ranges = detect_pipe_table_caption_ranges(content);
1706 assert_eq!(ranges.len(), 0);
1707 }
1708
1709 #[test]
1710 fn test_pipe_table_caption_no_blank_line_does_not_match() {
1711 let content = "\
1713| a | b |
1714|---|---|
1715| 1 | 2 |
1716: Caption
1717";
1718 let ranges = detect_pipe_table_caption_ranges(content);
1719 assert_eq!(ranges.len(), 0);
1720 }
1721
1722 #[test]
1723 fn test_pipe_table_caption_no_trailing_newline() {
1724 let content = "\
1727| a | b |
1728|---|---|
1729| 1 | 2 |
1730
1731: Trailing caption";
1732 let ranges = detect_pipe_table_caption_ranges(content);
1733 assert_eq!(ranges.len(), 1);
1734 assert_eq!(ranges[0].end, content.len());
1735 assert_eq!(&content[ranges[0].start..ranges[0].end], ": Trailing caption");
1736 }
1737
1738 #[test]
1739 fn test_pipe_table_caption_handles_crlf() {
1740 let content = "| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\n\r\n: CRLF caption\r\n";
1742 let ranges = detect_pipe_table_caption_ranges(content);
1743 assert_eq!(ranges.len(), 1);
1744 let cap = &content[ranges[0].start..ranges[0].end];
1745 assert!(cap.starts_with(": CRLF caption"));
1746 }
1747
1748 #[test]
1749 fn test_pipe_table_caption_lone_colon_does_not_match() {
1750 let content = "\
1752| a | b |
1753|---|---|
1754| 1 | 2 |
1755
1756:
1757";
1758 let ranges = detect_pipe_table_caption_ranges(content);
1759 assert_eq!(ranges.len(), 0);
1760 }
1761
1762 #[test]
1763 fn test_detect_metadata_block_at_start() {
1764 let content = "---\ntitle: Doc\n---\n\nBody.\n";
1766 let ranges = detect_yaml_metadata_block_ranges(content);
1767 assert_eq!(ranges.len(), 1);
1768 assert_eq!(ranges[0].start, 0);
1769 }
1770
1771 #[test]
1772 fn test_detect_metadata_block_mid_document() {
1773 let content = "---\ntitle: Doc\n---\n\n# Heading\n\n---\nauthor: X\n---\n\nBody.\n";
1775 let ranges = detect_yaml_metadata_block_ranges(content);
1776 assert_eq!(ranges.len(), 2);
1777 }
1778
1779 #[test]
1780 fn test_metadata_block_uses_dot_terminator() {
1781 let content = "---\ntitle: Doc\n...\n\nBody.\n";
1783 let ranges = detect_yaml_metadata_block_ranges(content);
1784 assert_eq!(ranges.len(), 1);
1785 }
1786
1787 #[test]
1788 fn test_metadata_block_unterminated_opener_skipped() {
1789 let content = "---\ntitle: Doc\nbody continues forever\n";
1791 let ranges = detect_yaml_metadata_block_ranges(content);
1792 assert_eq!(ranges.len(), 0);
1793 }
1794
1795 #[test]
1796 fn test_metadata_block_dashes_after_text_are_not_opener() {
1797 let content = "Some prose paragraph.\n---\nbody: not-metadata\n---\n";
1800 let ranges = detect_yaml_metadata_block_ranges(content);
1801 assert_eq!(ranges.len(), 0);
1802 }
1803
1804 #[test]
1805 fn test_metadata_block_no_trailing_newline() {
1806 let content = "---\ntitle: Doc\n---";
1809 let ranges = detect_yaml_metadata_block_ranges(content);
1810 assert_eq!(ranges.len(), 1);
1811 assert_eq!(ranges[0].start, 0);
1812 assert_eq!(ranges[0].end, content.len());
1813 }
1814
1815 #[test]
1816 fn test_metadata_block_handles_crlf() {
1817 let content = "---\r\ntitle: Doc\r\n---\r\n\r\nBody.\r\n";
1819 let ranges = detect_yaml_metadata_block_ranges(content);
1820 assert_eq!(ranges.len(), 1);
1821 let block = &content[ranges[0].start..ranges[0].end];
1822 assert!(block.starts_with("---\r\n"));
1823 assert!(block.ends_with("---\r\n"));
1824 }
1825
1826 #[test]
1827 fn test_collect_pandoc_header_slugs_skips_code_blocks() {
1828 let content = "\
1829# Real Heading
1830
1831```bash
1832# This is a bash comment
1833#!/usr/bin/env bash
1834```
1835
1836# Another Heading
1837";
1838 let slugs = collect_pandoc_header_slugs(content);
1839 assert!(slugs.contains("real-heading"));
1840 assert!(slugs.contains("another-heading"));
1841 assert!(!slugs.contains("this-is-a-bash-comment"));
1842 assert!(!slugs.iter().any(|s| s.contains("usr-bin")));
1843 }
1844
1845 #[test]
1846 fn test_detect_simple_grid_table() {
1847 let content = "\
1848+---------+---------+
1849| col1 | col2 |
1850+=========+=========+
1851| a | b |
1852+---------+---------+
1853";
1854 let ranges = detect_grid_table_ranges(content);
1855 assert_eq!(ranges.len(), 1);
1856 assert_eq!(ranges[0].start, 0);
1857 assert_eq!(ranges[0].end, content.len());
1858 }
1859
1860 #[test]
1861 fn test_grid_table_with_surrounding_text() {
1862 let content = "\
1863Before.
1864
1865+---+---+
1866| a | b |
1867+---+---+
1868| 1 | 2 |
1869+---+---+
1870
1871After.
1872";
1873 let ranges = detect_grid_table_ranges(content);
1874 assert_eq!(ranges.len(), 1);
1875 let region = &content[ranges[0].start..ranges[0].end];
1876 assert!(region.contains("+---+---+"));
1877 assert!(!region.contains("Before"));
1878 assert!(!region.contains("After"));
1879 }
1880
1881 #[test]
1882 fn test_lone_plus_dash_line_is_not_a_table() {
1883 let content = "Just a +---+ in prose.\n";
1884 let ranges = detect_grid_table_ranges(content);
1885 assert_eq!(ranges.len(), 0);
1886 }
1887
1888 #[test]
1889 fn test_grid_table_no_trailing_newline() {
1890 let content = "+---+---+\n| a | b |\n+---+---+\n| 1 | 2 |\n+---+---+";
1893 let ranges = detect_grid_table_ranges(content);
1894 assert_eq!(ranges.len(), 1);
1895 assert_eq!(ranges[0].start, 0);
1896 assert_eq!(ranges[0].end, content.len());
1897 }
1898
1899 #[test]
1900 fn test_grid_table_crlf() {
1901 let content = "+---+---+\r\n| a | b |\r\n+---+---+\r\n| 1 | 2 |\r\n+---+---+\r\n";
1903 let ranges = detect_grid_table_ranges(content);
1904 assert_eq!(ranges.len(), 1);
1905 assert_eq!(ranges[0].start, 0);
1906 assert_eq!(ranges[0].end, content.len());
1907 }
1908
1909 #[test]
1910 fn test_grid_table_borders_only_no_content_row_rejected() {
1911 let content = "+---+\n+---+\n";
1913 let ranges = detect_grid_table_ranges(content);
1914 assert_eq!(ranges.len(), 0);
1915 }
1916
1917 #[test]
1922 fn test_detect_multi_line_table() {
1923 let content = "\
1924-------------------------------------------------------------
1925 Centered Default Right Left
1926 Header Aligned Aligned Aligned
1927----------- ------- --------------- -------------------------
1928 First row 12.0 Example of a row that
1929 spans multiple lines.
1930
1931 Second row 5.0 Here's another one. Note
1932 the blank line between
1933 rows.
1934-------------------------------------------------------------
1935";
1936 let ranges = detect_multi_line_table_ranges(content);
1937 assert_eq!(ranges.len(), 1);
1938 assert_eq!(ranges[0].start, 0);
1939 assert_eq!(ranges[0].end, content.len());
1940 }
1941
1942 #[test]
1943 fn test_simple_dash_header_underline_only_does_not_match() {
1944 let content = "Some text\n--------\nMore text\n";
1947 let ranges = detect_multi_line_table_ranges(content);
1948 assert_eq!(ranges.len(), 0);
1949 }
1950
1951 #[test]
1952 fn test_multi_line_table_no_trailing_newline() {
1953 let content = "\
1955-------------------------------------------------------------
1956 Centered Default Right Left
1957 Header Aligned Aligned Aligned
1958----------- ------- --------------- -------------------------
1959 First row 12.0 Example of a row that
1960 spans multiple lines.
1961
1962 Second row 5.0 Here's another one. Note
1963 the blank line between
1964 rows.
1965-------------------------------------------------------------";
1966 let ranges = detect_multi_line_table_ranges(content);
1967 assert_eq!(ranges.len(), 1);
1968 assert_eq!(ranges[0].end, content.len());
1969 }
1970
1971 #[test]
1972 fn test_multi_line_table_crlf() {
1973 let content = "\
1975-------------------------------------------------------------\r\n\
1976 Centered Default Right Left\r\n\
1977 Header Aligned Aligned Aligned\r\n\
1978----------- ------- --------------- -------------------------\r\n\
1979 First row 12.0 Example of a row that\r\n\
1980 spans multiple lines.\r\n\
1981\r\n\
1982 Second row 5.0 Here's another one. Note\r\n\
1983 the blank line between\r\n\
1984 rows.\r\n\
1985-------------------------------------------------------------\r\n";
1986 let ranges = detect_multi_line_table_ranges(content);
1987 assert_eq!(ranges.len(), 1);
1988 assert_eq!(ranges[0].start, 0);
1989 assert_eq!(ranges[0].end, content.len());
1990 }
1991
1992 #[test]
1993 fn test_multi_line_table_unterminated_skipped() {
1994 let content = "\
1996 Centered Default
1997 Header Aligned
1998----------- -------
1999 First row
2000 Second row
2001";
2002 let ranges = detect_multi_line_table_ranges(content);
2003 assert_eq!(ranges.len(), 0);
2004 }
2005
2006 #[test]
2007 fn test_multi_line_table_no_top_border() {
2008 let content = "\
2011 Centered Default Right Left
2012----------- ------- --------------- -------------------------
2013 First row 12.0 Example
2014 Second row 5.0 Another
2015-------------------------------------------------------------
2016";
2017 let ranges = detect_multi_line_table_ranges(content);
2018 assert_eq!(ranges.len(), 1);
2019 assert_eq!(ranges[0].start, 0);
2020 assert_eq!(ranges[0].end, content.len());
2021 }
2022
2023 #[test]
2024 fn test_is_pandoc_raw_block_lang() {
2025 assert!(is_pandoc_raw_block_lang("{=html}"));
2026 assert!(is_pandoc_raw_block_lang("{=latex}"));
2027 assert!(is_pandoc_raw_block_lang("{=docx}"));
2028 assert!(is_pandoc_raw_block_lang("{=rst}"));
2029 assert!(is_pandoc_raw_block_lang("{=open-document}"));
2031 assert!(is_pandoc_raw_block_lang("{=my_format}"));
2032 assert!(is_pandoc_raw_block_lang("{=HTML}"));
2035 assert!(!is_pandoc_raw_block_lang("{r}"));
2037 assert!(!is_pandoc_raw_block_lang("{python}"));
2038 assert!(!is_pandoc_raw_block_lang("{=}"));
2040 assert!(!is_pandoc_raw_block_lang("{= }"));
2041 assert!(!is_pandoc_raw_block_lang("=html"));
2042 assert!(!is_pandoc_raw_block_lang("{=html }"));
2044 assert!(!is_pandoc_raw_block_lang("{=ht ml}"));
2045 }
2046
2047 #[test]
2048 fn test_is_pandoc_code_class_attr() {
2049 assert!(is_pandoc_code_class_attr("{.python}"));
2051 assert!(is_pandoc_code_class_attr("{.haskell}"));
2052 assert!(is_pandoc_code_class_attr("{.rust}"));
2053 assert!(is_pandoc_code_class_attr("{.haskell .numberLines}"));
2055 assert!(is_pandoc_code_class_attr("{#myid .python}"));
2057 assert!(is_pandoc_code_class_attr("{.python startFrom=\"10\"}"));
2059 assert!(is_pandoc_code_class_attr("{#snippet .python startFrom=\"10\"}"));
2061 assert!(is_pandoc_code_class_attr("{.objective-c}"));
2063 assert!(is_pandoc_code_class_attr("{.my_lang}"));
2064
2065 assert!(!is_pandoc_code_class_attr("{}"));
2067 assert!(!is_pandoc_code_class_attr("{#myid}"));
2068 assert!(!is_pandoc_code_class_attr("{startFrom=\"10\"}"));
2069 assert!(!is_pandoc_code_class_attr("{=html}"));
2071 assert!(!is_pandoc_code_class_attr("{r}"));
2073 assert!(!is_pandoc_code_class_attr("{python}"));
2074 assert!(!is_pandoc_code_class_attr("{.}"));
2076 assert!(!is_pandoc_code_class_attr(".python"));
2078 assert!(!is_pandoc_code_class_attr("python"));
2079 }
2080}