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 {
447 return false;
448 };
449 rest.trim_start_matches([' ', '\t'])
450 .strip_prefix(':')
451 .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
452}
453
454fn indented_block_columns(content: &str, options: Options) -> Vec<(usize, usize, usize)> {
460 let mut blocks = Vec::new();
461 let mut open: Option<(usize, usize)> = None;
462 let mut awaiting_first_event = false;
463 for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
464 if std::mem::take(&mut awaiting_first_event)
465 && let Event::Text(text) = &event
466 && range.is_empty()
467 && let Some((_, column)) = &mut open
468 {
469 *column = column.saturating_sub(text.len());
470 }
471 match event {
472 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
473 open = Some((range.start, visual_column(content, range.start).saturating_sub(4)));
474 awaiting_first_event = true;
475 }
476 Event::End(TagEnd::CodeBlock) => {
477 if let Some((start, column)) = open.take() {
478 blocks.push((start, range.end, column));
479 }
480 }
481 _ => {}
482 }
483 }
484 blocks
485}
486
487fn inline_ranges(content: &str, options: Options) -> Vec<(usize, usize)> {
491 Parser::new_ext(content, options)
492 .into_offset_iter()
493 .filter_map(|(event, range)| match event {
494 Event::Start(tag) if is_inline_tag(&tag) => Some((range.start, range.end)),
495 Event::Code(_) | Event::InlineHtml(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {
496 Some((range.start, range.end))
497 }
498 _ => None,
499 })
500 .collect()
501}
502
503struct InlineRangeCursor<'a> {
508 ranges: &'a [(usize, usize)],
509 next: usize,
510 active: Vec<(usize, usize)>,
511}
512
513impl<'a> InlineRangeCursor<'a> {
514 fn new(ranges: &'a [(usize, usize)]) -> Self {
515 Self {
516 ranges,
517 next: 0,
518 active: Vec::new(),
519 }
520 }
521
522 fn overlapping(&mut self, detail: &ColonDefinitionDetail) -> impl Iterator<Item = (usize, usize)> + '_ {
523 while self.next < self.ranges.len() && self.ranges[self.next].0 <= detail.start {
524 self.active.push(self.ranges[self.next]);
525 self.next += 1;
526 }
527 self.active.retain(|&(_, end)| end > detail.start);
528 let end = detail.end;
529 self.active.iter().copied().chain(
530 self.ranges[self.next..]
531 .iter()
532 .take_while(move |&&(start, _)| start < end)
533 .copied(),
534 )
535 }
536}
537
538pub struct CodeBlockUtils;
540
541impl CodeBlockUtils {
542 pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
552 Self::detect_code_blocks_and_spans(content).code_blocks
553 }
554
555 pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
558 let line_starts: Vec<usize> = std::iter::once(0)
559 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
560 .collect();
561 let options = rumdl_parser_options_for(content);
562 Self::collect_code_blocks_and_spans(
563 content,
564 &line_starts,
565 options,
566 Parser::new_ext(content, options).into_offset_iter(),
567 )
568 }
569
570 pub(crate) fn collect_code_blocks_and_spans<'a>(
574 content: &str,
575 line_starts: &[usize],
576 options: Options,
577 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
578 ) -> ParseResult {
579 let mut blocks = Vec::new();
580 let mut spans = Vec::new();
581 let mut details = Vec::new();
582 let mut strong_spans = Vec::new();
583 let mut html_blocks = Vec::new();
584 let mut code_block_start: Option<(usize, bool, String, Option<usize>)> = None;
585 let mut literal_newlines = 0usize;
588 let mut literal_partial_line = false;
589 let mut awaiting_first_code_event = false;
594 let mut definition_rejected: Vec<bool> = Vec::new();
599 let mut text_reading = Vec::new();
600
601 let mut definition_items: Vec<(usize, usize)> = Vec::new();
612 let mut pending_terms: Vec<(usize, usize)> = Vec::new();
613 let mut definition_terms = Vec::new();
614 let mut definition_texts = Vec::new();
615 let mut colon_definitions = Vec::new();
616 let mut table_ranges: Vec<(usize, usize)> = Vec::new();
617 let mut top_level_blocks: Vec<(usize, usize)> = Vec::new();
618 let mut rejected_definition_ranges: Vec<(usize, usize)> = Vec::new();
619 let mut list_item_spans: Vec<(usize, usize, Option<usize>)> = Vec::new();
620 let mut open_items: Vec<usize> = Vec::new();
621 let mut open_containers: Vec<(Enclosure, usize, StrippedLine)> = Vec::new();
625 let mut code_block_enclosing: Vec<Enclosure> = Vec::new();
626 let mut block_stack: Vec<Option<usize>> = Vec::new();
627 let mut definition_paragraph: Option<(usize, usize)> = None;
628 let mut tight_run: Option<DefinitionTextDetail> = None;
629
630 let mut line_to_list = LineToListMap::new();
632 let mut list_start_values = ListStartValues::new();
633 let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); let mut next_list_id: usize = 0;
635
636 let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
637
638 for (event, range) in events {
639 if let Some(&item) = open_items.last()
640 && list_item_spans[item].2.is_none()
641 && !matches!(event, Event::End(_))
642 {
643 list_item_spans[item].2 = Some(range.start);
644 }
645 if matches!(event, Event::Start(Tag::Item)) {
646 open_items.push(list_item_spans.len());
647 list_item_spans.push((range.start, range.end, None));
648 } else if matches!(event, Event::End(TagEnd::Item)) {
649 open_items.pop();
650 }
651 match &event {
652 Event::Start(Tag::BlockQuote(_)) => {
653 let line_start = content[..range.start].rfind('\n').map_or(0, |i| i + 1);
654 open_containers.push((Enclosure::Quote { end: range.end }, line_start, StrippedLine::default()));
655 }
656 Event::Start(Tag::Item) => {
657 let entry = item_content_start(content, range.start)
658 .and_then(|(content_start, line_start)| {
659 let prefix = &content[line_start..range.start];
660 let same_line = open_containers.iter().rposition(|&(parent, parent_line, _)| {
664 matches!(parent, Enclosure::Item { .. }) && parent_line == line_start
665 });
666 let (mut at, rest) = match same_line {
667 Some(i) => (open_containers[i].2, &open_containers[i + 1..]),
668 None => (StrippedLine::default(), &open_containers[..]),
669 };
670 if !rest.iter().all(|&(parent, _, _)| at.take_container(prefix, parent)) {
671 return None;
672 }
673 let width = content_start.col.checked_sub(at.col)?;
674 Some((Enclosure::Item { width, end: range.end }, line_start, content_start))
675 })
676 .unwrap_or((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
677 open_containers.push(entry);
678 }
679 Event::Start(Tag::DefinitionListDefinition | Tag::FootnoteDefinition(_)) => {
680 open_containers.push((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
681 }
682 Event::End(
683 TagEnd::BlockQuote(_)
684 | TagEnd::Item
685 | TagEnd::DefinitionListDefinition
686 | TagEnd::FootnoteDefinition,
687 ) => {
688 open_containers.pop();
689 }
690 _ => {}
691 }
692 match &event {
693 Event::Start(tag) if !is_inline_tag(tag) => {
694 definition_texts.extend(tight_run.take());
695 if block_stack.is_empty() {
696 top_level_blocks.push((range.start, range.end));
697 }
698 if matches!(tag, Tag::Table(_)) {
699 table_ranges.push((range.start, range.end));
700 }
701 if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
702 definition_paragraph = Some((range.start, *definition_start));
703 }
704 let mut definition_start = None;
705 match tag {
706 Tag::DefinitionList => pending_terms.clear(),
707 Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
708 Tag::DefinitionListDefinition => {
709 let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
712 let item_end = content.as_bytes()[range.clone()]
716 .iter()
717 .rposition(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'>'))
718 .map_or(range.start, |index| range.start + index + 1);
719 let accepted = opens_definition(content, range.start);
720 definition_rejected.push(!accepted);
721 if accepted {
722 definition_items.push((item_start, item_end));
723 definition_terms.append(&mut pending_terms);
724 definition_start = Some(range.start);
725 } else {
726 rejected_definition_ranges.push((item_start, item_end));
727 colon_definitions.push(ColonDefinitionDetail {
728 start: item_start,
729 end: item_end,
730 definition_start: range.start,
731 });
732 pending_terms.clear();
733 }
734 }
735 _ => {}
736 }
737 block_stack.push(definition_start);
738 }
739 Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
740 definition_texts.extend(tight_run.take());
741 if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
742 {
743 definition_texts.push(DefinitionTextDetail {
744 start,
745 end: range.end,
746 definition_start,
747 });
748 }
749 if let TagEnd::DefinitionListDefinition = tag_end {
750 definition_rejected.pop();
751 }
752 if let TagEnd::DefinitionList = tag_end {
753 pending_terms.clear();
754 }
755 block_stack.pop();
756 }
757 _ => {
760 if let Some(Some(definition_start)) = block_stack.last() {
761 match &mut tight_run {
762 Some(run) => run.end = run.end.max(range.end),
763 None => {
764 tight_run = Some(DefinitionTextDetail {
765 start: range.start,
766 end: range.end,
767 definition_start: *definition_start,
768 });
769 }
770 }
771 }
772 }
773 }
774 if std::mem::take(&mut awaiting_first_code_event)
775 && let Event::Text(text) = &event
776 && range.is_empty()
777 && let Some((_, _, _, Some(column))) = &mut code_block_start
778 {
779 *column = column.saturating_sub(text.len());
780 }
781 if code_block_start.is_some()
782 && let Event::Text(text) = &event
783 && !text.is_empty()
784 {
785 literal_newlines += text.matches('\n').count();
786 literal_partial_line = !text.ends_with('\n');
787 }
788 match event {
789 Event::Start(Tag::CodeBlock(kind)) => {
790 literal_newlines = 0;
791 literal_partial_line = false;
792 let (is_fenced, info_string) = match &kind {
793 CodeBlockKind::Fenced(info) => (true, info.to_string()),
794 CodeBlockKind::Indented => (false, String::new()),
795 };
796 let mut content_column =
797 (!is_fenced).then(|| visual_column(content, range.start).saturating_sub(4));
798 if !is_fenced && definition_rejected.contains(&true) {
799 if !block_stack.iter().any(Option::is_some) {
802 text_reading.push(range.start);
803 }
804 content_column = None;
805 }
806 awaiting_first_code_event = !is_fenced;
807 code_block_enclosing = if is_fenced {
808 open_containers.iter().map(|container| container.0).collect()
809 } else {
810 Vec::new()
811 };
812 code_block_start = Some((range.start, is_fenced, info_string, content_column));
813 }
814 Event::End(TagEnd::CodeBlock) => {
815 if let Some((start, is_fenced, info_string, content_column)) = code_block_start.take() {
816 blocks.push((start, range.end));
817 let literal_lines = literal_newlines + usize::from(literal_partial_line);
818 details.push(CodeBlockDetail {
819 start,
820 end: range.end,
821 is_fenced,
822 info_string,
823 content_column,
824 closed: is_fenced && fenced_block_is_closed(content, start, range.end, literal_lines),
825 enclosing: std::mem::take(&mut code_block_enclosing),
826 });
827 }
828 }
829 Event::Start(Tag::Strong) => {
830 if range.start + 2 <= content.len() {
831 let is_asterisk = &content[range.start..range.start + 2] == "**";
832 strong_spans.push(StrongSpanDetail {
833 start: range.start,
834 end: range.end,
835 is_asterisk,
836 });
837 }
838 }
839 Event::Start(Tag::List(start_num)) => {
840 let is_ordered = start_num.is_some();
841 let start_value = start_num.unwrap_or(1);
842 list_stack.push((next_list_id, is_ordered, start_value));
843 if is_ordered {
844 list_start_values.insert(next_list_id, start_value);
845 }
846 next_list_id += 1;
847 }
848 Event::End(TagEnd::List(_)) => {
849 list_stack.pop();
850 }
851 Event::Start(Tag::Item) => {
852 if let Some(&(list_id, is_ordered, _)) = list_stack.last()
853 && is_ordered
854 {
855 let line_num = byte_to_line(range.start);
856 line_to_list.insert(line_num, list_id);
857 }
858 }
859 Event::Start(Tag::HtmlBlock) => {
860 html_blocks.push((range.start, range.end));
862 }
863 Event::Code(_) => {
864 spans.push((range.start, range.end));
865 }
866 _ => {}
867 }
868 }
869
870 if let Some((start, is_fenced, info_string, content_column)) = code_block_start {
873 blocks.push((start, content.len()));
874 details.push(CodeBlockDetail {
875 start,
876 end: content.len(),
877 is_fenced,
878 info_string,
879 content_column,
880 closed: false,
881 enclosing: std::mem::take(&mut code_block_enclosing),
882 });
883 }
884
885 if !text_reading.is_empty() {
886 let text_blocks = indented_block_columns(content, options - Options::ENABLE_DEFINITION_LIST);
887 for detail in details.iter_mut().filter(|d| text_reading.contains(&d.start)) {
888 let lines = (byte_to_line(detail.start), byte_to_line(detail.end.saturating_sub(1)));
889 detail.content_column = text_blocks
890 .iter()
891 .find(|&&(start, end, _)| (byte_to_line(start), byte_to_line(end.saturating_sub(1))) == lines)
892 .map(|&(_, _, column)| column);
893 }
894 }
895
896 if !colon_definitions.is_empty() {
899 let as_definitions = inline_ranges(content, options);
900 let as_text = inline_ranges(content, options - Options::ENABLE_DEFINITION_LIST);
901 let mut definition_ranges = InlineRangeCursor::new(&as_definitions);
902 let mut text_ranges = InlineRangeCursor::new(&as_text);
903 colon_definitions.retain(|detail| {
904 !definition_ranges
905 .overlapping(detail)
906 .eq(text_ranges.overlapping(detail))
907 });
908 }
909
910 blocks.sort_by_key(|&(start, _)| start);
912 spans.sort_by_key(|&(start, _)| start);
913 details.sort_by_key(|d| d.start);
914 strong_spans.sort_by_key(|s| s.start);
915 html_blocks.sort_by_key(|&(start, _)| start);
916 ParseResult {
917 definition_items,
918 definition_terms,
919 definition_texts,
920 colon_definitions,
921 code_blocks: blocks,
922 code_spans: spans,
923 code_block_details: details,
924 strong_spans,
925 line_to_list,
926 list_start_values,
927 html_blocks,
928 table_ranges,
929 top_level_blocks,
930 rejected_definition_ranges,
931 list_items: list_item_spans
932 .into_iter()
933 .filter_map(|(start, end, content)| content.map(|content| (start, end, content)))
934 .collect(),
935 }
936 }
937
938 pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
940 Self::is_in_code_block(blocks, pos)
941 }
942
943 pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
949 let idx = blocks.partition_point(|&(start, _)| start <= pos);
951 idx > 0 && pos < blocks[idx - 1].1
954 }
955
956 pub fn analyze_code_block_context(
959 lines: &[crate::lint_context::LineInfo],
960 line_idx: usize,
961 min_continuation_indent: usize,
962 ) -> CodeBlockContext {
963 if let Some(line_info) = lines.get(line_idx) {
964 if line_info.indent >= min_continuation_indent {
966 return CodeBlockContext::Indented;
967 }
968
969 let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
971
972 if prev_blanks > 0 || next_blanks > 0 {
975 return CodeBlockContext::Standalone;
976 }
977
978 CodeBlockContext::Adjacent
980 } else {
981 CodeBlockContext::Adjacent
983 }
984 }
985
986 fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
988 let mut prev_blanks = 0;
989 let mut next_blanks = 0;
990
991 for i in (0..line_idx).rev() {
993 if let Some(line) = lines.get(i) {
994 if line.is_blank {
995 prev_blanks += 1;
996 } else {
997 break;
998 }
999 } else {
1000 break;
1001 }
1002 }
1003
1004 for i in (line_idx + 1)..lines.len() {
1006 if let Some(line) = lines.get(i) {
1007 if line.is_blank {
1008 next_blanks += 1;
1009 } else {
1010 break;
1011 }
1012 } else {
1013 break;
1014 }
1015 }
1016
1017 (prev_blanks, next_blanks)
1018 }
1019
1020 pub fn calculate_min_continuation_indent(
1023 content: &str,
1024 lines: &[crate::lint_context::LineInfo],
1025 current_line_idx: usize,
1026 ) -> usize {
1027 for i in (0..current_line_idx).rev() {
1029 if let Some(line_info) = lines.get(i)
1030 && let Some(indent) = Self::continuation_indent_boundary(content, line_info)
1031 {
1032 return indent;
1033 }
1034 }
1035 0
1036 }
1037
1038 pub(crate) fn continuation_indent_boundary(
1042 content: &str,
1043 line_info: &crate::lint_context::LineInfo,
1044 ) -> Option<usize> {
1045 if let Some(list_item) = &line_info.list_item {
1046 Some(if list_item.is_ordered {
1047 list_item.marker_column + list_item.marker.len() + 1
1048 } else {
1049 list_item.marker_column + 2
1050 })
1051 } else if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
1052 Some(0)
1053 } else {
1054 None
1055 }
1056 }
1057
1058 fn is_structural_separator(content: &str) -> bool {
1060 let trimmed = content.trim();
1061 trimmed.starts_with("---")
1062 || trimmed.starts_with("***")
1063 || trimmed.starts_with("___")
1064 || crate::utils::skip_context::is_table_line(trimmed)
1065 || trimmed.starts_with('>') }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 #[test]
1072 fn test_continued_depth_is_the_longest_prefix_strip_enclosure_accepts() {
1073 use super::{Enclosure, continued_depth, item_runs, strip_enclosure};
1074 let quote = Enclosure::Quote { end: 0 };
1075 let item = |width| Enclosure::Item { width, end: 0 };
1076 let stacks = [
1077 vec![quote, item(2), item(2), quote, item(3)],
1078 vec![item(2), quote, quote, item(4)],
1079 vec![item(2), item(2), item(2)],
1080 vec![quote, Enclosure::Opaque { end: 0 }, item(2)],
1081 ];
1082 let lines = [
1083 "",
1084 " ",
1085 "\t",
1086 ">",
1087 "> ",
1088 ">\t",
1089 "> ",
1090 "> > x",
1091 "> x",
1092 " > > x",
1093 " >",
1094 " > ",
1095 " x",
1096 " x",
1097 " x",
1098 "\tx",
1099 " \t x",
1100 "lazy",
1101 "> > > x",
1102 "> > x",
1103 ];
1104 for stack in &stacks {
1105 let runs = item_runs(stack);
1106 for line in lines {
1107 let expected = (0..=stack.len())
1108 .rev()
1109 .find(|&k| strip_enclosure(line, &stack[..k]).is_some())
1110 .unwrap();
1111 assert_eq!(
1112 continued_depth(line, stack, &runs),
1113 expected,
1114 "line {line:?} in {stack:?}"
1115 );
1116 }
1117 }
1118 }
1119
1120 #[test]
1121 fn test_items_opening_on_one_line_through_a_quote_record_their_widths() {
1122 use super::Enclosure;
1123 let quote = Enclosure::Quote { end: 0 };
1124 let item = |width| Enclosure::Item { width, end: 0 };
1125 let cases = [
1126 ("- > - ```md\n > x\n > ```\n", vec![item(2), quote, item(2)]),
1127 (
1128 "> - > - ```md\n> > x\n> > ```\n",
1129 vec![quote, item(2), quote, item(2)],
1130 ),
1131 ("1. > - ```md\n > x\n > ```\n", vec![item(3), quote, item(2)]),
1132 ("- > 10. ```md\n > x\n > ```\n", vec![item(2), quote, item(4)]),
1133 ("- >\t- ```md\n >\t x\n >\t ```\n", vec![item(2), quote, item(2)]),
1134 (
1135 "- > - - ```md\n > x\n > ```\n",
1136 vec![item(2), quote, item(2), item(2)],
1137 ),
1138 (
1139 "- > > - ```md\n > > x\n > > ```\n",
1140 vec![item(2), quote, quote, item(2)],
1141 ),
1142 ];
1143 for (content, expected) in cases {
1144 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1145 let enclosing: Vec<Enclosure> = parsed.code_block_details[0]
1146 .enclosing
1147 .iter()
1148 .map(|&enclosure| match enclosure {
1149 Enclosure::Quote { .. } => quote,
1150 Enclosure::Item { width, .. } => item(width),
1151 Enclosure::Opaque { .. } => Enclosure::Opaque { end: 0 },
1152 })
1153 .collect();
1154 assert_eq!(enclosing, expected, "{content:?}");
1155 }
1156 }
1157
1158 #[test]
1159 fn test_table_ranges_and_top_level_blocks_are_recorded() {
1160 let content = "para\n\n|a|b|\n|-|-|\n|1|2|\n\n- item\n\n |c|d|\n |-|-|\n";
1161 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1162 let text = |&(start, end): &(usize, usize)| &content[start..end];
1163
1164 assert_eq!(parsed.table_ranges.len(), 2);
1165 assert_eq!(text(&parsed.table_ranges[0]), "|a|b|\n|-|-|\n|1|2|\n");
1166 assert!(text(&parsed.table_ranges[1]).starts_with("|c|d|"));
1167
1168 let top: Vec<&str> = parsed.top_level_blocks.iter().map(text).collect();
1171 assert_eq!(top.len(), 3, "{top:?}");
1172 assert_eq!(top[0], "para\n");
1173 assert!(top[2].starts_with("- item"));
1174 }
1175
1176 #[test]
1177 fn test_list_items_record_where_their_content_starts() {
1178 let content = "- - x\n\n- y\n-\n- \n z\n";
1179 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1180 let starts: Vec<(&str, &str)> = parsed
1181 .list_items
1182 .iter()
1183 .map(|&(start, _, content_start)| (&content[start..=start], &content[content_start..=content_start]))
1184 .collect();
1185 assert_eq!(starts, [("-", "-"), ("-", "x"), ("-", "y"), ("-", "z")]);
1187 }
1188
1189 #[test]
1190 fn test_no_table_ranges_without_a_table() {
1191 let parsed = CodeBlockUtils::detect_code_blocks_and_spans("a|b\n|-|\n\n |a|b|\n |-|-|\n");
1192 assert!(parsed.table_ranges.is_empty());
1193 }
1194
1195 use super::*;
1196
1197 #[test]
1198 fn test_detect_fenced_code_blocks() {
1199 let content = "Some text\n```\ncode here\n```\nMore text";
1204 let blocks = CodeBlockUtils::detect_code_blocks(content);
1205 assert_eq!(blocks.len(), 1);
1207
1208 let fenced_block = blocks
1210 .iter()
1211 .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
1212 assert!(fenced_block.is_some());
1213
1214 let content = "Some text\n~~~\ncode here\n~~~\nMore text";
1216 let blocks = CodeBlockUtils::detect_code_blocks(content);
1217 assert_eq!(blocks.len(), 1);
1218 assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
1219
1220 let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
1222 let blocks = CodeBlockUtils::detect_code_blocks(content);
1223 assert_eq!(blocks.len(), 2);
1225 }
1226
1227 #[test]
1228 fn test_detect_code_blocks_with_language() {
1229 let content = "Text\n```rust\nfn main() {}\n```\nMore";
1231 let blocks = CodeBlockUtils::detect_code_blocks(content);
1232 assert_eq!(blocks.len(), 1);
1234 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
1236 assert!(fenced.is_some());
1237 }
1238
1239 #[test]
1240 fn test_unclosed_code_block() {
1241 let content = "Text\n```\ncode here\nno closing fence";
1243 let blocks = CodeBlockUtils::detect_code_blocks(content);
1244 assert_eq!(blocks.len(), 1);
1245 assert_eq!(blocks[0].1, content.len());
1246 }
1247
1248 #[test]
1249 fn test_indented_code_blocks() {
1250 let content = "Paragraph\n\n code line 1\n code line 2\n\nMore text";
1252 let blocks = CodeBlockUtils::detect_code_blocks(content);
1253 assert_eq!(blocks.len(), 1);
1254 assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
1255 assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
1256
1257 let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
1259 let blocks = CodeBlockUtils::detect_code_blocks(content);
1260 assert_eq!(blocks.len(), 1);
1261 }
1262
1263 #[test]
1264 fn test_indented_code_requires_blank_line() {
1265 let content = "Paragraph\n indented but not code\nMore text";
1267 let blocks = CodeBlockUtils::detect_code_blocks(content);
1268 assert_eq!(blocks.len(), 0);
1269
1270 let content = "Paragraph\n\n now it's code\nMore text";
1272 let blocks = CodeBlockUtils::detect_code_blocks(content);
1273 assert_eq!(blocks.len(), 1);
1274 }
1275
1276 #[test]
1277 fn test_indented_content_with_list_markers_is_code_block() {
1278 let content = "List:\n\n - Item 1\n - Item 2\n * Item 3\n + Item 4";
1283 let blocks = CodeBlockUtils::detect_code_blocks(content);
1284 assert_eq!(blocks.len(), 1); let content = "List:\n\n 1. First\n 2. Second";
1288 let blocks = CodeBlockUtils::detect_code_blocks(content);
1289 assert_eq!(blocks.len(), 1); }
1291
1292 #[test]
1293 fn test_actual_list_items_not_code_blocks() {
1294 let content = "- Item 1\n- Item 2\n* Item 3";
1296 let blocks = CodeBlockUtils::detect_code_blocks(content);
1297 assert_eq!(blocks.len(), 0);
1298
1299 let content = "- Item 1\n - Nested item\n- Item 2";
1301 let blocks = CodeBlockUtils::detect_code_blocks(content);
1302 assert_eq!(blocks.len(), 0);
1303 }
1304
1305 #[test]
1306 fn test_inline_code_spans_not_detected() {
1307 let content = "Text with `inline code` here";
1309 let blocks = CodeBlockUtils::detect_code_blocks(content);
1310 assert_eq!(blocks.len(), 0); let content = "Text with ``code with ` backtick`` here";
1314 let blocks = CodeBlockUtils::detect_code_blocks(content);
1315 assert_eq!(blocks.len(), 0); let content = "Has `code1` and `code2` spans";
1319 let blocks = CodeBlockUtils::detect_code_blocks(content);
1320 assert_eq!(blocks.len(), 0); }
1322
1323 #[test]
1324 fn test_unclosed_code_span() {
1325 let content = "Text with `unclosed code span";
1327 let blocks = CodeBlockUtils::detect_code_blocks(content);
1328 assert_eq!(blocks.len(), 0);
1329
1330 let content = "Text with ``one style` different close";
1332 let blocks = CodeBlockUtils::detect_code_blocks(content);
1333 assert_eq!(blocks.len(), 0);
1334 }
1335
1336 #[test]
1337 fn test_mixed_code_blocks_and_spans() {
1338 let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
1339 let blocks = CodeBlockUtils::detect_code_blocks(content);
1340 assert_eq!(blocks.len(), 1);
1342
1343 assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
1345 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
1347 assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
1348 }
1349
1350 #[test]
1351 fn test_is_in_code_block_or_span() {
1352 let blocks = vec![(10, 20), (30, 40), (50, 60)];
1353
1354 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
1356 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
1357 assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
1358
1359 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));
1365 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
1366 assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
1367 }
1368
1369 #[test]
1370 fn test_empty_content() {
1371 let blocks = CodeBlockUtils::detect_code_blocks("");
1372 assert_eq!(blocks.len(), 0);
1373 }
1374
1375 #[test]
1376 fn test_code_block_at_start() {
1377 let content = "```\ncode\n```\nText after";
1378 let blocks = CodeBlockUtils::detect_code_blocks(content);
1379 assert_eq!(blocks.len(), 1);
1381 assert_eq!(blocks[0].0, 0); }
1383
1384 #[test]
1385 fn test_code_block_at_end() {
1386 let content = "Text before\n```\ncode\n```";
1387 let blocks = CodeBlockUtils::detect_code_blocks(content);
1388 assert_eq!(blocks.len(), 1);
1390 let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
1392 assert!(fenced.is_some());
1393 }
1394
1395 #[test]
1396 fn test_nested_fence_markers() {
1397 let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1399 let blocks = CodeBlockUtils::detect_code_blocks(content);
1400 assert!(!blocks.is_empty());
1402 let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
1404 assert!(outer.is_some());
1405 }
1406
1407 #[test]
1408 fn test_indented_code_with_blank_lines() {
1409 let content = "Text\n\n line1\n\n line2\n\nAfter";
1411 let blocks = CodeBlockUtils::detect_code_blocks(content);
1412 assert!(!blocks.is_empty());
1414 let all_content: String = blocks
1416 .iter()
1417 .map(|(s, e)| &content[*s..*e])
1418 .collect::<Vec<_>>()
1419 .join("");
1420 assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
1421 }
1422
1423 #[test]
1424 fn test_code_span_with_spaces() {
1425 let content = "Text ` code with spaces ` more";
1427 let blocks = CodeBlockUtils::detect_code_blocks(content);
1428 assert_eq!(blocks.len(), 0); }
1430
1431 #[test]
1432 fn test_fenced_block_with_info_string() {
1433 let content = "```rust,no_run,should_panic\ncode\n```";
1435 let blocks = CodeBlockUtils::detect_code_blocks(content);
1436 assert_eq!(blocks.len(), 1);
1438 assert_eq!(blocks[0].0, 0);
1439 }
1440
1441 #[test]
1442 fn test_indented_fences_not_code_blocks() {
1443 let content = "Text\n ```\n code\n ```\nAfter";
1445 let blocks = CodeBlockUtils::detect_code_blocks(content);
1446 assert_eq!(blocks.len(), 1);
1448 }
1449
1450 #[test]
1452 fn test_backticks_in_info_string_not_code_block() {
1453 let content = "```something```\n\n```bash\n# comment\n```";
1459 let blocks = CodeBlockUtils::detect_code_blocks(content);
1460 assert_eq!(blocks.len(), 1);
1462 assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1464 }
1465
1466 #[test]
1467 fn test_issue_175_reproduction() {
1468 let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐ฆ\"\n```";
1470 let blocks = CodeBlockUtils::detect_code_blocks(content);
1471 assert_eq!(blocks.len(), 1);
1473 assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
1474 }
1475
1476 #[test]
1477 fn test_tilde_fence_allows_tildes_in_info_string() {
1478 let content = "~~~abc~~~\ncode content\n~~~";
1481 let blocks = CodeBlockUtils::detect_code_blocks(content);
1482 assert_eq!(blocks.len(), 1);
1484 }
1485
1486 #[test]
1487 fn test_nested_longer_fence_contains_shorter() {
1488 let content = "````\n```\nnested content\n```\n````";
1490 let blocks = CodeBlockUtils::detect_code_blocks(content);
1491 assert_eq!(blocks.len(), 1);
1492 assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
1493 }
1494
1495 #[test]
1496 fn test_mixed_fence_types() {
1497 let content = "~~~\n```\nmixed content\n~~~";
1499 let blocks = CodeBlockUtils::detect_code_blocks(content);
1500 assert_eq!(blocks.len(), 1);
1501 assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
1502 }
1503
1504 #[test]
1505 fn test_indented_block_content_column_is_its_containers() {
1506 let cases = [
1507 ("Text\n\n code\n", 0),
1508 ("- a\n\n code\n", 2),
1509 ("1. a\n\n code\n", 3),
1510 ("1. a\n\nb\n\n code\n", 0),
1512 ("1. a\nb\nc\nd\ne\nf\ng\n\n code\n", 3),
1514 ("> quoted\n", 2),
1515 ("- a\n\n\t code\n", 2),
1516 ("1. a\n\n\t\tcode\n", 3),
1518 (">\t\tcode\n", 2),
1519 ];
1520 for (content, expected) in cases {
1521 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1522 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1523 assert_eq!(columns, [Some(expected)], "content column for {content:?}");
1524 }
1525
1526 let fenced = CodeBlockUtils::detect_code_blocks_and_spans("- a\n\n ```\n code\n ```\n");
1527 let columns: Vec<_> = fenced.code_block_details.iter().map(|d| d.content_column).collect();
1528 assert_eq!(columns, [None]);
1529 }
1530
1531 #[test]
1532 fn test_indented_block_in_rejected_definition_takes_its_columns_from_the_text_reading() {
1533 let cases = [
1534 ("term\n:warning:\n\n x\n", Some(0)),
1536 ("- a\n\n term\n :warning:\n\n x\n", Some(2)),
1537 ("term\n: def\n\n x\n", Some(2)),
1539 ("term\n: def\n\n t2\n :warning:\n\n x\n", None),
1542 ];
1543 for (content, expected) in cases {
1544 let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1545 let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1546 assert_eq!(columns, [expected], "content column for {content:?}");
1547 }
1548 }
1549
1550 #[test]
1551 fn definition_marker_inside_utf8_keeps_the_rejected_source_reading() {
1552 for newline in ["\n", "\r", "\r\n"] {
1553 for term in ["", "a", "ยก", "๐ฆ"] {
1554 for text in ["b", "\u{e000}", "๐ฆ"] {
1555 for tail in ["\t:", "\t: next", "\t: *next*", "\t: `next`"] {
1556 let source = format!("{term}{newline}:{text}{newline}{tail}");
1557 let result = CodeBlockUtils::detect_code_blocks_and_spans(&source);
1558 assert!(result.definition_items.is_empty(), "{source:?}");
1559 assert!(result.definition_texts.is_empty(), "{source:?}");
1560 for &(start, end) in &result.rejected_definition_ranges {
1561 assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1562 }
1563 let ctx = crate::lint_context::LintContext::new(
1564 &source,
1565 crate::config::MarkdownFlavor::Standard,
1566 None,
1567 );
1568 assert_eq!(ctx.content, source);
1569 }
1570 }
1571 }
1572 }
1573 assert!(!super::opens_definition("ยก", 1));
1574 assert!(!super::opens_definition(": text", usize::MAX));
1575 assert!(super::opens_definition("\t: text", 0));
1576 }
1577
1578 #[test]
1579 fn test_indented_code_in_list_issue_276() {
1580 let content = r#"1. First item
15822. Second item with code:
1583
1584 # This is a code block in a list
1585 print("Hello, world!")
1586
15874. Third item"#;
1588
1589 let blocks = CodeBlockUtils::detect_code_blocks(content);
1590 assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1592
1593 let all_content: String = blocks
1595 .iter()
1596 .map(|(s, e)| &content[*s..*e])
1597 .collect::<Vec<_>>()
1598 .join("");
1599 assert!(
1600 all_content.contains("code block in a list") || all_content.contains("print"),
1601 "Detected block should contain the code content: {all_content:?}"
1602 );
1603 }
1604
1605 #[test]
1608 fn test_is_in_code_block_empty_blocks() {
1609 assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1610 assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1611 assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1612 }
1613
1614 #[test]
1615 fn test_is_in_code_block_single_range() {
1616 let blocks = [(10, 20)];
1617 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1618 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1619 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1620 assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1621 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1622 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1624 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1625 }
1626
1627 #[test]
1628 fn test_is_in_code_block_multiple_ranges() {
1629 let blocks = [(5, 10), (20, 30), (50, 60)];
1630 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1632 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1633 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1635 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1636 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1638 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1639 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1640 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1642 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1643 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1645 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1646 assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1648 assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1649 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1651 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1652 }
1653
1654 #[test]
1655 fn test_is_in_code_block_adjacent_ranges() {
1656 let blocks = [(0, 10), (10, 20), (20, 30)];
1658 assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1659 assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1660 assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1661 assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1662 assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1663 assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1664 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1665 }
1666
1667 #[test]
1668 fn test_is_in_code_block_single_byte_range() {
1669 let blocks = [(5, 6)];
1670 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1671 assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1672 assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1673 }
1674
1675 #[test]
1676 fn test_is_in_code_block_matches_linear_scan() {
1677 let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1680 let blocks = CodeBlockUtils::detect_code_blocks(content);
1681
1682 for pos in 0..content.len() {
1683 let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1684 let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1685 assert_eq!(
1686 binary, linear,
1687 "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1688 );
1689 }
1690 }
1691
1692 #[test]
1693 fn test_is_in_code_block_at_range_boundaries() {
1694 let blocks = [(100, 200), (300, 400), (500, 600)];
1696 for &(start, end) in &blocks {
1697 assert!(
1698 !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1699 "pos={} should be outside",
1700 start - 1
1701 );
1702 assert!(
1703 CodeBlockUtils::is_in_code_block(&blocks, start),
1704 "pos={start} should be inside"
1705 );
1706 assert!(
1707 CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1708 "pos={} should be inside",
1709 end - 1
1710 );
1711 assert!(
1712 !CodeBlockUtils::is_in_code_block(&blocks, end),
1713 "pos={end} should be outside"
1714 );
1715 }
1716 }
1717}
1718
1719#[cfg(test)]
1723mod closed_fence_tests {
1724 use super::*;
1725
1726 fn closed_of(content: &str) -> Vec<bool> {
1727 CodeBlockUtils::detect_code_blocks_and_spans(content)
1728 .code_block_details
1729 .iter()
1730 .filter(|detail| detail.is_fenced)
1731 .map(|detail| detail.closed)
1732 .collect()
1733 }
1734
1735 #[test]
1736 fn a_block_ended_by_a_closing_fence_is_closed() {
1737 for content in [
1738 "```\nx\n```\n",
1739 "```\nx\n```",
1740 "```\n```\n",
1741 "~~~\nx\n~~~\n",
1742 "````\nx\n`````\n",
1743 "```\nx\n``` \n",
1744 "> ```\n> x\n> ```\nfoo\n",
1745 "> ```\n> ```\nfoo\n",
1746 "-\n ```\n x\n ```\n",
1747 "- ```\n x\n\n ```\n",
1748 "```\n\n\n```\n",
1749 " ```\nx\n ```\n",
1750 ] {
1751 assert_eq!(closed_of(content), vec![true], "{content:?}");
1752 }
1753 }
1754
1755 #[test]
1756 fn a_block_the_document_or_container_ends_is_not_closed() {
1757 for content in [
1758 "```\nx\n",
1759 "```\nx",
1760 "```",
1761 "```\n",
1762 "> ```\nfoo\n",
1763 "> ```\n> x\nfoo\n",
1764 "> ```\n> ~~~\nfoo\n",
1766 "```\n~~~\n",
1767 "> ````\n> ```\nfoo\n",
1768 "> ```\n> ```\nfoo\n",
1769 "```\n ```\n",
1770 "> ```\n> \u{a0}```\nfoo\n",
1771 "> ```\n> ```x\nfoo\n",
1772 "> ```\n> ```\u{a0}\nfoo\n",
1773 "> ```\n> ",
1776 "```\n ",
1777 "> ```\n> ~~~\n> ",
1778 "- ```\nfoo\n",
1779 ] {
1780 assert_eq!(closed_of(content), vec![false], "{content:?}");
1781 }
1782 }
1783}