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 pub enclosing: Vec<Enclosure>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Enclosure {
48 Quote { end: usize },
50 Item { width: usize, end: usize },
53 Opaque { end: usize },
56}
57
58impl Enclosure {
59 pub fn end(&self) -> usize {
61 match *self {
62 Self::Quote { end } | Self::Item { end, .. } | Self::Opaque { end } => end,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, Default)]
69pub struct StrippedLine {
70 pub pos: usize,
73 pub col: usize,
75 pub pending: usize,
77}
78
79impl StrippedLine {
80 pub fn indent(&self, line: &str) -> (usize, Option<usize>) {
83 let mut col = self.col + self.pending;
84 let mut pos = self.pos;
85 for c in line[pos..].chars() {
86 match c {
87 ' ' => col += 1,
88 '\t' => col += 4 - col % 4,
89 _ => return (col - self.col, Some(pos)),
90 }
91 pos += c.len_utf8();
92 }
93 (col - self.col, None)
94 }
95
96 fn take_whitespace(&mut self, line: &str, max: usize) -> usize {
97 let mut taken = 0;
98 while taken < max {
99 if self.pending > 0 {
100 let n = self.pending.min(max - taken);
101 self.pending -= n;
102 self.col += n;
103 taken += n;
104 continue;
105 }
106 match line[self.pos..].chars().next() {
107 Some(' ') => {
108 self.pos += 1;
109 self.col += 1;
110 taken += 1;
111 }
112 Some('\t') => {
113 let width = 4 - self.col % 4;
114 let n = width.min(max - taken);
115 self.pos += 1;
116 self.col += n;
117 self.pending = width - n;
118 taken += n;
119 }
120 _ => break,
121 }
122 }
123 taken
124 }
125
126 fn rest_is_blank(&self, line: &str) -> bool {
127 line[self.pos..].chars().all(|c| c == ' ' || c == '\t')
128 }
129
130 fn take_container(&mut self, line: &str, enclosure: Enclosure) -> bool {
133 match enclosure {
134 Enclosure::Quote { .. } => {
135 self.take_whitespace(line, 3);
136 if self.pending > 0 || !line[self.pos..].starts_with('>') {
137 return false;
138 }
139 self.pos += 1;
140 self.col += 1;
141 self.take_whitespace(line, 1);
142 true
143 }
144 Enclosure::Item { width, .. } => self.take_whitespace(line, width) >= width || self.rest_is_blank(line),
145 Enclosure::Opaque { .. } => false,
146 }
147 }
148}
149
150pub fn strip_enclosure(line: &str, enclosing: &[Enclosure]) -> Option<StrippedLine> {
158 let mut at = StrippedLine {
159 pos: 0,
160 col: 0,
161 pending: 0,
162 };
163 enclosing
164 .iter()
165 .all(|&enclosure| at.take_container(line, enclosure))
166 .then_some(at)
167}
168
169pub fn continued_depth(line: &str, enclosing: &[Enclosure], item_runs: &[usize]) -> usize {
176 let blank_from = line.trim_end_matches([' ', '\t']).len();
177 let mut at = StrippedLine {
178 pos: 0,
179 col: 0,
180 pending: 0,
181 };
182 for (i, &enclosure) in enclosing.iter().enumerate() {
183 if at.pos >= blank_from {
184 return i + item_runs[i];
185 }
186 if !at.take_container(line, enclosure) {
187 return i;
188 }
189 }
190 enclosing.len()
191}
192
193pub fn item_runs(enclosing: &[Enclosure]) -> Vec<usize> {
196 let mut runs = vec![0; enclosing.len()];
197 for i in (0..enclosing.len()).rev() {
198 if matches!(enclosing[i], Enclosure::Item { .. }) {
199 runs[i] = 1 + runs.get(i + 1).copied().unwrap_or(0);
200 }
201 }
202 runs
203}
204
205fn item_content_start(content: &str, marker: usize) -> Option<(StrippedLine, usize)> {
210 let line_start = content[..marker].rfind('\n').map_or(0, |i| i + 1);
211 let rest = content.get(marker..)?;
212 let marker_len = match rest.as_bytes().first()? {
213 b'-' | b'+' | b'*' => 1,
214 b'0'..=b'9' => {
215 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
216 match rest.as_bytes().get(digits)? {
217 b'.' | b')' => digits + 1,
218 _ => return None,
219 }
220 }
221 _ => return None,
222 };
223 let marker_end = marker + marker_len;
224 let marker_col = visual_column(content, marker_end);
225 let line_end = content[marker_end..]
226 .find('\n')
227 .map_or(content.len(), |i| marker_end + i);
228 let after = content[marker_end..line_end].trim_end_matches('\r');
229 let mut col = marker_col;
230 let mut text = None;
231 for (i, c) in after.char_indices() {
232 match c {
233 ' ' => col += 1,
234 '\t' => col += 4 - col % 4,
235 _ => {
236 text = Some(i);
237 break;
238 }
239 }
240 }
241 let mut at = StrippedLine {
242 pos: marker_end - line_start,
243 col: marker_col,
244 pending: 0,
245 };
246 match text {
247 Some(i) if (1..=4).contains(&(col - marker_col)) => {
248 at.pos += i;
249 at.col = col;
250 }
251 _ => {
254 if at.take_whitespace(&content[line_start..line_end], 1) == 0 {
255 at.col += 1;
256 }
257 }
258 }
259 Some((at, line_start))
260}
261
262fn fenced_block_is_closed(content: &str, start: usize, end: usize, literal_lines: usize) -> bool {
272 let bytes = content.as_bytes();
273 let end = end.min(bytes.len());
274 let body_end = if end > start && bytes[end - 1] == b'\n' {
276 end - 1
277 } else {
278 end
279 };
280 let body = &bytes[start.min(body_end)..body_end];
281 let span_lines = body.split(|&b| b == b'\n').count();
283 let line_start = body
284 .iter()
285 .rposition(|&b| b == b'\n')
286 .map_or(start, |at| start + at + 1);
287 let last_line = content.get(line_start..body_end).unwrap_or("");
288 span_lines == literal_lines + 2 && last_line.trim_start_matches(['>', ' ', '\t']).starts_with(['`', '~'])
289}
290
291fn visual_column(content: &str, pos: usize) -> usize {
294 let line_start = content[..pos].rfind('\n').map_or(0, |i| i + 1);
295 content[line_start..pos]
296 .chars()
297 .fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 })
298}
299
300#[derive(Debug, Clone)]
302pub struct StrongSpanDetail {
303 pub start: usize,
305 pub end: usize,
307 pub is_asterisk: bool,
309}
310
311#[derive(Debug, Clone)]
318pub struct DefinitionTextDetail {
319 pub start: usize,
321 pub end: usize,
323 pub definition_start: usize,
326}
327
328pub type LineToListMap = std::collections::HashMap<usize, usize>;
330pub type ListStartValues = std::collections::HashMap<usize, u64>;
332
333#[derive(Debug, Default)]
335pub struct ParseResult {
336 pub code_blocks: Vec<(usize, usize)>,
338 pub code_spans: Vec<(usize, usize)>,
340 pub code_block_details: Vec<CodeBlockDetail>,
342 pub strong_spans: Vec<StrongSpanDetail>,
344 pub line_to_list: LineToListMap,
346 pub list_start_values: ListStartValues,
348 pub html_blocks: Vec<(usize, usize)>,
355 pub definition_items: Vec<(usize, usize)>,
360 pub definition_terms: Vec<(usize, usize)>,
362 pub definition_texts: Vec<DefinitionTextDetail>,
364 pub colon_definitions: Vec<ColonDefinitionDetail>,
370 pub table_ranges: Vec<(usize, usize)>,
372 pub top_level_blocks: Vec<(usize, usize)>,
375 pub rejected_definition_ranges: Vec<(usize, usize)>,
379 pub list_items: Vec<(usize, usize, usize)>,
382}
383
384#[derive(Debug, Clone)]
387pub struct ColonDefinitionDetail {
388 pub start: usize,
390 pub end: usize,
392 pub definition_start: usize,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
398pub enum CodeBlockContext {
399 Standalone,
401 Indented,
403 Adjacent,
405}
406
407fn is_inline_tag(tag: &Tag) -> bool {
409 matches!(
410 tag,
411 Tag::Emphasis
412 | Tag::Strong
413 | Tag::Strikethrough
414 | Tag::Superscript
415 | Tag::Subscript
416 | Tag::Link { .. }
417 | Tag::Image { .. }
418 )
419}
420
421fn is_inline_tag_end(tag_end: TagEnd) -> bool {
423 matches!(
424 tag_end,
425 TagEnd::Emphasis
426 | TagEnd::Strong
427 | TagEnd::Strikethrough
428 | TagEnd::Superscript
429 | TagEnd::Subscript
430 | TagEnd::Link
431 | TagEnd::Image
432 )
433}
434
435fn opens_definition(content: &str, start: usize) -> bool {
443 let Some(rest) = content.get(start..) else {
446 return false;
447 };
448 rest.trim_start_matches([' ', '\t'])
449 .strip_prefix(':')
450 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
451}
452
453fn indented_block_columns(content: &str, options: Options) -> Vec<(usize, usize, usize)> {
459 let mut blocks = Vec::new();
460 let mut open: Option<(usize, usize)> = None;
461 let mut awaiting_first_event = false;
462 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
463 if std::mem::take(&mut awaiting_first_event)
464 && let Event::Text(text) = &event
465 && range.is_empty()
466 && let Some((_, column)) = &mut open
467 {
468 *column = column.saturating_sub(text.len());
469 }
470 match event {
471 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
472 open = Some((range.start, visual_column(content, range.start).saturating_sub(4)));
473 awaiting_first_event = true;
474 }
475 Event::End(TagEnd::CodeBlock) => {
476 if let Some((start, column)) = open.take() {
477 blocks.push((start, range.end, column));
478 }
479 }
480 _ => {}
481 }
482 }
483 blocks
484}
485
486fn inline_ranges(content: &str, options: Options) -> Vec<(usize, usize)> {
490 Parser::new_ext(content, options)
491 .into_offset_iter()
492 .filter_map(|(event, range)| match event {
493 Event::Start(tag) if is_inline_tag(&tag) => Some((range.start, range.end)),
494 Event::Code(_) | Event::InlineHtml(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {
495 Some((range.start, range.end))
496 }
497 _ => None,
498 })
499 .collect()
500}
501
502struct InlineRangeCursor<'a> {
507 ranges: &'a [(usize, usize)],
508 next: usize,
509 active: Vec<(usize, usize)>,
510}
511
512impl<'a> InlineRangeCursor<'a> {
513 fn new(ranges: &'a [(usize, usize)]) -> Self {
514 Self {
515 ranges,
516 next: 0,
517 active: Vec::new(),
518 }
519 }
520
521 fn overlapping(&mut self, detail: &ColonDefinitionDetail) -> impl Iterator<Item = (usize, usize)> + '_ {
522 while self.next < self.ranges.len() && self.ranges[self.next].0 <= detail.start {
523 self.active.push(self.ranges[self.next]);
524 self.next += 1;
525 }
526 self.active.retain(|&(_, end)| end > detail.start);
527 let end = detail.end;
528 self.active.iter().copied().chain(
529 self.ranges[self.next..]
530 .iter()
531 .take_while(move |&&(start, _)| start < end)
532 .copied(),
533 )
534 }
535}
536
537pub struct CodeBlockUtils;
539
540impl CodeBlockUtils {
541 pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
551 Self::detect_code_blocks_and_spans(content).code_blocks
552 }
553
554 pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
557 let line_starts: Vec<usize> = std::iter::once(0)
558 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
559 .collect();
560 let options = rumdl_parser_options_for(content);
561 Self::collect_code_blocks_and_spans(
562 content,
563 &line_starts,
564 options,
565 Parser::new_ext(content, options).into_offset_iter(),
566 )
567 }
568
569 pub(crate) fn collect_code_blocks_and_spans<'a>(
573 content: &str,
574 line_starts: &[usize],
575 options: Options,
576 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
577 ) -> ParseResult {
578 let mut blocks = Vec::new();
579 let mut spans = Vec::new();
580 let mut details = Vec::new();
581 let mut strong_spans = Vec::new();
582 let mut html_blocks = Vec::new();
583 let mut code_block_start: Option<(usize, bool, String, Option<usize>)> = None;
584 let mut literal_newlines = 0usize;
587 let mut literal_partial_line = false;
588 let mut awaiting_first_code_event = false;
593 let mut definition_rejected: Vec<bool> = Vec::new();
598 let mut text_reading = Vec::new();
599
600 let mut definition_items: Vec<(usize, usize)> = Vec::new();
611 let mut pending_terms: Vec<(usize, usize)> = Vec::new();
612 let mut definition_terms = Vec::new();
613 let mut definition_texts = Vec::new();
614 let mut colon_definitions = Vec::new();
615 let mut table_ranges: Vec<(usize, usize)> = Vec::new();
616 let mut top_level_blocks: Vec<(usize, usize)> = Vec::new();
617 let mut rejected_definition_ranges: Vec<(usize, usize)> = Vec::new();
618 let mut list_item_spans: Vec<(usize, usize, Option<usize>)> = Vec::new();
619 let mut open_items: Vec<usize> = Vec::new();
620 let mut open_containers: Vec<(Enclosure, usize, StrippedLine)> = Vec::new();
624 let mut code_block_enclosing: Vec<Enclosure> = Vec::new();
625 let mut block_stack: Vec<Option<usize>> = Vec::new();
626 let mut definition_paragraph: Option<(usize, usize)> = None;
627 let mut tight_run: Option<DefinitionTextDetail> = None;
628
629 let mut line_to_list = LineToListMap::new();
631 let mut list_start_values = ListStartValues::new();
632 let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); let mut next_list_id: usize = 0;
634
635 let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
636
637 for (event, range) in events {
638 if let Some(&item) = open_items.last()
639 && list_item_spans[item].2.is_none()
640 && !matches!(event, Event::End(_))
641 {
642 list_item_spans[item].2 = Some(range.start);
643 }
644 if matches!(event, Event::Start(Tag::Item)) {
645 open_items.push(list_item_spans.len());
646 list_item_spans.push((range.start, range.end, None));
647 } else if matches!(event, Event::End(TagEnd::Item)) {
648 open_items.pop();
649 }
650 match &event {
651 Event::Start(Tag::BlockQuote(_)) => {
652 let line_start = content[..range.start].rfind('\n').map_or(0, |i| i + 1);
653 open_containers.push((Enclosure::Quote { end: range.end }, line_start, StrippedLine::default()));
654 }
655 Event::Start(Tag::Item) => {
656 let entry = item_content_start(content, range.start)
657 .and_then(|(content_start, line_start)| {
658 let prefix = &content[line_start..range.start];
659 let same_line = open_containers.iter().rposition(|&(parent, parent_line, _)| {
663 matches!(parent, Enclosure::Item { .. }) && parent_line == line_start
664 });
665 let (mut at, rest) = match same_line {
666 Some(i) => (open_containers[i].2, &open_containers[i + 1..]),
667 None => (StrippedLine::default(), &open_containers[..]),
668 };
669 if !rest.iter().all(|&(parent, _, _)| at.take_container(prefix, parent)) {
670 return None;
671 }
672 let width = content_start.col.checked_sub(at.col)?;
673 Some((Enclosure::Item { width, end: range.end }, line_start, content_start))
674 })
675 .unwrap_or((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
676 open_containers.push(entry);
677 }
678 Event::Start(Tag::DefinitionListDefinition | Tag::FootnoteDefinition(_)) => {
679 open_containers.push((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
680 }
681 Event::End(
682 TagEnd::BlockQuote(_)
683 | TagEnd::Item
684 | TagEnd::DefinitionListDefinition
685 | TagEnd::FootnoteDefinition,
686 ) => {
687 open_containers.pop();
688 }
689 _ => {}
690 }
691 match &event {
692 Event::Start(tag) if !is_inline_tag(tag) => {
693 definition_texts.extend(tight_run.take());
694 if block_stack.is_empty() {
695 top_level_blocks.push((range.start, range.end));
696 }
697 if matches!(tag, Tag::Table(_)) {
698 table_ranges.push((range.start, range.end));
699 }
700 if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
701 definition_paragraph = Some((range.start, *definition_start));
702 }
703 let mut definition_start = None;
704 match tag {
705 Tag::DefinitionList => pending_terms.clear(),
706 Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
707 Tag::DefinitionListDefinition => {
708 let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
711 let item_end = content.as_bytes()[range.clone()]
715 .iter()
716 .rposition(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'>'))
717 .map_or(range.start, |index| range.start + index + 1);
718 let accepted = opens_definition(content, range.start);
719 definition_rejected.push(!accepted);
720 if accepted {
721 definition_items.push((item_start, item_end));
722 definition_terms.append(&mut pending_terms);
723 definition_start = Some(range.start);
724 } else {
725 rejected_definition_ranges.push((item_start, item_end));
726 colon_definitions.push(ColonDefinitionDetail {
727 start: item_start,
728 end: item_end,
729 definition_start: range.start,
730 });
731 pending_terms.clear();
732 }
733 }
734 _ => {}
735 }
736 block_stack.push(definition_start);
737 }
738 Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
739 definition_texts.extend(tight_run.take());
740 if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
741 {
742 definition_texts.push(DefinitionTextDetail {
743 start,
744 end: range.end,
745 definition_start,
746 });
747 }
748 if let TagEnd::DefinitionListDefinition = tag_end {
749 definition_rejected.pop();
750 }
751 if let TagEnd::DefinitionList = tag_end {
752 pending_terms.clear();
753 }
754 block_stack.pop();
755 }
756 _ => {
759 if let Some(Some(definition_start)) = block_stack.last() {
760 match &mut tight_run {
761 Some(run) => run.end = run.end.max(range.end),
762 None => {
763 tight_run = Some(DefinitionTextDetail {
764 start: range.start,
765 end: range.end,
766 definition_start: *definition_start,
767 });
768 }
769 }
770 }
771 }
772 }
773 if std::mem::take(&mut awaiting_first_code_event)
774 && let Event::Text(text) = &event
775 && range.is_empty()
776 && let Some((_, _, _, Some(column))) = &mut code_block_start
777 {
778 *column = column.saturating_sub(text.len());
779 }
780 if code_block_start.is_some()
781 && let Event::Text(text) = &event
782 && !text.is_empty()
783 {
784 literal_newlines += text.matches('\n').count();
785 literal_partial_line = !text.ends_with('\n');
786 }
787 match event {
788 Event::Start(Tag::CodeBlock(kind)) => {
789 literal_newlines = 0;
790 literal_partial_line = false;
791 let (is_fenced, info_string) = match &kind {
792 CodeBlockKind::Fenced(info) => (true, info.to_string()),
793 CodeBlockKind::Indented => (false, String::new()),
794 };
795 let mut content_column =
796 (!is_fenced).then(|| visual_column(content, range.start).saturating_sub(4));
797 if !is_fenced && definition_rejected.contains(&true) {
798 if !block_stack.iter().any(Option::is_some) {
801 text_reading.push(range.start);
802 }
803 content_column = None;
804 }
805 awaiting_first_code_event = !is_fenced;
806 code_block_enclosing = if is_fenced {
807 open_containers.iter().map(|container| container.0).collect()
808 } else {
809 Vec::new()
810 };
811 code_block_start = Some((range.start, is_fenced, info_string, content_column));
812 }
813 Event::End(TagEnd::CodeBlock) => {
814 if let Some((start, is_fenced, info_string, content_column)) = code_block_start.take() {
815 blocks.push((start, range.end));
816 let literal_lines = literal_newlines + usize::from(literal_partial_line);
817 details.push(CodeBlockDetail {
818 start,
819 end: range.end,
820 is_fenced,
821 info_string,
822 content_column,
823 closed: is_fenced && fenced_block_is_closed(content, start, range.end, literal_lines),
824 enclosing: std::mem::take(&mut code_block_enclosing),
825 });
826 }
827 }
828 Event::Start(Tag::Strong) => {
829 if range.start + 2 <= content.len() {
830 let is_asterisk = &content[range.start..range.start + 2] == "**";
831 strong_spans.push(StrongSpanDetail {
832 start: range.start,
833 end: range.end,
834 is_asterisk,
835 });
836 }
837 }
838 Event::Start(Tag::List(start_num)) => {
839 let is_ordered = start_num.is_some();
840 let start_value = start_num.unwrap_or(1);
841 list_stack.push((next_list_id, is_ordered, start_value));
842 if is_ordered {
843 list_start_values.insert(next_list_id, start_value);
844 }
845 next_list_id += 1;
846 }
847 Event::End(TagEnd::List(_)) => {
848 list_stack.pop();
849 }
850 Event::Start(Tag::Item) => {
851 if let Some(&(list_id, is_ordered, _)) = list_stack.last()
852 && is_ordered
853 {
854 let line_num = byte_to_line(range.start);
855 line_to_list.insert(line_num, list_id);
856 }
857 }
858 Event::Start(Tag::HtmlBlock) => {
859 html_blocks.push((range.start, range.end));
861 }
862 Event::Code(_) => {
863 spans.push((range.start, range.end));
864 }
865 _ => {}
866 }
867 }
868
869 if let Some((start, is_fenced, info_string, content_column)) = code_block_start {
872 blocks.push((start, content.len()));
873 details.push(CodeBlockDetail {
874 start,
875 end: content.len(),
876 is_fenced,
877 info_string,
878 content_column,
879 closed: false,
880 enclosing: std::mem::take(&mut code_block_enclosing),
881 });
882 }
883
884 if !text_reading.is_empty() {
885 let text_blocks = indented_block_columns(content, options - Options::ENABLE_DEFINITION_LIST);
886 for detail in details.iter_mut().filter(|d| text_reading.contains(&d.start)) {
887 let lines = (byte_to_line(detail.start), byte_to_line(detail.end.saturating_sub(1)));
888 detail.content_column = text_blocks
889 .iter()
890 .find(|&&(start, end, _)| (byte_to_line(start), byte_to_line(end.saturating_sub(1))) == lines)
891 .map(|&(_, _, column)| column);
892 }
893 }
894
895 if !colon_definitions.is_empty() {
898 let as_definitions = inline_ranges(content, options);
899 let as_text = inline_ranges(content, options - Options::ENABLE_DEFINITION_LIST);
900 let mut definition_ranges = InlineRangeCursor::new(&as_definitions);
901 let mut text_ranges = InlineRangeCursor::new(&as_text);
902 colon_definitions.retain(|detail| {
903 !definition_ranges
904 .overlapping(detail)
905 .eq(text_ranges.overlapping(detail))
906 });
907 }
908
909 blocks.sort_by_key(|&(start, _)| start);
911 spans.sort_by_key(|&(start, _)| start);
912 details.sort_by_key(|d| d.start);
913 strong_spans.sort_by_key(|s| s.start);
914 html_blocks.sort_by_key(|&(start, _)| start);
915 ParseResult {
916 definition_items,
917 definition_terms,
918 definition_texts,
919 colon_definitions,
920 code_blocks: blocks,
921 code_spans: spans,
922 code_block_details: details,
923 strong_spans,
924 line_to_list,
925 list_start_values,
926 html_blocks,
927 table_ranges,
928 top_level_blocks,
929 rejected_definition_ranges,
930 list_items: list_item_spans
931 .into_iter()
932 .filter_map(|(start, end, content)| content.map(|content| (start, end, content)))
933 .collect(),
934 }
935 }
936
937 pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
939 Self::is_in_code_block(blocks, pos)
940 }
941
942 pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
948 let idx = blocks.partition_point(|&(start, _)| start <= pos);
950 idx > 0 && pos < blocks[idx - 1].1
953 }
954
955 pub fn analyze_code_block_context(
958 lines: &[crate::lint_context::LineInfo],
959 line_idx: usize,
960 min_continuation_indent: usize,
961 ) -> CodeBlockContext {
962 if let Some(line_info) = lines.get(line_idx) {
963 if line_info.indent >= min_continuation_indent {
965 return CodeBlockContext::Indented;
966 }
967
968 let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
970
971 if prev_blanks > 0 || next_blanks > 0 {
974 return CodeBlockContext::Standalone;
975 }
976
977 CodeBlockContext::Adjacent
979 } else {
980 CodeBlockContext::Adjacent
982 }
983 }
984
985 fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
987 let mut prev_blanks = 0;
988 let mut next_blanks = 0;
989
990 for i in (0..line_idx).rev() {
992 if let Some(line) = lines.get(i) {
993 if line.is_blank {
994 prev_blanks += 1;
995 } else {
996 break;
997 }
998 } else {
999 break;
1000 }
1001 }
1002
1003 for i in (line_idx + 1)..lines.len() {
1005 if let Some(line) = lines.get(i) {
1006 if line.is_blank {
1007 next_blanks += 1;
1008 } else {
1009 break;
1010 }
1011 } else {
1012 break;
1013 }
1014 }
1015
1016 (prev_blanks, next_blanks)
1017 }
1018
1019 pub fn calculate_min_continuation_indent(
1022 content: &str,
1023 lines: &[crate::lint_context::LineInfo],
1024 current_line_idx: usize,
1025 ) -> usize {
1026 for i in (0..current_line_idx).rev() {
1028 if let Some(line_info) = lines.get(i)
1029 && let Some(indent) = Self::continuation_indent_boundary(content, line_info)
1030 {
1031 return indent;
1032 }
1033 }
1034 0
1035 }
1036
1037 pub(crate) fn continuation_indent_boundary(
1041 content: &str,
1042 line_info: &crate::lint_context::LineInfo,
1043 ) -> Option<usize> {
1044 if let Some(list_item) = &line_info.list_item {
1045 Some(if list_item.is_ordered {
1046 list_item.marker_column + list_item.marker.len() + 1
1047 } else {
1048 list_item.marker_column + 2
1049 })
1050 } else if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
1051 Some(0)
1052 } else {
1053 None
1054 }
1055 }
1056
1057 fn is_structural_separator(content: &str) -> bool {
1059 let trimmed = content.trim();
1060 trimmed.starts_with("---")
1061 || trimmed.starts_with("***")
1062 || trimmed.starts_with("___")
1063 || crate::utils::skip_context::is_table_line(trimmed)
1064 || trimmed.starts_with('>') }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 #[test]
1071 fn test_continued_depth_is_the_longest_prefix_strip_enclosure_accepts() {
1072 use super::{Enclosure, continued_depth, item_runs, strip_enclosure};
1073 let quote = Enclosure::Quote { end: 0 };
1074 let item = |width| Enclosure::Item { width, end: 0 };
1075 let stacks = [
1076 vec![quote, item(2), item(2), quote, item(3)],
1077 vec![item(2), quote, quote, item(4)],
1078 vec![item(2), item(2), item(2)],
1079 vec![quote, Enclosure::Opaque { end: 0 }, item(2)],
1080 ];
1081 let lines = [
1082 "",
1083 " ",
1084 "\t",
1085 ">",
1086 "> ",
1087 ">\t",
1088 "> ",
1089 "> > x",
1090 "> x",
1091 " > > x",
1092 " >",
1093 " > ",
1094 " x",
1095 " x",
1096 " x",
1097 "\tx",
1098 " \t x",
1099 "lazy",
1100 "> > > x",
1101 "> > x",
1102 ];
1103 for stack in &stacks {
1104 let runs = item_runs(stack);
1105 for line in lines {
1106 let expected = (0..=stack.len())
1107 .rev()
1108 .find(|&k| strip_enclosure(line, &stack[..k]).is_some())
1109 .unwrap();
1110 assert_eq!(
1111 continued_depth(line, stack, &runs),
1112 expected,
1113 "line {line:?} in {stack:?}"
1114 );
1115 }
1116 }
1117 }
1118
1119 #[test]
1120 fn test_items_opening_on_one_line_through_a_quote_record_their_widths() {
1121 use super::Enclosure;
1122 let quote = Enclosure::Quote { end: 0 };
1123 let item = |width| Enclosure::Item { width, end: 0 };
1124 let cases = [
1125 ("- > - ```md\n > x\n > ```\n", vec![item(2), quote, item(2)]),
1126 (
1127 "> - > - ```md\n> > x\n> > ```\n",
1128 vec![quote, item(2), quote, item(2)],
1129 ),
1130 ("1. > - ```md\n > x\n > ```\n", vec![item(3), quote, item(2)]),
1131 ("- > 10. ```md\n > x\n > ```\n", vec![item(2), quote, item(4)]),
1132 ("- >\t- ```md\n >\t x\n >\t ```\n", vec![item(2), quote, item(2)]),
1133 (
1134 "- > - - ```md\n > x\n > ```\n",
1135 vec![item(2), quote, item(2), item(2)],
1136 ),
1137 (
1138 "- > > - ```md\n > > x\n > > ```\n",
1139 vec![item(2), quote, quote, item(2)],
1140 ),
1141 ];
1142 for (content, expected) in cases {
1143 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1144 let enclosing: Vec<Enclosure> = parsed.code_block_details[0]
1145 .enclosing
1146 .iter()
1147 .map(|&enclosure| match enclosure {
1148 Enclosure::Quote { .. } => quote,
1149 Enclosure::Item { width, .. } => item(width),
1150 Enclosure::Opaque { .. } => Enclosure::Opaque { end: 0 },
1151 })
1152 .collect();
1153 assert_eq!(enclosing, expected, "{content:?}");
1154 }
1155 }
1156
1157 #[test]
1158 fn test_table_ranges_and_top_level_blocks_are_recorded() {
1159 let content = "para\n\n|a|b|\n|-|-|\n|1|2|\n\n- item\n\n |c|d|\n |-|-|\n";
1160 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1161 let text = |&(start, end): &(usize, usize)| &content[start..end];
1162
1163 assert_eq!(parsed.table_ranges.len(), 2);
1164 assert_eq!(text(&parsed.table_ranges[0]), "|a|b|\n|-|-|\n|1|2|\n");
1165 assert!(text(&parsed.table_ranges[1]).starts_with("|c|d|"));
1166
1167 let top: Vec<&str> = parsed.top_level_blocks.iter().map(text).collect();
1170 assert_eq!(top.len(), 3, "{top:?}");
1171 assert_eq!(top[0], "para\n");
1172 assert!(top[2].starts_with("- item"));
1173 }
1174
1175 #[test]
1176 fn test_list_items_record_where_their_content_starts() {
1177 let content = "- - x\n\n- y\n-\n- \n z\n";
1178 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1179 let starts: Vec<(&str, &str)> = parsed
1180 .list_items
1181 .iter()
1182 .map(|&(start, _, content_start)| (&content[start..=start], &content[content_start..=content_start]))
1183 .collect();
1184 assert_eq!(starts, [("-", "-"), ("-", "x"), ("-", "y"), ("-", "z")]);
1186 }
1187
1188 #[test]
1189 fn test_no_table_ranges_without_a_table() {
1190 let parsed = CodeBlockUtils::detect_code_blocks_and_spans("a|b\n|-|\n\n |a|b|\n |-|-|\n");
1191 assert!(parsed.table_ranges.is_empty());
1192 }
1193
1194 use super::*;
1195
1196 #[test]
1197 fn test_detect_fenced_code_blocks() {
1198 let content = "Some text\n```\ncode here\n```\nMore text";
1203 let blocks = CodeBlockUtils::detect_code_blocks(content);
1204 assert_eq!(blocks.len(), 1);
1206
1207 let fenced_block = blocks
1209 .iter()
1210 .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
1211 assert!(fenced_block.is_some());
1212
1213 let content = "Some text\n~~~\ncode here\n~~~\nMore text";
1215 let blocks = CodeBlockUtils::detect_code_blocks(content);
1216 assert_eq!(blocks.len(), 1);
1217 assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
1218
1219 let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
1221 let blocks = CodeBlockUtils::detect_code_blocks(content);
1222 assert_eq!(blocks.len(), 2);
1224 }
1225
1226 #[test]
1227 fn test_detect_code_blocks_with_language() {
1228 let content = "Text\n```rust\nfn main() {}\n```\nMore";
1230 let blocks = CodeBlockUtils::detect_code_blocks(content);
1231 assert_eq!(blocks.len(), 1);
1233 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
1235 assert!(fenced.is_some());
1236 }
1237
1238 #[test]
1239 fn test_unclosed_code_block() {
1240 let content = "Text\n```\ncode here\nno closing fence";
1242 let blocks = CodeBlockUtils::detect_code_blocks(content);
1243 assert_eq!(blocks.len(), 1);
1244 assert_eq!(blocks[0].1, content.len());
1245 }
1246
1247 #[test]
1248 fn test_indented_code_blocks() {
1249 let content = "Paragraph\n\n code line 1\n code line 2\n\nMore text";
1251 let blocks = CodeBlockUtils::detect_code_blocks(content);
1252 assert_eq!(blocks.len(), 1);
1253 assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
1254 assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
1255
1256 let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
1258 let blocks = CodeBlockUtils::detect_code_blocks(content);
1259 assert_eq!(blocks.len(), 1);
1260 }
1261
1262 #[test]
1263 fn test_indented_code_requires_blank_line() {
1264 let content = "Paragraph\n indented but not code\nMore text";
1266 let blocks = CodeBlockUtils::detect_code_blocks(content);
1267 assert_eq!(blocks.len(), 0);
1268
1269 let content = "Paragraph\n\n now it's code\nMore text";
1271 let blocks = CodeBlockUtils::detect_code_blocks(content);
1272 assert_eq!(blocks.len(), 1);
1273 }
1274
1275 #[test]
1276 fn test_indented_content_with_list_markers_is_code_block() {
1277 let content = "List:\n\n - Item 1\n - Item 2\n * Item 3\n + Item 4";
1282 let blocks = CodeBlockUtils::detect_code_blocks(content);
1283 assert_eq!(blocks.len(), 1); let content = "List:\n\n 1. First\n 2. Second";
1287 let blocks = CodeBlockUtils::detect_code_blocks(content);
1288 assert_eq!(blocks.len(), 1); }
1290
1291 #[test]
1292 fn test_actual_list_items_not_code_blocks() {
1293 let content = "- Item 1\n- Item 2\n* Item 3";
1295 let blocks = CodeBlockUtils::detect_code_blocks(content);
1296 assert_eq!(blocks.len(), 0);
1297
1298 let content = "- Item 1\n - Nested item\n- Item 2";
1300 let blocks = CodeBlockUtils::detect_code_blocks(content);
1301 assert_eq!(blocks.len(), 0);
1302 }
1303
1304 #[test]
1305 fn test_inline_code_spans_not_detected() {
1306 let content = "Text with `inline code` here";
1308 let blocks = CodeBlockUtils::detect_code_blocks(content);
1309 assert_eq!(blocks.len(), 0); let content = "Text with ``code with ` backtick`` here";
1313 let blocks = CodeBlockUtils::detect_code_blocks(content);
1314 assert_eq!(blocks.len(), 0); let content = "Has `code1` and `code2` spans";
1318 let blocks = CodeBlockUtils::detect_code_blocks(content);
1319 assert_eq!(blocks.len(), 0); }
1321
1322 #[test]
1323 fn test_unclosed_code_span() {
1324 let content = "Text with `unclosed code span";
1326 let blocks = CodeBlockUtils::detect_code_blocks(content);
1327 assert_eq!(blocks.len(), 0);
1328
1329 let content = "Text with ``one style` different close";
1331 let blocks = CodeBlockUtils::detect_code_blocks(content);
1332 assert_eq!(blocks.len(), 0);
1333 }
1334
1335 #[test]
1336 fn test_mixed_code_blocks_and_spans() {
1337 let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
1338 let blocks = CodeBlockUtils::detect_code_blocks(content);
1339 assert_eq!(blocks.len(), 1);
1341
1342 assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
1344 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
1346 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
1347 }
1348
1349 #[test]
1350 fn test_is_in_code_block_or_span() {
1351 let blocks = vec![(10, 20), (30, 40), (50, 60)];
1352
1353 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
1355 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
1356 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
1357
1358 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));
1364 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
1365 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
1366 }
1367
1368 #[test]
1369 fn test_empty_content() {
1370 let blocks = CodeBlockUtils::detect_code_blocks("");
1371 assert_eq!(blocks.len(), 0);
1372 }
1373
1374 #[test]
1375 fn test_code_block_at_start() {
1376 let content = "```\ncode\n```\nText after";
1377 let blocks = CodeBlockUtils::detect_code_blocks(content);
1378 assert_eq!(blocks.len(), 1);
1380 assert_eq!(blocks[0].0, 0); }
1382
1383 #[test]
1384 fn test_code_block_at_end() {
1385 let content = "Text before\n```\ncode\n```";
1386 let blocks = CodeBlockUtils::detect_code_blocks(content);
1387 assert_eq!(blocks.len(), 1);
1389 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
1391 assert!(fenced.is_some());
1392 }
1393
1394 #[test]
1395 fn test_nested_fence_markers() {
1396 let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1398 let blocks = CodeBlockUtils::detect_code_blocks(content);
1399 assert!(!blocks.is_empty());
1401 let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
1403 assert!(outer.is_some());
1404 }
1405
1406 #[test]
1407 fn test_indented_code_with_blank_lines() {
1408 let content = "Text\n\n line1\n\n line2\n\nAfter";
1410 let blocks = CodeBlockUtils::detect_code_blocks(content);
1411 assert!(!blocks.is_empty());
1413 let all_content: String = blocks
1415 .iter()
1416 .map(|(s, e)| &content[*s..*e])
1417 .collect::<Vec<_>>()
1418 .join("");
1419 assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
1420 }
1421
1422 #[test]
1423 fn test_code_span_with_spaces() {
1424 let content = "Text ` code with spaces ` more";
1426 let blocks = CodeBlockUtils::detect_code_blocks(content);
1427 assert_eq!(blocks.len(), 0); }
1429
1430 #[test]
1431 fn test_fenced_block_with_info_string() {
1432 let content = "```rust,no_run,should_panic\ncode\n```";
1434 let blocks = CodeBlockUtils::detect_code_blocks(content);
1435 assert_eq!(blocks.len(), 1);
1437 assert_eq!(blocks[0].0, 0);
1438 }
1439
1440 #[test]
1441 fn test_indented_fences_not_code_blocks() {
1442 let content = "Text\n ```\n code\n ```\nAfter";
1444 let blocks = CodeBlockUtils::detect_code_blocks(content);
1445 assert_eq!(blocks.len(), 1);
1447 }
1448
1449 #[test]
1451 fn test_backticks_in_info_string_not_code_block() {
1452 let content = "```something```\n\n```bash\n# comment\n```";
1458 let blocks = CodeBlockUtils::detect_code_blocks(content);
1459 assert_eq!(blocks.len(), 1);
1461 assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1463 }
1464
1465 #[test]
1466 fn test_issue_175_reproduction() {
1467 let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐ฆ\"\n```";
1469 let blocks = CodeBlockUtils::detect_code_blocks(content);
1470 assert_eq!(blocks.len(), 1);
1472 assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
1473 }
1474
1475 #[test]
1476 fn test_tilde_fence_allows_tildes_in_info_string() {
1477 let content = "~~~abc~~~\ncode content\n~~~";
1480 let blocks = CodeBlockUtils::detect_code_blocks(content);
1481 assert_eq!(blocks.len(), 1);
1483 }
1484
1485 #[test]
1486 fn test_nested_longer_fence_contains_shorter() {
1487 let content = "````\n```\nnested content\n```\n````";
1489 let blocks = CodeBlockUtils::detect_code_blocks(content);
1490 assert_eq!(blocks.len(), 1);
1491 assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
1492 }
1493
1494 #[test]
1495 fn test_mixed_fence_types() {
1496 let content = "~~~\n```\nmixed content\n~~~";
1498 let blocks = CodeBlockUtils::detect_code_blocks(content);
1499 assert_eq!(blocks.len(), 1);
1500 assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
1501 }
1502
1503 #[test]
1504 fn test_indented_block_content_column_is_its_containers() {
1505 let cases = [
1506 ("Text\n\n code\n", 0),
1507 ("- a\n\n code\n", 2),
1508 ("1. a\n\n code\n", 3),
1509 ("1. a\n\nb\n\n code\n", 0),
1511 ("1. a\nb\nc\nd\ne\nf\ng\n\n code\n", 3),
1513 ("> quoted\n", 2),
1514 ("- a\n\n\t code\n", 2),
1515 ("1. a\n\n\t\tcode\n", 3),
1517 (">\t\tcode\n", 2),
1518 ];
1519 for (content, expected) in cases {
1520 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1521 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1522 assert_eq!(columns, [Some(expected)], "content column for {content:?}");
1523 }
1524
1525 let fenced = CodeBlockUtils::detect_code_blocks_and_spans("- a\n\n ```\n code\n ```\n");
1526 let columns: Vec<_> = fenced.code_block_details.iter().map(|d| d.content_column).collect();
1527 assert_eq!(columns, [None]);
1528 }
1529
1530 #[test]
1531 fn test_indented_block_in_rejected_definition_takes_its_columns_from_the_text_reading() {
1532 let cases = [
1533 ("term\n:warning:\n\n x\n", Some(0)),
1535 ("- a\n\n term\n :warning:\n\n x\n", Some(2)),
1536 ("term\n: def\n\n x\n", Some(2)),
1538 ("term\n: def\n\n t2\n :warning:\n\n x\n", None),
1541 ];
1542 for (content, expected) in cases {
1543 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1544 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1545 assert_eq!(columns, [expected], "content column for {content:?}");
1546 }
1547 }
1548
1549 #[test]
1550 fn tab_indented_definition_markers_after_utf8_keep_valid_ranges() {
1551 for newline in ["\n", "\r", "\r\n"] {
1552 for term in ["", "a", "ยก", "๐ฆ"] {
1553 for text in ["b", "\u{e000}", "๐ฆ"] {
1554 for tail in ["\t:", "\t: next", "\t: *next*", "\t: `next`"] {
1555 let source = format!("{term}{newline}:{text}{newline}{tail}");
1556 let result = CodeBlockUtils::detect_code_blocks_and_spans(&source);
1557 assert_eq!(
1561 result.definition_items.len(),
1562 usize::from(!term.is_empty()),
1563 "{source:?}"
1564 );
1565 assert_eq!(
1566 result.definition_texts.len(),
1567 usize::from(!term.is_empty() && tail != "\t:"),
1568 "{source:?}"
1569 );
1570 let parent_colon = term.len() + newline.len();
1571 let nested_colon = source.len() - tail.len() + 1;
1572 for &(start, end) in &result.definition_items {
1573 assert_eq!((start, end), (parent_colon + 1, source.len()), "{source:?}");
1574 assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1575 }
1576 for detail in &result.definition_texts {
1577 assert!(source.get(detail.start..detail.end).is_some(), "{source:?}: {detail:?}");
1578 assert_eq!(detail.definition_start, nested_colon, "{source:?}");
1579 }
1580 if !term.is_empty() {
1581 assert!(
1582 result
1583 .rejected_definition_ranges
1584 .iter()
1585 .any(|&(start, end)| start <= parent_colon && parent_colon < end),
1586 "{source:?}"
1587 );
1588 }
1589 for &(start, end) in &result.rejected_definition_ranges {
1590 assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1591 }
1592 let ctx = crate::lint_context::LintContext::new(
1593 &source,
1594 crate::config::MarkdownFlavor::Standard,
1595 None,
1596 );
1597 assert_eq!(ctx.content, source);
1598 }
1599 }
1600 }
1601 }
1602 assert!(!super::opens_definition("ยก", 1));
1603 assert!(!super::opens_definition(": text", usize::MAX));
1604 assert!(super::opens_definition("\t: text", 0));
1605 }
1606
1607 #[test]
1608 fn test_indented_code_in_list_issue_276() {
1609 let content = r#"1. First item
16112. Second item with code:
1612
1613 # This is a code block in a list
1614 print("Hello, world!")
1615
16164. Third item"#;
1617
1618 let blocks = CodeBlockUtils::detect_code_blocks(content);
1619 assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1621
1622 let all_content: String = blocks
1624 .iter()
1625 .map(|(s, e)| &content[*s..*e])
1626 .collect::<Vec<_>>()
1627 .join("");
1628 assert!(
1629 all_content.contains("code block in a list") || all_content.contains("print"),
1630 "Detected block should contain the code content: {all_content:?}"
1631 );
1632 }
1633
1634 #[test]
1637 fn test_is_in_code_block_empty_blocks() {
1638 assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1639 assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1640 assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1641 }
1642
1643 #[test]
1644 fn test_is_in_code_block_single_range() {
1645 let blocks = [(10, 20)];
1646 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1647 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1648 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1649 assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1650 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1651 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1653 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1654 }
1655
1656 #[test]
1657 fn test_is_in_code_block_multiple_ranges() {
1658 let blocks = [(5, 10), (20, 30), (50, 60)];
1659 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1661 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1662 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1664 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1665 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1667 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1668 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1669 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1671 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1672 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1674 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1675 assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1677 assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1678 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1680 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1681 }
1682
1683 #[test]
1684 fn test_is_in_code_block_adjacent_ranges() {
1685 let blocks = [(0, 10), (10, 20), (20, 30)];
1687 assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1688 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1689 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1690 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1691 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1692 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1693 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1694 }
1695
1696 #[test]
1697 fn test_is_in_code_block_single_byte_range() {
1698 let blocks = [(5, 6)];
1699 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1700 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1701 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1702 }
1703
1704 #[test]
1705 fn test_is_in_code_block_matches_linear_scan() {
1706 let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1709 let blocks = CodeBlockUtils::detect_code_blocks(content);
1710
1711 for pos in 0..content.len() {
1712 let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1713 let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1714 assert_eq!(
1715 binary, linear,
1716 "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1717 );
1718 }
1719 }
1720
1721 #[test]
1722 fn test_is_in_code_block_at_range_boundaries() {
1723 let blocks = [(100, 200), (300, 400), (500, 600)];
1725 for &(start, end) in &blocks {
1726 assert!(
1727 !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1728 "pos={} should be outside",
1729 start - 1
1730 );
1731 assert!(
1732 CodeBlockUtils::is_in_code_block(&blocks, start),
1733 "pos={start} should be inside"
1734 );
1735 assert!(
1736 CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1737 "pos={} should be inside",
1738 end - 1
1739 );
1740 assert!(
1741 !CodeBlockUtils::is_in_code_block(&blocks, end),
1742 "pos={end} should be outside"
1743 );
1744 }
1745 }
1746}
1747
1748#[cfg(test)]
1752mod closed_fence_tests {
1753 use super::*;
1754
1755 fn closed_of(content: &str) -> Vec<bool> {
1756 CodeBlockUtils::detect_code_blocks_and_spans(content)
1757 .code_block_details
1758 .iter()
1759 .filter(|detail| detail.is_fenced)
1760 .map(|detail| detail.closed)
1761 .collect()
1762 }
1763
1764 #[test]
1765 fn a_block_ended_by_a_closing_fence_is_closed() {
1766 for content in [
1767 "```\nx\n```\n",
1768 "```\nx\n```",
1769 "```\n```\n",
1770 "~~~\nx\n~~~\n",
1771 "````\nx\n`````\n",
1772 "```\nx\n``` \n",
1773 "> ```\n> x\n> ```\nfoo\n",
1774 "> ```\n> ```\nfoo\n",
1775 "-\n ```\n x\n ```\n",
1776 "- ```\n x\n\n ```\n",
1777 "```\n\n\n```\n",
1778 " ```\nx\n ```\n",
1779 ] {
1780 assert_eq!(closed_of(content), vec![true], "{content:?}");
1781 }
1782 }
1783
1784 #[test]
1785 fn a_block_the_document_or_container_ends_is_not_closed() {
1786 for content in [
1787 "```\nx\n",
1788 "```\nx",
1789 "```",
1790 "```\n",
1791 "> ```\nfoo\n",
1792 "> ```\n> x\nfoo\n",
1793 "> ```\n> ~~~\nfoo\n",
1795 "```\n~~~\n",
1796 "> ````\n> ```\nfoo\n",
1797 "> ```\n> ```\nfoo\n",
1798 "```\n ```\n",
1799 "> ```\n> \u{a0}```\nfoo\n",
1800 "> ```\n> ```x\nfoo\n",
1801 "> ```\n> ```\u{a0}\nfoo\n",
1802 "> ```\n> ",
1805 "```\n ",
1806 "> ```\n> ~~~\n> ",
1807 "- ```\nfoo\n",
1808 ] {
1809 assert_eq!(closed_of(content), vec![false], "{content:?}");
1810 }
1811 }
1812}