1use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
12
13use super::parser_options::rumdl_parser_options_for;
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 pub content_column: Option<usize>,
31 pub closed: bool,
36}
37
38fn fenced_block_is_closed(content: &str, start: usize, end: usize, literal_lines: usize) -> bool {
48 let bytes = content.as_bytes();
49 let end = end.min(bytes.len());
50 let body_end = if end > start && bytes[end - 1] == b'\n' {
52 end - 1
53 } else {
54 end
55 };
56 let body = &bytes[start.min(body_end)..body_end];
57 let span_lines = body.split(|&b| b == b'\n').count();
59 let line_start = body
60 .iter()
61 .rposition(|&b| b == b'\n')
62 .map_or(start, |at| start + at + 1);
63 let last_line = content.get(line_start..body_end).unwrap_or("");
64 span_lines == literal_lines + 2 && last_line.trim_start_matches(['>', ' ', '\t']).starts_with(['`', '~'])
65}
66
67fn visual_column(content: &str, pos: usize) -> usize {
70 let line_start = content[..pos].rfind('\n').map_or(0, |i| i + 1);
71 content[line_start..pos]
72 .chars()
73 .fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 })
74}
75
76#[derive(Debug, Clone)]
78pub struct StrongSpanDetail {
79 pub start: usize,
81 pub end: usize,
83 pub is_asterisk: bool,
85}
86
87#[derive(Debug, Clone)]
94pub struct DefinitionTextDetail {
95 pub start: usize,
97 pub end: usize,
99 pub definition_start: usize,
102}
103
104pub type LineToListMap = std::collections::HashMap<usize, usize>;
106pub type ListStartValues = std::collections::HashMap<usize, u64>;
108
109#[derive(Debug, Default)]
111pub struct ParseResult {
112 pub code_blocks: Vec<(usize, usize)>,
114 pub code_spans: Vec<(usize, usize)>,
116 pub code_block_details: Vec<CodeBlockDetail>,
118 pub strong_spans: Vec<StrongSpanDetail>,
120 pub line_to_list: LineToListMap,
122 pub list_start_values: ListStartValues,
124 pub html_blocks: Vec<(usize, usize)>,
131 pub definition_items: Vec<(usize, usize)>,
136 pub definition_terms: Vec<(usize, usize)>,
138 pub definition_texts: Vec<DefinitionTextDetail>,
140 pub colon_definitions: Vec<ColonDefinitionDetail>,
146 pub table_ranges: Vec<(usize, usize)>,
148 pub top_level_blocks: Vec<(usize, usize)>,
151 pub rejected_definition_ranges: Vec<(usize, usize)>,
155 pub list_items: Vec<(usize, usize, usize)>,
158}
159
160#[derive(Debug, Clone)]
163pub struct ColonDefinitionDetail {
164 pub start: usize,
166 pub end: usize,
168 pub definition_start: usize,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum CodeBlockContext {
175 Standalone,
177 Indented,
179 Adjacent,
181}
182
183fn is_inline_tag(tag: &Tag) -> bool {
185 matches!(
186 tag,
187 Tag::Emphasis
188 | Tag::Strong
189 | Tag::Strikethrough
190 | Tag::Superscript
191 | Tag::Subscript
192 | Tag::Link { .. }
193 | Tag::Image { .. }
194 )
195}
196
197fn is_inline_tag_end(tag_end: TagEnd) -> bool {
199 matches!(
200 tag_end,
201 TagEnd::Emphasis
202 | TagEnd::Strong
203 | TagEnd::Strikethrough
204 | TagEnd::Superscript
205 | TagEnd::Subscript
206 | TagEnd::Link
207 | TagEnd::Image
208 )
209}
210
211fn opens_definition(content: &str, start: usize) -> bool {
219 let Some(rest) = content.get(start..) else {
223 return false;
224 };
225 rest.trim_start_matches([' ', '\t'])
226 .strip_prefix(':')
227 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
228}
229
230fn indented_block_columns(content: &str, options: Options) -> Vec<(usize, usize, usize)> {
236 let mut blocks = Vec::new();
237 let mut open: Option<(usize, usize)> = None;
238 let mut awaiting_first_event = false;
239 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
240 if std::mem::take(&mut awaiting_first_event)
241 && let Event::Text(text) = &event
242 && range.is_empty()
243 && let Some((_, column)) = &mut open
244 {
245 *column = column.saturating_sub(text.len());
246 }
247 match event {
248 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
249 open = Some((range.start, visual_column(content, range.start).saturating_sub(4)));
250 awaiting_first_event = true;
251 }
252 Event::End(TagEnd::CodeBlock) => {
253 if let Some((start, column)) = open.take() {
254 blocks.push((start, range.end, column));
255 }
256 }
257 _ => {}
258 }
259 }
260 blocks
261}
262
263fn inline_ranges(content: &str, options: Options) -> Vec<(usize, usize)> {
267 Parser::new_ext(content, options)
268 .into_offset_iter()
269 .filter_map(|(event, range)| match event {
270 Event::Start(tag) if is_inline_tag(&tag) => Some((range.start, range.end)),
271 Event::Code(_) | Event::InlineHtml(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {
272 Some((range.start, range.end))
273 }
274 _ => None,
275 })
276 .collect()
277}
278
279struct InlineRangeCursor<'a> {
284 ranges: &'a [(usize, usize)],
285 next: usize,
286 active: Vec<(usize, usize)>,
287}
288
289impl<'a> InlineRangeCursor<'a> {
290 fn new(ranges: &'a [(usize, usize)]) -> Self {
291 Self {
292 ranges,
293 next: 0,
294 active: Vec::new(),
295 }
296 }
297
298 fn overlapping(&mut self, detail: &ColonDefinitionDetail) -> impl Iterator<Item = (usize, usize)> + '_ {
299 while self.next < self.ranges.len() && self.ranges[self.next].0 <= detail.start {
300 self.active.push(self.ranges[self.next]);
301 self.next += 1;
302 }
303 self.active.retain(|&(_, end)| end > detail.start);
304 let end = detail.end;
305 self.active.iter().copied().chain(
306 self.ranges[self.next..]
307 .iter()
308 .take_while(move |&&(start, _)| start < end)
309 .copied(),
310 )
311 }
312}
313
314pub struct CodeBlockUtils;
316
317impl CodeBlockUtils {
318 pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
328 Self::detect_code_blocks_and_spans(content).code_blocks
329 }
330
331 pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
334 let line_starts: Vec<usize> = std::iter::once(0)
335 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
336 .collect();
337 let options = rumdl_parser_options_for(content);
338 Self::collect_code_blocks_and_spans(
339 content,
340 &line_starts,
341 options,
342 Parser::new_ext(content, options).into_offset_iter(),
343 )
344 }
345
346 pub(crate) fn collect_code_blocks_and_spans<'a>(
350 content: &str,
351 line_starts: &[usize],
352 options: Options,
353 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
354 ) -> ParseResult {
355 let mut blocks = Vec::new();
356 let mut spans = Vec::new();
357 let mut details = Vec::new();
358 let mut strong_spans = Vec::new();
359 let mut html_blocks = Vec::new();
360 let mut code_block_start: Option<(usize, bool, String, Option<usize>)> = None;
361 let mut literal_newlines = 0usize;
364 let mut literal_partial_line = false;
365 let mut awaiting_first_code_event = false;
370 let mut definition_rejected: Vec<bool> = Vec::new();
375 let mut text_reading = Vec::new();
376
377 let mut definition_items: Vec<(usize, usize)> = Vec::new();
388 let mut pending_terms: Vec<(usize, usize)> = Vec::new();
389 let mut definition_terms = Vec::new();
390 let mut definition_texts = Vec::new();
391 let mut colon_definitions = Vec::new();
392 let mut table_ranges: Vec<(usize, usize)> = Vec::new();
393 let mut top_level_blocks: Vec<(usize, usize)> = Vec::new();
394 let mut rejected_definition_ranges: Vec<(usize, usize)> = Vec::new();
395 let mut list_item_spans: Vec<(usize, usize, Option<usize>)> = Vec::new();
396 let mut open_items: Vec<usize> = Vec::new();
397 let mut block_stack: Vec<Option<usize>> = Vec::new();
398 let mut definition_paragraph: Option<(usize, usize)> = None;
399 let mut tight_run: Option<DefinitionTextDetail> = None;
400
401 let mut line_to_list = LineToListMap::new();
403 let mut list_start_values = ListStartValues::new();
404 let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); let mut next_list_id: usize = 0;
406
407 let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
408
409 for (event, range) in events {
410 if let Some(&item) = open_items.last()
411 && list_item_spans[item].2.is_none()
412 && !matches!(event, Event::End(_))
413 {
414 list_item_spans[item].2 = Some(range.start);
415 }
416 if matches!(event, Event::Start(Tag::Item)) {
417 open_items.push(list_item_spans.len());
418 list_item_spans.push((range.start, range.end, None));
419 } else if matches!(event, Event::End(TagEnd::Item)) {
420 open_items.pop();
421 }
422 match &event {
423 Event::Start(tag) if !is_inline_tag(tag) => {
424 definition_texts.extend(tight_run.take());
425 if block_stack.is_empty() {
426 top_level_blocks.push((range.start, range.end));
427 }
428 if matches!(tag, Tag::Table(_)) {
429 table_ranges.push((range.start, range.end));
430 }
431 if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
432 definition_paragraph = Some((range.start, *definition_start));
433 }
434 let mut definition_start = None;
435 match tag {
436 Tag::DefinitionList => pending_terms.clear(),
437 Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
438 Tag::DefinitionListDefinition => {
439 let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
442 let item_end = content.as_bytes()[range.clone()]
446 .iter()
447 .rposition(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'>'))
448 .map_or(range.start, |index| range.start + index + 1);
449 let accepted = opens_definition(content, range.start);
450 definition_rejected.push(!accepted);
451 if accepted {
452 definition_items.push((item_start, item_end));
453 definition_terms.append(&mut pending_terms);
454 definition_start = Some(range.start);
455 } else {
456 rejected_definition_ranges.push((item_start, item_end));
457 colon_definitions.push(ColonDefinitionDetail {
458 start: item_start,
459 end: item_end,
460 definition_start: range.start,
461 });
462 pending_terms.clear();
463 }
464 }
465 _ => {}
466 }
467 block_stack.push(definition_start);
468 }
469 Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
470 definition_texts.extend(tight_run.take());
471 if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
472 {
473 definition_texts.push(DefinitionTextDetail {
474 start,
475 end: range.end,
476 definition_start,
477 });
478 }
479 if let TagEnd::DefinitionListDefinition = tag_end {
480 definition_rejected.pop();
481 }
482 if let TagEnd::DefinitionList = tag_end {
483 pending_terms.clear();
484 }
485 block_stack.pop();
486 }
487 _ => {
490 if let Some(Some(definition_start)) = block_stack.last() {
491 match &mut tight_run {
492 Some(run) => run.end = run.end.max(range.end),
493 None => {
494 tight_run = Some(DefinitionTextDetail {
495 start: range.start,
496 end: range.end,
497 definition_start: *definition_start,
498 });
499 }
500 }
501 }
502 }
503 }
504 if std::mem::take(&mut awaiting_first_code_event)
505 && let Event::Text(text) = &event
506 && range.is_empty()
507 && let Some((_, _, _, Some(column))) = &mut code_block_start
508 {
509 *column = column.saturating_sub(text.len());
510 }
511 if code_block_start.is_some()
512 && let Event::Text(text) = &event
513 && !text.is_empty()
514 {
515 literal_newlines += text.matches('\n').count();
516 literal_partial_line = !text.ends_with('\n');
517 }
518 match event {
519 Event::Start(Tag::CodeBlock(kind)) => {
520 literal_newlines = 0;
521 literal_partial_line = false;
522 let (is_fenced, info_string) = match &kind {
523 CodeBlockKind::Fenced(info) => (true, info.to_string()),
524 CodeBlockKind::Indented => (false, String::new()),
525 };
526 let mut content_column =
527 (!is_fenced).then(|| visual_column(content, range.start).saturating_sub(4));
528 if !is_fenced && definition_rejected.contains(&true) {
529 if !block_stack.iter().any(Option::is_some) {
532 text_reading.push(range.start);
533 }
534 content_column = None;
535 }
536 awaiting_first_code_event = !is_fenced;
537 code_block_start = Some((range.start, is_fenced, info_string, content_column));
538 }
539 Event::End(TagEnd::CodeBlock) => {
540 if let Some((start, is_fenced, info_string, content_column)) = code_block_start.take() {
541 blocks.push((start, range.end));
542 let literal_lines = literal_newlines + usize::from(literal_partial_line);
543 details.push(CodeBlockDetail {
544 start,
545 end: range.end,
546 is_fenced,
547 info_string,
548 content_column,
549 closed: is_fenced && fenced_block_is_closed(content, start, range.end, literal_lines),
550 });
551 }
552 }
553 Event::Start(Tag::Strong) => {
554 if range.start + 2 <= content.len() {
555 let is_asterisk = &content[range.start..range.start + 2] == "**";
556 strong_spans.push(StrongSpanDetail {
557 start: range.start,
558 end: range.end,
559 is_asterisk,
560 });
561 }
562 }
563 Event::Start(Tag::List(start_num)) => {
564 let is_ordered = start_num.is_some();
565 let start_value = start_num.unwrap_or(1);
566 list_stack.push((next_list_id, is_ordered, start_value));
567 if is_ordered {
568 list_start_values.insert(next_list_id, start_value);
569 }
570 next_list_id += 1;
571 }
572 Event::End(TagEnd::List(_)) => {
573 list_stack.pop();
574 }
575 Event::Start(Tag::Item) => {
576 if let Some(&(list_id, is_ordered, _)) = list_stack.last()
577 && is_ordered
578 {
579 let line_num = byte_to_line(range.start);
580 line_to_list.insert(line_num, list_id);
581 }
582 }
583 Event::Start(Tag::HtmlBlock) => {
584 html_blocks.push((range.start, range.end));
586 }
587 Event::Code(_) => {
588 spans.push((range.start, range.end));
589 }
590 _ => {}
591 }
592 }
593
594 if let Some((start, is_fenced, info_string, content_column)) = code_block_start {
597 blocks.push((start, content.len()));
598 details.push(CodeBlockDetail {
599 start,
600 end: content.len(),
601 is_fenced,
602 info_string,
603 content_column,
604 closed: false,
605 });
606 }
607
608 if !text_reading.is_empty() {
609 let text_blocks = indented_block_columns(content, options - Options::ENABLE_DEFINITION_LIST);
610 for detail in details.iter_mut().filter(|d| text_reading.contains(&d.start)) {
611 let lines = (byte_to_line(detail.start), byte_to_line(detail.end.saturating_sub(1)));
612 detail.content_column = text_blocks
613 .iter()
614 .find(|&&(start, end, _)| (byte_to_line(start), byte_to_line(end.saturating_sub(1))) == lines)
615 .map(|&(_, _, column)| column);
616 }
617 }
618
619 if !colon_definitions.is_empty() {
622 let as_definitions = inline_ranges(content, options);
623 let as_text = inline_ranges(content, options - Options::ENABLE_DEFINITION_LIST);
624 let mut definition_ranges = InlineRangeCursor::new(&as_definitions);
625 let mut text_ranges = InlineRangeCursor::new(&as_text);
626 colon_definitions.retain(|detail| {
627 !definition_ranges
628 .overlapping(detail)
629 .eq(text_ranges.overlapping(detail))
630 });
631 }
632
633 blocks.sort_by_key(|&(start, _)| start);
635 spans.sort_by_key(|&(start, _)| start);
636 details.sort_by_key(|d| d.start);
637 strong_spans.sort_by_key(|s| s.start);
638 html_blocks.sort_by_key(|&(start, _)| start);
639 ParseResult {
640 definition_items,
641 definition_terms,
642 definition_texts,
643 colon_definitions,
644 code_blocks: blocks,
645 code_spans: spans,
646 code_block_details: details,
647 strong_spans,
648 line_to_list,
649 list_start_values,
650 html_blocks,
651 table_ranges,
652 top_level_blocks,
653 rejected_definition_ranges,
654 list_items: list_item_spans
655 .into_iter()
656 .filter_map(|(start, end, content)| content.map(|content| (start, end, content)))
657 .collect(),
658 }
659 }
660
661 pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
663 Self::is_in_code_block(blocks, pos)
664 }
665
666 pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
672 let idx = blocks.partition_point(|&(start, _)| start <= pos);
674 idx > 0 && pos < blocks[idx - 1].1
677 }
678
679 pub fn analyze_code_block_context(
682 lines: &[crate::lint_context::LineInfo],
683 line_idx: usize,
684 min_continuation_indent: usize,
685 ) -> CodeBlockContext {
686 if let Some(line_info) = lines.get(line_idx) {
687 if line_info.indent >= min_continuation_indent {
689 return CodeBlockContext::Indented;
690 }
691
692 let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
694
695 if prev_blanks > 0 || next_blanks > 0 {
698 return CodeBlockContext::Standalone;
699 }
700
701 CodeBlockContext::Adjacent
703 } else {
704 CodeBlockContext::Adjacent
706 }
707 }
708
709 fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
711 let mut prev_blanks = 0;
712 let mut next_blanks = 0;
713
714 for i in (0..line_idx).rev() {
716 if let Some(line) = lines.get(i) {
717 if line.is_blank {
718 prev_blanks += 1;
719 } else {
720 break;
721 }
722 } else {
723 break;
724 }
725 }
726
727 for i in (line_idx + 1)..lines.len() {
729 if let Some(line) = lines.get(i) {
730 if line.is_blank {
731 next_blanks += 1;
732 } else {
733 break;
734 }
735 } else {
736 break;
737 }
738 }
739
740 (prev_blanks, next_blanks)
741 }
742
743 pub fn calculate_min_continuation_indent(
746 content: &str,
747 lines: &[crate::lint_context::LineInfo],
748 current_line_idx: usize,
749 ) -> usize {
750 for i in (0..current_line_idx).rev() {
752 if let Some(line_info) = lines.get(i) {
753 if let Some(list_item) = &line_info.list_item {
754 return if list_item.is_ordered {
756 list_item.marker_column + list_item.marker.len() + 1 } else {
758 list_item.marker_column + 2 };
760 }
761
762 if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
764 break;
765 }
766 }
767 }
768
769 0 }
771
772 fn is_structural_separator(content: &str) -> bool {
774 let trimmed = content.trim();
775 trimmed.starts_with("---")
776 || trimmed.starts_with("***")
777 || trimmed.starts_with("___")
778 || crate::utils::skip_context::is_table_line(trimmed)
779 || trimmed.starts_with('>') }
781}
782
783#[cfg(test)]
784mod tests {
785 #[test]
786 fn test_table_ranges_and_top_level_blocks_are_recorded() {
787 let content = "para\n\n|a|b|\n|-|-|\n|1|2|\n\n- item\n\n |c|d|\n |-|-|\n";
788 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
789 let text = |&(start, end): &(usize, usize)| &content[start..end];
790
791 assert_eq!(parsed.table_ranges.len(), 2);
792 assert_eq!(text(&parsed.table_ranges[0]), "|a|b|\n|-|-|\n|1|2|\n");
793 assert!(text(&parsed.table_ranges[1]).starts_with("|c|d|"));
794
795 let top: Vec<&str> = parsed.top_level_blocks.iter().map(text).collect();
798 assert_eq!(top.len(), 3, "{top:?}");
799 assert_eq!(top[0], "para\n");
800 assert!(top[2].starts_with("- item"));
801 }
802
803 #[test]
804 fn test_list_items_record_where_their_content_starts() {
805 let content = "- - x\n\n- y\n-\n- \n z\n";
806 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
807 let starts: Vec<(&str, &str)> = parsed
808 .list_items
809 .iter()
810 .map(|&(start, _, content_start)| (&content[start..=start], &content[content_start..=content_start]))
811 .collect();
812 assert_eq!(starts, [("-", "-"), ("-", "x"), ("-", "y"), ("-", "z")]);
814 }
815
816 #[test]
817 fn test_no_table_ranges_without_a_table() {
818 let parsed = CodeBlockUtils::detect_code_blocks_and_spans("a|b\n|-|\n\n |a|b|\n |-|-|\n");
819 assert!(parsed.table_ranges.is_empty());
820 }
821
822 use super::*;
823
824 #[test]
825 fn test_detect_fenced_code_blocks() {
826 let content = "Some text\n```\ncode here\n```\nMore text";
831 let blocks = CodeBlockUtils::detect_code_blocks(content);
832 assert_eq!(blocks.len(), 1);
834
835 let fenced_block = blocks
837 .iter()
838 .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
839 assert!(fenced_block.is_some());
840
841 let content = "Some text\n~~~\ncode here\n~~~\nMore text";
843 let blocks = CodeBlockUtils::detect_code_blocks(content);
844 assert_eq!(blocks.len(), 1);
845 assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
846
847 let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
849 let blocks = CodeBlockUtils::detect_code_blocks(content);
850 assert_eq!(blocks.len(), 2);
852 }
853
854 #[test]
855 fn test_detect_code_blocks_with_language() {
856 let content = "Text\n```rust\nfn main() {}\n```\nMore";
858 let blocks = CodeBlockUtils::detect_code_blocks(content);
859 assert_eq!(blocks.len(), 1);
861 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
863 assert!(fenced.is_some());
864 }
865
866 #[test]
867 fn test_unclosed_code_block() {
868 let content = "Text\n```\ncode here\nno closing fence";
870 let blocks = CodeBlockUtils::detect_code_blocks(content);
871 assert_eq!(blocks.len(), 1);
872 assert_eq!(blocks[0].1, content.len());
873 }
874
875 #[test]
876 fn test_indented_code_blocks() {
877 let content = "Paragraph\n\n code line 1\n code line 2\n\nMore text";
879 let blocks = CodeBlockUtils::detect_code_blocks(content);
880 assert_eq!(blocks.len(), 1);
881 assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
882 assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
883
884 let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
886 let blocks = CodeBlockUtils::detect_code_blocks(content);
887 assert_eq!(blocks.len(), 1);
888 }
889
890 #[test]
891 fn test_indented_code_requires_blank_line() {
892 let content = "Paragraph\n indented but not code\nMore text";
894 let blocks = CodeBlockUtils::detect_code_blocks(content);
895 assert_eq!(blocks.len(), 0);
896
897 let content = "Paragraph\n\n now it's code\nMore text";
899 let blocks = CodeBlockUtils::detect_code_blocks(content);
900 assert_eq!(blocks.len(), 1);
901 }
902
903 #[test]
904 fn test_indented_content_with_list_markers_is_code_block() {
905 let content = "List:\n\n - Item 1\n - Item 2\n * Item 3\n + Item 4";
910 let blocks = CodeBlockUtils::detect_code_blocks(content);
911 assert_eq!(blocks.len(), 1); let content = "List:\n\n 1. First\n 2. Second";
915 let blocks = CodeBlockUtils::detect_code_blocks(content);
916 assert_eq!(blocks.len(), 1); }
918
919 #[test]
920 fn test_actual_list_items_not_code_blocks() {
921 let content = "- Item 1\n- Item 2\n* Item 3";
923 let blocks = CodeBlockUtils::detect_code_blocks(content);
924 assert_eq!(blocks.len(), 0);
925
926 let content = "- Item 1\n - Nested item\n- Item 2";
928 let blocks = CodeBlockUtils::detect_code_blocks(content);
929 assert_eq!(blocks.len(), 0);
930 }
931
932 #[test]
933 fn test_inline_code_spans_not_detected() {
934 let content = "Text with `inline code` here";
936 let blocks = CodeBlockUtils::detect_code_blocks(content);
937 assert_eq!(blocks.len(), 0); let content = "Text with ``code with ` backtick`` here";
941 let blocks = CodeBlockUtils::detect_code_blocks(content);
942 assert_eq!(blocks.len(), 0); let content = "Has `code1` and `code2` spans";
946 let blocks = CodeBlockUtils::detect_code_blocks(content);
947 assert_eq!(blocks.len(), 0); }
949
950 #[test]
951 fn test_unclosed_code_span() {
952 let content = "Text with `unclosed code span";
954 let blocks = CodeBlockUtils::detect_code_blocks(content);
955 assert_eq!(blocks.len(), 0);
956
957 let content = "Text with ``one style` different close";
959 let blocks = CodeBlockUtils::detect_code_blocks(content);
960 assert_eq!(blocks.len(), 0);
961 }
962
963 #[test]
964 fn test_mixed_code_blocks_and_spans() {
965 let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
966 let blocks = CodeBlockUtils::detect_code_blocks(content);
967 assert_eq!(blocks.len(), 1);
969
970 assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
972 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
974 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
975 }
976
977 #[test]
978 fn test_is_in_code_block_or_span() {
979 let blocks = vec![(10, 20), (30, 40), (50, 60)];
980
981 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
983 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
984 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
985
986 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));
992 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
993 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
994 }
995
996 #[test]
997 fn test_empty_content() {
998 let blocks = CodeBlockUtils::detect_code_blocks("");
999 assert_eq!(blocks.len(), 0);
1000 }
1001
1002 #[test]
1003 fn test_code_block_at_start() {
1004 let content = "```\ncode\n```\nText after";
1005 let blocks = CodeBlockUtils::detect_code_blocks(content);
1006 assert_eq!(blocks.len(), 1);
1008 assert_eq!(blocks[0].0, 0); }
1010
1011 #[test]
1012 fn test_code_block_at_end() {
1013 let content = "Text before\n```\ncode\n```";
1014 let blocks = CodeBlockUtils::detect_code_blocks(content);
1015 assert_eq!(blocks.len(), 1);
1017 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
1019 assert!(fenced.is_some());
1020 }
1021
1022 #[test]
1023 fn test_nested_fence_markers() {
1024 let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1026 let blocks = CodeBlockUtils::detect_code_blocks(content);
1027 assert!(!blocks.is_empty());
1029 let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
1031 assert!(outer.is_some());
1032 }
1033
1034 #[test]
1035 fn test_indented_code_with_blank_lines() {
1036 let content = "Text\n\n line1\n\n line2\n\nAfter";
1038 let blocks = CodeBlockUtils::detect_code_blocks(content);
1039 assert!(!blocks.is_empty());
1041 let all_content: String = blocks
1043 .iter()
1044 .map(|(s, e)| &content[*s..*e])
1045 .collect::<Vec<_>>()
1046 .join("");
1047 assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
1048 }
1049
1050 #[test]
1051 fn test_code_span_with_spaces() {
1052 let content = "Text ` code with spaces ` more";
1054 let blocks = CodeBlockUtils::detect_code_blocks(content);
1055 assert_eq!(blocks.len(), 0); }
1057
1058 #[test]
1059 fn test_fenced_block_with_info_string() {
1060 let content = "```rust,no_run,should_panic\ncode\n```";
1062 let blocks = CodeBlockUtils::detect_code_blocks(content);
1063 assert_eq!(blocks.len(), 1);
1065 assert_eq!(blocks[0].0, 0);
1066 }
1067
1068 #[test]
1069 fn test_indented_fences_not_code_blocks() {
1070 let content = "Text\n ```\n code\n ```\nAfter";
1072 let blocks = CodeBlockUtils::detect_code_blocks(content);
1073 assert_eq!(blocks.len(), 1);
1075 }
1076
1077 #[test]
1079 fn test_backticks_in_info_string_not_code_block() {
1080 let content = "```something```\n\n```bash\n# comment\n```";
1086 let blocks = CodeBlockUtils::detect_code_blocks(content);
1087 assert_eq!(blocks.len(), 1);
1089 assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1091 }
1092
1093 #[test]
1094 fn test_issue_175_reproduction() {
1095 let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐ฆ\"\n```";
1097 let blocks = CodeBlockUtils::detect_code_blocks(content);
1098 assert_eq!(blocks.len(), 1);
1100 assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
1101 }
1102
1103 #[test]
1104 fn test_tilde_fence_allows_tildes_in_info_string() {
1105 let content = "~~~abc~~~\ncode content\n~~~";
1108 let blocks = CodeBlockUtils::detect_code_blocks(content);
1109 assert_eq!(blocks.len(), 1);
1111 }
1112
1113 #[test]
1114 fn test_nested_longer_fence_contains_shorter() {
1115 let content = "````\n```\nnested content\n```\n````";
1117 let blocks = CodeBlockUtils::detect_code_blocks(content);
1118 assert_eq!(blocks.len(), 1);
1119 assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
1120 }
1121
1122 #[test]
1123 fn test_mixed_fence_types() {
1124 let content = "~~~\n```\nmixed content\n~~~";
1126 let blocks = CodeBlockUtils::detect_code_blocks(content);
1127 assert_eq!(blocks.len(), 1);
1128 assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
1129 }
1130
1131 #[test]
1132 fn test_indented_block_content_column_is_its_containers() {
1133 let cases = [
1134 ("Text\n\n code\n", 0),
1135 ("- a\n\n code\n", 2),
1136 ("1. a\n\n code\n", 3),
1137 ("1. a\n\nb\n\n code\n", 0),
1139 ("1. a\nb\nc\nd\ne\nf\ng\n\n code\n", 3),
1141 ("> quoted\n", 2),
1142 ("- a\n\n\t code\n", 2),
1143 ("1. a\n\n\t\tcode\n", 3),
1145 (">\t\tcode\n", 2),
1146 ];
1147 for (content, expected) in cases {
1148 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1149 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1150 assert_eq!(columns, [Some(expected)], "content column for {content:?}");
1151 }
1152
1153 let fenced = CodeBlockUtils::detect_code_blocks_and_spans("- a\n\n ```\n code\n ```\n");
1154 let columns: Vec<_> = fenced.code_block_details.iter().map(|d| d.content_column).collect();
1155 assert_eq!(columns, [None]);
1156 }
1157
1158 #[test]
1159 fn test_indented_block_in_rejected_definition_takes_its_columns_from_the_text_reading() {
1160 let cases = [
1161 ("term\n:warning:\n\n x\n", Some(0)),
1163 ("- a\n\n term\n :warning:\n\n x\n", Some(2)),
1164 ("term\n: def\n\n x\n", Some(2)),
1166 ("term\n: def\n\n t2\n :warning:\n\n x\n", None),
1169 ];
1170 for (content, expected) in cases {
1171 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1172 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1173 assert_eq!(columns, [expected], "content column for {content:?}");
1174 }
1175 }
1176
1177 #[test]
1178 fn definition_marker_inside_utf8_keeps_the_rejected_source_reading() {
1179 for newline in ["\n", "\r", "\r\n"] {
1180 for term in ["", "a", "ยก", "๐ฆ"] {
1181 for text in ["b", "\u{e000}", "๐ฆ"] {
1182 for tail in ["\t:", "\t: next", "\t: *next*", "\t: `next`"] {
1183 let source = format!("{term}{newline}:{text}{newline}{tail}");
1184 let result = CodeBlockUtils::detect_code_blocks_and_spans(&source);
1185 assert!(result.definition_items.is_empty(), "{source:?}");
1186 assert!(result.definition_texts.is_empty(), "{source:?}");
1187 for &(start, end) in &result.rejected_definition_ranges {
1188 assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1189 }
1190 let ctx = crate::lint_context::LintContext::new(
1191 &source,
1192 crate::config::MarkdownFlavor::Standard,
1193 None,
1194 );
1195 assert_eq!(ctx.content, source);
1196 }
1197 }
1198 }
1199 }
1200 assert!(!super::opens_definition("ยก", 1));
1201 assert!(!super::opens_definition(": text", usize::MAX));
1202 assert!(super::opens_definition("\t: text", 0));
1203 }
1204
1205 #[test]
1206 fn test_indented_code_in_list_issue_276() {
1207 let content = r#"1. First item
12092. Second item with code:
1210
1211 # This is a code block in a list
1212 print("Hello, world!")
1213
12144. Third item"#;
1215
1216 let blocks = CodeBlockUtils::detect_code_blocks(content);
1217 assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1219
1220 let all_content: String = blocks
1222 .iter()
1223 .map(|(s, e)| &content[*s..*e])
1224 .collect::<Vec<_>>()
1225 .join("");
1226 assert!(
1227 all_content.contains("code block in a list") || all_content.contains("print"),
1228 "Detected block should contain the code content: {all_content:?}"
1229 );
1230 }
1231
1232 #[test]
1235 fn test_is_in_code_block_empty_blocks() {
1236 assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1237 assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1238 assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1239 }
1240
1241 #[test]
1242 fn test_is_in_code_block_single_range() {
1243 let blocks = [(10, 20)];
1244 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1245 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1246 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1247 assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1248 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1249 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1251 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1252 }
1253
1254 #[test]
1255 fn test_is_in_code_block_multiple_ranges() {
1256 let blocks = [(5, 10), (20, 30), (50, 60)];
1257 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1259 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1260 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1262 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1263 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1265 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1266 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1267 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1269 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1270 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1272 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1273 assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1275 assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1276 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1278 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1279 }
1280
1281 #[test]
1282 fn test_is_in_code_block_adjacent_ranges() {
1283 let blocks = [(0, 10), (10, 20), (20, 30)];
1285 assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1286 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1287 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1288 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1289 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1290 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1291 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1292 }
1293
1294 #[test]
1295 fn test_is_in_code_block_single_byte_range() {
1296 let blocks = [(5, 6)];
1297 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1298 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1299 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1300 }
1301
1302 #[test]
1303 fn test_is_in_code_block_matches_linear_scan() {
1304 let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1307 let blocks = CodeBlockUtils::detect_code_blocks(content);
1308
1309 for pos in 0..content.len() {
1310 let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1311 let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1312 assert_eq!(
1313 binary, linear,
1314 "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1315 );
1316 }
1317 }
1318
1319 #[test]
1320 fn test_is_in_code_block_at_range_boundaries() {
1321 let blocks = [(100, 200), (300, 400), (500, 600)];
1323 for &(start, end) in &blocks {
1324 assert!(
1325 !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1326 "pos={} should be outside",
1327 start - 1
1328 );
1329 assert!(
1330 CodeBlockUtils::is_in_code_block(&blocks, start),
1331 "pos={start} should be inside"
1332 );
1333 assert!(
1334 CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1335 "pos={} should be inside",
1336 end - 1
1337 );
1338 assert!(
1339 !CodeBlockUtils::is_in_code_block(&blocks, end),
1340 "pos={end} should be outside"
1341 );
1342 }
1343 }
1344}
1345
1346#[cfg(test)]
1350mod closed_fence_tests {
1351 use super::*;
1352
1353 fn closed_of(content: &str) -> Vec<bool> {
1354 CodeBlockUtils::detect_code_blocks_and_spans(content)
1355 .code_block_details
1356 .iter()
1357 .filter(|detail| detail.is_fenced)
1358 .map(|detail| detail.closed)
1359 .collect()
1360 }
1361
1362 #[test]
1363 fn a_block_ended_by_a_closing_fence_is_closed() {
1364 for content in [
1365 "```\nx\n```\n",
1366 "```\nx\n```",
1367 "```\n```\n",
1368 "~~~\nx\n~~~\n",
1369 "````\nx\n`````\n",
1370 "```\nx\n``` \n",
1371 "> ```\n> x\n> ```\nfoo\n",
1372 "> ```\n> ```\nfoo\n",
1373 "-\n ```\n x\n ```\n",
1374 "- ```\n x\n\n ```\n",
1375 "```\n\n\n```\n",
1376 " ```\nx\n ```\n",
1377 ] {
1378 assert_eq!(closed_of(content), vec![true], "{content:?}");
1379 }
1380 }
1381
1382 #[test]
1383 fn a_block_the_document_or_container_ends_is_not_closed() {
1384 for content in [
1385 "```\nx\n",
1386 "```\nx",
1387 "```",
1388 "```\n",
1389 "> ```\nfoo\n",
1390 "> ```\n> x\nfoo\n",
1391 "> ```\n> ~~~\nfoo\n",
1393 "```\n~~~\n",
1394 "> ````\n> ```\nfoo\n",
1395 "> ```\n> ```\nfoo\n",
1396 "```\n ```\n",
1397 "> ```\n> \u{a0}```\nfoo\n",
1398 "> ```\n> ```x\nfoo\n",
1399 "> ```\n> ```\u{a0}\nfoo\n",
1400 "> ```\n> ",
1403 "```\n ",
1404 "> ```\n> ~~~\n> ",
1405 "- ```\nfoo\n",
1406 ] {
1407 assert_eq!(closed_of(content), vec![false], "{content:?}");
1408 }
1409 }
1410}