1use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
12
13use super::parser_options::rumdl_parser_options;
14
15#[derive(Debug, Clone)]
17pub struct CodeBlockDetail {
18 pub start: usize,
20 pub end: usize,
22 pub is_fenced: bool,
24 pub info_string: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct StrongSpanDetail {
31 pub start: usize,
33 pub end: usize,
35 pub is_asterisk: bool,
37}
38
39#[derive(Debug, Clone)]
46pub struct DefinitionTextDetail {
47 pub start: usize,
49 pub end: usize,
51 pub definition_start: usize,
54}
55
56pub type LineToListMap = std::collections::HashMap<usize, usize>;
58pub type ListStartValues = std::collections::HashMap<usize, u64>;
60
61pub struct ParseResult {
63 pub code_blocks: Vec<(usize, usize)>,
65 pub code_spans: Vec<(usize, usize)>,
67 pub code_block_details: Vec<CodeBlockDetail>,
69 pub strong_spans: Vec<StrongSpanDetail>,
71 pub line_to_list: LineToListMap,
73 pub list_start_values: ListStartValues,
75 pub html_blocks: Vec<(usize, usize)>,
82 pub definition_items: Vec<(usize, usize)>,
87 pub definition_terms: Vec<(usize, usize)>,
89 pub definition_texts: Vec<DefinitionTextDetail>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CodeBlockContext {
96 Standalone,
98 Indented,
100 Adjacent,
102}
103
104fn is_inline_tag(tag: &Tag) -> bool {
106 matches!(
107 tag,
108 Tag::Emphasis
109 | Tag::Strong
110 | Tag::Strikethrough
111 | Tag::Superscript
112 | Tag::Subscript
113 | Tag::Link { .. }
114 | Tag::Image { .. }
115 )
116}
117
118fn is_inline_tag_end(tag_end: TagEnd) -> bool {
120 matches!(
121 tag_end,
122 TagEnd::Emphasis
123 | TagEnd::Strong
124 | TagEnd::Strikethrough
125 | TagEnd::Superscript
126 | TagEnd::Subscript
127 | TagEnd::Link
128 | TagEnd::Image
129 )
130}
131
132fn opens_definition(content: &str, start: usize) -> bool {
140 content[start..]
141 .trim_start_matches([' ', '\t'])
142 .strip_prefix(':')
143 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
144}
145
146pub struct CodeBlockUtils;
148
149impl CodeBlockUtils {
150 pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
160 Self::detect_code_blocks_and_spans(content).code_blocks
161 }
162
163 pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
166 let mut blocks = Vec::new();
167 let mut spans = Vec::new();
168 let mut details = Vec::new();
169 let mut strong_spans = Vec::new();
170 let mut html_blocks = Vec::new();
171 let mut code_block_start: Option<(usize, bool, String)> = None;
172
173 let mut definition_items: Vec<(usize, usize)> = Vec::new();
184 let mut pending_terms: Vec<(usize, usize)> = Vec::new();
185 let mut definition_terms = Vec::new();
186 let mut definition_texts = Vec::new();
187 let mut block_stack: Vec<Option<usize>> = Vec::new();
188 let mut definition_paragraph: Option<(usize, usize)> = None;
189 let mut tight_run: Option<DefinitionTextDetail> = None;
190
191 let mut line_to_list = LineToListMap::new();
193 let mut list_start_values = ListStartValues::new();
194 let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); let mut next_list_id: usize = 0;
196
197 let line_starts: Vec<usize> = std::iter::once(0)
199 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
200 .collect();
201
202 let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
203
204 let options = rumdl_parser_options();
205 let parser = Parser::new_ext(content, options).into_offset_iter();
206
207 for (event, range) in parser {
208 match &event {
209 Event::Start(tag) if !is_inline_tag(tag) => {
210 definition_texts.extend(tight_run.take());
211 if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
212 definition_paragraph = Some((range.start, *definition_start));
213 }
214 let mut definition_start = None;
215 match tag {
216 Tag::DefinitionList => pending_terms.clear(),
217 Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
218 Tag::DefinitionListDefinition if opens_definition(content, range.start) => {
219 let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
222 let item_end = range.start
223 + content[range.clone()]
224 .trim_end_matches([' ', '\t', '\n', '\r', '>'])
225 .len();
226 definition_items.push((item_start, item_end));
227 definition_terms.append(&mut pending_terms);
228 definition_start = Some(range.start);
229 }
230 Tag::DefinitionListDefinition => pending_terms.clear(),
231 _ => {}
232 }
233 block_stack.push(definition_start);
234 }
235 Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
236 definition_texts.extend(tight_run.take());
237 if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
238 {
239 definition_texts.push(DefinitionTextDetail {
240 start,
241 end: range.end,
242 definition_start,
243 });
244 }
245 if let TagEnd::DefinitionList = tag_end {
246 pending_terms.clear();
247 }
248 block_stack.pop();
249 }
250 _ => {
253 if let Some(Some(definition_start)) = block_stack.last() {
254 match &mut tight_run {
255 Some(run) => run.end = run.end.max(range.end),
256 None => {
257 tight_run = Some(DefinitionTextDetail {
258 start: range.start,
259 end: range.end,
260 definition_start: *definition_start,
261 });
262 }
263 }
264 }
265 }
266 }
267 match event {
268 Event::Start(Tag::CodeBlock(kind)) => {
269 let (is_fenced, info_string) = match &kind {
270 CodeBlockKind::Fenced(info) => (true, info.to_string()),
271 CodeBlockKind::Indented => (false, String::new()),
272 };
273 code_block_start = Some((range.start, is_fenced, info_string));
274 }
275 Event::End(TagEnd::CodeBlock) => {
276 if let Some((start, is_fenced, info_string)) = code_block_start.take() {
277 blocks.push((start, range.end));
278 details.push(CodeBlockDetail {
279 start,
280 end: range.end,
281 is_fenced,
282 info_string,
283 });
284 }
285 }
286 Event::Start(Tag::Strong) => {
287 if range.start + 2 <= content.len() {
288 let is_asterisk = &content[range.start..range.start + 2] == "**";
289 strong_spans.push(StrongSpanDetail {
290 start: range.start,
291 end: range.end,
292 is_asterisk,
293 });
294 }
295 }
296 Event::Start(Tag::List(start_num)) => {
297 let is_ordered = start_num.is_some();
298 let start_value = start_num.unwrap_or(1);
299 list_stack.push((next_list_id, is_ordered, start_value));
300 if is_ordered {
301 list_start_values.insert(next_list_id, start_value);
302 }
303 next_list_id += 1;
304 }
305 Event::End(TagEnd::List(_)) => {
306 list_stack.pop();
307 }
308 Event::Start(Tag::Item) => {
309 if let Some(&(list_id, is_ordered, _)) = list_stack.last()
310 && is_ordered
311 {
312 let line_num = byte_to_line(range.start);
313 line_to_list.insert(line_num, list_id);
314 }
315 }
316 Event::Start(Tag::HtmlBlock) => {
317 html_blocks.push((range.start, range.end));
319 }
320 Event::Code(_) => {
321 spans.push((range.start, range.end));
322 }
323 _ => {}
324 }
325 }
326
327 if let Some((start, is_fenced, info_string)) = code_block_start {
330 blocks.push((start, content.len()));
331 details.push(CodeBlockDetail {
332 start,
333 end: content.len(),
334 is_fenced,
335 info_string,
336 });
337 }
338
339 blocks.sort_by_key(|&(start, _)| start);
341 spans.sort_by_key(|&(start, _)| start);
342 details.sort_by_key(|d| d.start);
343 strong_spans.sort_by_key(|s| s.start);
344 html_blocks.sort_by_key(|&(start, _)| start);
345 ParseResult {
346 definition_items,
347 definition_terms,
348 definition_texts,
349 code_blocks: blocks,
350 code_spans: spans,
351 code_block_details: details,
352 strong_spans,
353 line_to_list,
354 list_start_values,
355 html_blocks,
356 }
357 }
358
359 pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
361 Self::is_in_code_block(blocks, pos)
362 }
363
364 pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
370 let idx = blocks.partition_point(|&(start, _)| start <= pos);
372 idx > 0 && pos < blocks[idx - 1].1
375 }
376
377 pub fn analyze_code_block_context(
380 lines: &[crate::lint_context::LineInfo],
381 line_idx: usize,
382 min_continuation_indent: usize,
383 ) -> CodeBlockContext {
384 if let Some(line_info) = lines.get(line_idx) {
385 if line_info.indent >= min_continuation_indent {
387 return CodeBlockContext::Indented;
388 }
389
390 let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
392
393 if prev_blanks > 0 || next_blanks > 0 {
396 return CodeBlockContext::Standalone;
397 }
398
399 CodeBlockContext::Adjacent
401 } else {
402 CodeBlockContext::Adjacent
404 }
405 }
406
407 fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
409 let mut prev_blanks = 0;
410 let mut next_blanks = 0;
411
412 for i in (0..line_idx).rev() {
414 if let Some(line) = lines.get(i) {
415 if line.is_blank {
416 prev_blanks += 1;
417 } else {
418 break;
419 }
420 } else {
421 break;
422 }
423 }
424
425 for i in (line_idx + 1)..lines.len() {
427 if let Some(line) = lines.get(i) {
428 if line.is_blank {
429 next_blanks += 1;
430 } else {
431 break;
432 }
433 } else {
434 break;
435 }
436 }
437
438 (prev_blanks, next_blanks)
439 }
440
441 pub fn calculate_min_continuation_indent(
444 content: &str,
445 lines: &[crate::lint_context::LineInfo],
446 current_line_idx: usize,
447 ) -> usize {
448 for i in (0..current_line_idx).rev() {
450 if let Some(line_info) = lines.get(i) {
451 if let Some(list_item) = &line_info.list_item {
452 return if list_item.is_ordered {
454 list_item.marker_column + list_item.marker.len() + 1 } else {
456 list_item.marker_column + 2 };
458 }
459
460 if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
462 break;
463 }
464 }
465 }
466
467 0 }
469
470 fn is_structural_separator(content: &str) -> bool {
472 let trimmed = content.trim();
473 trimmed.starts_with("---")
474 || trimmed.starts_with("***")
475 || trimmed.starts_with("___")
476 || crate::utils::skip_context::is_table_line(trimmed)
477 || trimmed.starts_with('>') }
479
480 pub fn detect_markdown_code_blocks(content: &str) -> Vec<MarkdownCodeBlock> {
488 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
489
490 let mut blocks = Vec::new();
491 let mut current_block: Option<MarkdownCodeBlockBuilder> = None;
492
493 let options = rumdl_parser_options();
494 let parser = Parser::new_ext(content, options).into_offset_iter();
495
496 for (event, range) in parser {
497 match event {
498 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
499 let language = info.split_whitespace().next().unwrap_or("");
501 if language.eq_ignore_ascii_case("markdown") || language.eq_ignore_ascii_case("md") {
502 let block_start = range.start;
504 let content_start = content[block_start..]
505 .find('\n')
506 .map_or(content.len(), |i| block_start + i + 1);
507
508 current_block = Some(MarkdownCodeBlockBuilder { content_start });
509 }
510 }
511 Event::End(TagEnd::CodeBlock) => {
512 if let Some(builder) = current_block.take() {
513 let block_end = range.end;
515
516 if builder.content_start > block_end || builder.content_start > content.len() {
518 continue;
519 }
520
521 let search_range = &content[builder.content_start..block_end.min(content.len())];
522 let content_end = search_range
523 .rfind('\n')
524 .map_or(builder.content_start, |i| builder.content_start + i);
525
526 if content_end >= builder.content_start {
528 blocks.push(MarkdownCodeBlock {
529 content_start: builder.content_start,
530 content_end,
531 });
532 }
533 }
534 }
535 _ => {}
536 }
537 }
538
539 blocks
540 }
541}
542
543#[derive(Debug, Clone)]
545pub struct MarkdownCodeBlock {
546 pub content_start: usize,
548 pub content_end: usize,
550}
551
552struct MarkdownCodeBlockBuilder {
554 content_start: usize,
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_detect_fenced_code_blocks() {
563 let content = "Some text\n```\ncode here\n```\nMore text";
568 let blocks = CodeBlockUtils::detect_code_blocks(content);
569 assert_eq!(blocks.len(), 1);
571
572 let fenced_block = blocks
574 .iter()
575 .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
576 assert!(fenced_block.is_some());
577
578 let content = "Some text\n~~~\ncode here\n~~~\nMore text";
580 let blocks = CodeBlockUtils::detect_code_blocks(content);
581 assert_eq!(blocks.len(), 1);
582 assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
583
584 let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
586 let blocks = CodeBlockUtils::detect_code_blocks(content);
587 assert_eq!(blocks.len(), 2);
589 }
590
591 #[test]
592 fn test_detect_code_blocks_with_language() {
593 let content = "Text\n```rust\nfn main() {}\n```\nMore";
595 let blocks = CodeBlockUtils::detect_code_blocks(content);
596 assert_eq!(blocks.len(), 1);
598 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
600 assert!(fenced.is_some());
601 }
602
603 #[test]
604 fn test_unclosed_code_block() {
605 let content = "Text\n```\ncode here\nno closing fence";
607 let blocks = CodeBlockUtils::detect_code_blocks(content);
608 assert_eq!(blocks.len(), 1);
609 assert_eq!(blocks[0].1, content.len());
610 }
611
612 #[test]
613 fn test_indented_code_blocks() {
614 let content = "Paragraph\n\n code line 1\n code line 2\n\nMore text";
616 let blocks = CodeBlockUtils::detect_code_blocks(content);
617 assert_eq!(blocks.len(), 1);
618 assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
619 assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
620
621 let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
623 let blocks = CodeBlockUtils::detect_code_blocks(content);
624 assert_eq!(blocks.len(), 1);
625 }
626
627 #[test]
628 fn test_indented_code_requires_blank_line() {
629 let content = "Paragraph\n indented but not code\nMore text";
631 let blocks = CodeBlockUtils::detect_code_blocks(content);
632 assert_eq!(blocks.len(), 0);
633
634 let content = "Paragraph\n\n now it's code\nMore text";
636 let blocks = CodeBlockUtils::detect_code_blocks(content);
637 assert_eq!(blocks.len(), 1);
638 }
639
640 #[test]
641 fn test_indented_content_with_list_markers_is_code_block() {
642 let content = "List:\n\n - Item 1\n - Item 2\n * Item 3\n + Item 4";
647 let blocks = CodeBlockUtils::detect_code_blocks(content);
648 assert_eq!(blocks.len(), 1); let content = "List:\n\n 1. First\n 2. Second";
652 let blocks = CodeBlockUtils::detect_code_blocks(content);
653 assert_eq!(blocks.len(), 1); }
655
656 #[test]
657 fn test_actual_list_items_not_code_blocks() {
658 let content = "- Item 1\n- Item 2\n* Item 3";
660 let blocks = CodeBlockUtils::detect_code_blocks(content);
661 assert_eq!(blocks.len(), 0);
662
663 let content = "- Item 1\n - Nested item\n- Item 2";
665 let blocks = CodeBlockUtils::detect_code_blocks(content);
666 assert_eq!(blocks.len(), 0);
667 }
668
669 #[test]
670 fn test_inline_code_spans_not_detected() {
671 let content = "Text with `inline code` here";
673 let blocks = CodeBlockUtils::detect_code_blocks(content);
674 assert_eq!(blocks.len(), 0); let content = "Text with ``code with ` backtick`` here";
678 let blocks = CodeBlockUtils::detect_code_blocks(content);
679 assert_eq!(blocks.len(), 0); let content = "Has `code1` and `code2` spans";
683 let blocks = CodeBlockUtils::detect_code_blocks(content);
684 assert_eq!(blocks.len(), 0); }
686
687 #[test]
688 fn test_unclosed_code_span() {
689 let content = "Text with `unclosed code span";
691 let blocks = CodeBlockUtils::detect_code_blocks(content);
692 assert_eq!(blocks.len(), 0);
693
694 let content = "Text with ``one style` different close";
696 let blocks = CodeBlockUtils::detect_code_blocks(content);
697 assert_eq!(blocks.len(), 0);
698 }
699
700 #[test]
701 fn test_mixed_code_blocks_and_spans() {
702 let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
703 let blocks = CodeBlockUtils::detect_code_blocks(content);
704 assert_eq!(blocks.len(), 1);
706
707 assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
709 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
711 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
712 }
713
714 #[test]
715 fn test_is_in_code_block_or_span() {
716 let blocks = vec![(10, 20), (30, 40), (50, 60)];
717
718 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
720 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
721 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
722
723 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 10)); assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 20)); assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 5));
729 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
730 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
731 }
732
733 #[test]
734 fn test_empty_content() {
735 let blocks = CodeBlockUtils::detect_code_blocks("");
736 assert_eq!(blocks.len(), 0);
737 }
738
739 #[test]
740 fn test_code_block_at_start() {
741 let content = "```\ncode\n```\nText after";
742 let blocks = CodeBlockUtils::detect_code_blocks(content);
743 assert_eq!(blocks.len(), 1);
745 assert_eq!(blocks[0].0, 0); }
747
748 #[test]
749 fn test_code_block_at_end() {
750 let content = "Text before\n```\ncode\n```";
751 let blocks = CodeBlockUtils::detect_code_blocks(content);
752 assert_eq!(blocks.len(), 1);
754 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
756 assert!(fenced.is_some());
757 }
758
759 #[test]
760 fn test_nested_fence_markers() {
761 let content = "Text\n````\n```\nnested\n```\n````\nAfter";
763 let blocks = CodeBlockUtils::detect_code_blocks(content);
764 assert!(!blocks.is_empty());
766 let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
768 assert!(outer.is_some());
769 }
770
771 #[test]
772 fn test_indented_code_with_blank_lines() {
773 let content = "Text\n\n line1\n\n line2\n\nAfter";
775 let blocks = CodeBlockUtils::detect_code_blocks(content);
776 assert!(!blocks.is_empty());
778 let all_content: String = blocks
780 .iter()
781 .map(|(s, e)| &content[*s..*e])
782 .collect::<Vec<_>>()
783 .join("");
784 assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
785 }
786
787 #[test]
788 fn test_code_span_with_spaces() {
789 let content = "Text ` code with spaces ` more";
791 let blocks = CodeBlockUtils::detect_code_blocks(content);
792 assert_eq!(blocks.len(), 0); }
794
795 #[test]
796 fn test_fenced_block_with_info_string() {
797 let content = "```rust,no_run,should_panic\ncode\n```";
799 let blocks = CodeBlockUtils::detect_code_blocks(content);
800 assert_eq!(blocks.len(), 1);
802 assert_eq!(blocks[0].0, 0);
803 }
804
805 #[test]
806 fn test_indented_fences_not_code_blocks() {
807 let content = "Text\n ```\n code\n ```\nAfter";
809 let blocks = CodeBlockUtils::detect_code_blocks(content);
810 assert_eq!(blocks.len(), 1);
812 }
813
814 #[test]
816 fn test_backticks_in_info_string_not_code_block() {
817 let content = "```something```\n\n```bash\n# comment\n```";
823 let blocks = CodeBlockUtils::detect_code_blocks(content);
824 assert_eq!(blocks.len(), 1);
826 assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
828 }
829
830 #[test]
831 fn test_issue_175_reproduction() {
832 let content = "```something```\n\n```bash\n# Have a parrot\necho \"🦜\"\n```";
834 let blocks = CodeBlockUtils::detect_code_blocks(content);
835 assert_eq!(blocks.len(), 1);
837 assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
838 }
839
840 #[test]
841 fn test_tilde_fence_allows_tildes_in_info_string() {
842 let content = "~~~abc~~~\ncode content\n~~~";
845 let blocks = CodeBlockUtils::detect_code_blocks(content);
846 assert_eq!(blocks.len(), 1);
848 }
849
850 #[test]
851 fn test_nested_longer_fence_contains_shorter() {
852 let content = "````\n```\nnested content\n```\n````";
854 let blocks = CodeBlockUtils::detect_code_blocks(content);
855 assert_eq!(blocks.len(), 1);
856 assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
857 }
858
859 #[test]
860 fn test_mixed_fence_types() {
861 let content = "~~~\n```\nmixed content\n~~~";
863 let blocks = CodeBlockUtils::detect_code_blocks(content);
864 assert_eq!(blocks.len(), 1);
865 assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
866 }
867
868 #[test]
869 fn test_indented_code_in_list_issue_276() {
870 let content = r#"1. First item
8722. Second item with code:
873
874 # This is a code block in a list
875 print("Hello, world!")
876
8774. Third item"#;
878
879 let blocks = CodeBlockUtils::detect_code_blocks(content);
880 assert!(!blocks.is_empty(), "Should detect indented code block inside list");
882
883 let all_content: String = blocks
885 .iter()
886 .map(|(s, e)| &content[*s..*e])
887 .collect::<Vec<_>>()
888 .join("");
889 assert!(
890 all_content.contains("code block in a list") || all_content.contains("print"),
891 "Detected block should contain the code content: {all_content:?}"
892 );
893 }
894
895 #[test]
896 fn test_detect_markdown_code_blocks() {
897 let content = r#"# Example
898
899```markdown
900# Heading
901Content here
902```
903
904```md
905Another heading
906More content
907```
908
909```rust
910// Not markdown
911fn main() {}
912```
913"#;
914
915 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
916
917 assert_eq!(
919 blocks.len(),
920 2,
921 "Should detect exactly 2 markdown blocks, got {blocks:?}"
922 );
923
924 let first = &blocks[0];
926 let first_content = &content[first.content_start..first.content_end];
927 assert!(
928 first_content.contains("# Heading"),
929 "First block should contain '# Heading', got: {first_content:?}"
930 );
931
932 let second = &blocks[1];
934 let second_content = &content[second.content_start..second.content_end];
935 assert!(
936 second_content.contains("Another heading"),
937 "Second block should contain 'Another heading', got: {second_content:?}"
938 );
939 }
940
941 #[test]
942 fn test_detect_markdown_code_blocks_empty() {
943 let content = "# Just a heading\n\nNo code blocks here\n";
944 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
945 assert_eq!(blocks.len(), 0);
946 }
947
948 #[test]
949 fn test_detect_markdown_code_blocks_case_insensitive() {
950 let content = "```MARKDOWN\nContent\n```\n";
951 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
952 assert_eq!(blocks.len(), 1);
953 }
954
955 #[test]
956 fn test_detect_markdown_code_blocks_at_eof_no_trailing_newline() {
957 let content = "# Doc\n\n```markdown\nContent\n```";
959 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
960 assert_eq!(blocks.len(), 1);
961 let block_content = &content[blocks[0].content_start..blocks[0].content_end];
963 assert!(block_content.contains("Content"));
964 }
965
966 #[test]
967 fn test_detect_markdown_code_blocks_single_line_content() {
968 let content = "```markdown\nX\n```\n";
970 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
971 assert_eq!(blocks.len(), 1);
972 let block_content = &content[blocks[0].content_start..blocks[0].content_end];
973 assert_eq!(block_content, "X");
974 }
975
976 #[test]
977 fn test_detect_markdown_code_blocks_empty_content() {
978 let content = "```markdown\n```\n";
980 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
981 if !blocks.is_empty() {
984 assert!(blocks[0].content_start <= blocks[0].content_end);
986 }
987 }
988
989 #[test]
990 fn test_detect_markdown_code_blocks_validates_ranges() {
991 let test_cases = [
993 "", "```markdown", "```markdown\n", "```\n```", "```markdown\n```", " ```markdown\n X\n ```", ];
1000
1001 for content in test_cases {
1002 let blocks = CodeBlockUtils::detect_markdown_code_blocks(content);
1004 for block in &blocks {
1006 assert!(
1007 block.content_start <= block.content_end,
1008 "Invalid range in content: {content:?}"
1009 );
1010 assert!(
1011 block.content_end <= content.len(),
1012 "Range exceeds content length in: {content:?}"
1013 );
1014 }
1015 }
1016 }
1017
1018 #[test]
1021 fn test_is_in_code_block_empty_blocks() {
1022 assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1023 assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1024 assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1025 }
1026
1027 #[test]
1028 fn test_is_in_code_block_single_range() {
1029 let blocks = [(10, 20)];
1030 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1031 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1032 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1033 assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1034 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1035 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1037 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1038 }
1039
1040 #[test]
1041 fn test_is_in_code_block_multiple_ranges() {
1042 let blocks = [(5, 10), (20, 30), (50, 60)];
1043 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1045 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1046 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1048 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1049 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1051 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1052 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1053 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1055 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1056 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1058 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1059 assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1061 assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1062 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1064 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1065 }
1066
1067 #[test]
1068 fn test_is_in_code_block_adjacent_ranges() {
1069 let blocks = [(0, 10), (10, 20), (20, 30)];
1071 assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1072 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1073 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1074 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1075 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1076 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1077 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1078 }
1079
1080 #[test]
1081 fn test_is_in_code_block_single_byte_range() {
1082 let blocks = [(5, 6)];
1083 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1084 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1085 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1086 }
1087
1088 #[test]
1089 fn test_is_in_code_block_matches_linear_scan() {
1090 let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1093 let blocks = CodeBlockUtils::detect_code_blocks(content);
1094
1095 for pos in 0..content.len() {
1096 let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1097 let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1098 assert_eq!(
1099 binary, linear,
1100 "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1101 );
1102 }
1103 }
1104
1105 #[test]
1106 fn test_is_in_code_block_at_range_boundaries() {
1107 let blocks = [(100, 200), (300, 400), (500, 600)];
1109 for &(start, end) in &blocks {
1110 assert!(
1111 !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1112 "pos={} should be outside",
1113 start - 1
1114 );
1115 assert!(
1116 CodeBlockUtils::is_in_code_block(&blocks, start),
1117 "pos={start} should be inside"
1118 );
1119 assert!(
1120 CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1121 "pos={} should be inside",
1122 end - 1
1123 );
1124 assert!(
1125 !CodeBlockUtils::is_in_code_block(&blocks, end),
1126 "pos={end} should be outside"
1127 );
1128 }
1129 }
1130}