Skip to main content

rumdl_lib/utils/
code_block_utils.rs

1//!
2//! Utility functions for detecting and handling code blocks and code spans in Markdown for rumdl.
3//!
4//! Code block detection is delegated to pulldown-cmark, which correctly implements the
5//! CommonMark specification. This handles edge cases like:
6//! - Backtick fences with backticks in the info string (invalid per spec)
7//! - Nested fences (longer fence contains shorter fence as content)
8//! - Mixed fence types (tilde fence contains backticks as content)
9//! - Indented code blocks with proper list context handling
10
11use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
12
13use super::parser_options::rumdl_parser_options_for;
14
15/// Detailed information about a code block captured during parsing
16#[derive(Debug, Clone)]
17pub struct CodeBlockDetail {
18    /// Byte offset where this code block starts
19    pub start: usize,
20    /// Byte offset where this code block ends
21    pub end: usize,
22    /// Whether this is a fenced code block (true) or indented (false)
23    pub is_fenced: bool,
24    /// The info string from fenced blocks (e.g., "rust" from ```rust), empty for indented
25    pub info_string: String,
26    /// For an indented block, the visual column (tabs expanded to multiples of
27    /// four) where its enclosing container's content begins: the document
28    /// margin, a list item's content column, or a blockquote's content. The
29    /// code itself starts four columns past it. `None` for fenced blocks.
30    pub content_column: Option<usize>,
31    /// For a fenced block, whether a closing fence line ends it. A block the
32    /// document or its container ends first is not closed, even when its last
33    /// line looks like a fence (a shorter run, another character, or code
34    /// indented four columns). `false` for indented blocks.
35    pub closed: bool,
36    /// For a fenced block, the containers holding it, outermost first. Empty
37    /// for a block at the document margin and for indented blocks.
38    pub enclosing: Vec<Enclosure>,
39}
40
41/// A container a fenced block sits in, with the extent the parser gave it.
42///
43/// What continues the container on a later line is relative to the container
44/// around it: a quote's marker may be indented differently on every line, and
45/// a list item's indent counts from where its parent's content starts.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Enclosure {
48    /// A blockquote: each line carries a `>` marker.
49    Quote { end: usize },
50    /// A list item: a non-blank line continues it when indented `width`
51    /// columns past the content start of the container around it.
52    Item { width: usize, end: usize },
53    /// A container whose continuation rules are not modelled (definition,
54    /// footnote definition, or an item whose position cannot be derived).
55    Opaque { end: usize },
56}
57
58impl Enclosure {
59    /// Byte offset where the container's range ends.
60    pub fn end(&self) -> usize {
61        match *self {
62            Self::Quote { end } | Self::Item { end, .. } | Self::Opaque { end } => end,
63        }
64    }
65}
66
67/// A line with its container prefixes removed (see [`strip_enclosure`]).
68#[derive(Debug, Clone, Copy, Default)]
69pub struct StrippedLine {
70    /// Byte offset in the line where the unconsumed text starts. When a tab was
71    /// only partly consumed this is past the tab and `pending` holds the rest.
72    pub pos: usize,
73    /// Visual column reached, counting the consumed part of a split tab.
74    pub col: usize,
75    /// Columns of a split tab not yet consumed.
76    pub pending: usize,
77}
78
79impl StrippedLine {
80    /// Columns of indentation left, and the byte offset of the first character
81    /// that is not a space or tab. `None` offset: the rest of the line is blank.
82    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    /// Consume the prefix one container puts on `line`, or `false` when the
131    /// line does not continue it.
132    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
150/// Remove the prefixes the containers in `enclosing` (outermost first) put on
151/// `line`, or `None` when the line does not continue all of them.
152///
153/// A quote takes up to three columns of indentation, `>`, and one optional
154/// column after it. An item takes its `width` columns of indentation; a blank
155/// line continues an item but not a quote. A line that continues nothing (a
156/// lazy line) is `None`: lazy continuation never applies to code.
157pub 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
169/// How many of the containers in `enclosing` (outermost first) `line`
170/// continues: the longest prefix of `enclosing` that `strip_enclosure` accepts.
171///
172/// `item_runs` is `item_runs(enclosing)`. Once the rest of the line is blank,
173/// the items up to the next quote continue it and that quote does not, so the
174/// cost is bounded by the line's length rather than by the nesting depth.
175pub 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
193/// For each container in `enclosing`, the number of consecutive items that
194/// start there.
195pub 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
205/// The width a list item opening at `marker` takes, in columns: the marker, the
206/// whitespace after it when 1 to 4 columns of text follow, otherwise a single
207/// column. Returns where its content starts on the marker line, and that line's
208/// start.
209fn 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        // Nothing follows the marker on an empty item, so no later container
252        // reads the position; its content still starts one column on.
253        _ => {
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
262/// Whether the fenced block spanning `start..end` of `content` ends with a
263/// closing fence line, given how many lines its literal text has.
264///
265/// A closing fence is the one line of a block that is not in its literal text,
266/// so a closed block spans two lines more than its literal holds (opener and
267/// closer) and an unclosed one a single line more. A final line holding only
268/// whitespace is inside the parser's range without a text event of its own, so
269/// the last line must also start with a fence character once quote markers and
270/// whitespace are set aside.
271fn 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    // The range of an unclosed block ends after the newline of its last line.
275    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    // Each newline starts another line, and an empty body is one line.
282    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
291/// Visual column of byte `pos`, counting from the start of its line, with a
292/// tab advancing to the next multiple of four.
293fn 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/// A strong emphasis span captured during parsing
301#[derive(Debug, Clone)]
302pub struct StrongSpanDetail {
303    /// Byte offset where the strong span starts (including **)
304    pub start: usize,
305    /// Byte offset where the strong span ends (including **)
306    pub end: usize,
307    /// Whether this uses asterisk (**) or underscore (__) markers
308    pub is_asterisk: bool,
309}
310
311/// Text a definition holds directly, captured during parsing
312///
313/// One of the definition's paragraphs, or, for a tight definition, the run of
314/// inline content the parser reports with no paragraph around it. Blocks nested
315/// in the definition (lists, blockquotes, code) hold their own text and are not
316/// part of one.
317#[derive(Debug, Clone)]
318pub struct DefinitionTextDetail {
319    /// Byte offset where the text starts
320    pub start: usize,
321    /// Byte offset where the text ends
322    pub end: usize,
323    /// Byte offset where the definition holding the text starts: its `:`
324    /// marker, or indentation before it inside a container
325    pub definition_start: usize,
326}
327
328/// Ordered list membership: maps line number (1-indexed) to list ID
329pub type LineToListMap = std::collections::HashMap<usize, usize>;
330/// Ordered list start values: maps list ID to the start value
331pub type ListStartValues = std::collections::HashMap<usize, u64>;
332
333/// Structure facts from document analysis, capturing data needed by individual rules.
334#[derive(Debug, Default)]
335pub struct ParseResult {
336    /// Code block byte ranges (start, end)
337    pub code_blocks: Vec<(usize, usize)>,
338    /// Inline code span byte ranges (start, end)
339    pub code_spans: Vec<(usize, usize)>,
340    /// Detailed code block info (fenced vs indented, info string)
341    pub code_block_details: Vec<CodeBlockDetail>,
342    /// Strong emphasis span details
343    pub strong_spans: Vec<StrongSpanDetail>,
344    /// Ordered list membership: maps line number (1-indexed) to list ID
345    pub line_to_list: LineToListMap,
346    /// Ordered list start values: maps list ID to start value
347    pub list_start_values: ListStartValues,
348    /// HTML block byte ranges (start, end)
349    ///
350    /// Needed to know how far an unclosed `<!--` reaches. The block ends where
351    /// CommonMark says it does, which is the end of the enclosing container
352    /// rather than the end of the document: an unclosed comment in a blockquote
353    /// stops at the quote, and one in a list item stops at the blank line.
354    pub html_blocks: Vec<(usize, usize)>,
355    /// Definition list item byte ranges (start, end) in document order: a
356    /// definition together with the terms before it, blank lines after it left
357    /// out, items of nested lists too. Only definitions passing
358    /// `opens_definition`, and their terms, count.
359    pub definition_items: Vec<(usize, usize)>,
360    /// Definition list term byte ranges (start, end)
361    pub definition_terms: Vec<(usize, usize)>,
362    /// Text held directly by a definition, in document order
363    pub definition_texts: Vec<DefinitionTextDetail>,
364    /// Definitions failing `opens_definition`, with their terms, in document
365    /// order, where reading them as the paragraph text rumdl takes them for
366    /// changes which inline constructs the text holds. The parse the other
367    /// fields come from reads a definition, which ends every code span, link
368    /// and emphasis running into it.
369    pub colon_definitions: Vec<ColonDefinitionDetail>,
370    /// Byte range of every table the parser reads
371    pub table_ranges: Vec<(usize, usize)>,
372    /// Byte range of every block that is not inside another block, in document
373    /// order: a paragraph, list, blockquote, table, code block, and so on
374    pub top_level_blocks: Vec<(usize, usize)>,
375    /// Byte range (terms included) of every definition failing
376    /// `opens_definition`, before `colon_definitions` drops those whose text
377    /// reads the same either way
378    pub rejected_definition_ranges: Vec<(usize, usize)>,
379    /// Byte range of every list item together with the byte offset where its
380    /// content starts, for items that have content
381    pub list_items: Vec<(usize, usize, usize)>,
382}
383
384/// A definition pulldown-cmark opens on a colon touching its text, together
385/// with the terms before it
386#[derive(Debug, Clone)]
387pub struct ColonDefinitionDetail {
388    /// Byte offset where the first term starts, or the definition when it has none
389    pub start: usize,
390    /// Byte offset where the definition ends, blank lines after it left out
391    pub end: usize,
392    /// Byte offset where the definition starts
393    pub definition_start: usize,
394}
395
396/// Classification of code blocks relative to list contexts
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub enum CodeBlockContext {
399    /// Code block that separates lists (root-level, with blank lines)
400    Standalone,
401    /// Code block that continues a list (properly indented)
402    Indented,
403    /// Code block adjacent to list content (edge case, defaults to non-breaking)
404    Adjacent,
405}
406
407/// Whether a tag opens inline content rather than a block
408fn 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
421/// Whether a tag end closes inline content rather than a block
422fn 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
435/// Whether the definition starting at `start` opens with a colon followed by
436/// whitespace or the end of its line.
437///
438/// That is the marker the definition-list extensions agree on (PHP Markdown
439/// Extra, Python-Markdown, Pandoc). pulldown-cmark also opens a definition on a
440/// colon touching its text, as in `:warning:` or a `:::` fence closing a div,
441/// which those read as prose or as the fence it is.
442fn opens_definition(content: &str, start: usize) -> bool {
443    // Defensively reject offsets inside UTF-8 characters. Before 0.13.5, a
444    // tab crossing a definition's container indent could produce one.
445    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
453/// Every indented code block a parse with `options` finds in `content`, as
454/// its byte range and the content column of the container it sits in: four
455/// columns short of where its code begins, less the columns a tab straddling
456/// that point overshot, which the parser supplies as a leading text event
457/// with an empty range.
458fn 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
486/// The byte ranges of the inline constructs a parse with `options` finds in
487/// `content`, in document order: code spans, math, inline HTML, and the
488/// emphasis, link and image spans holding text.
489fn 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
502/// Read source-ordered inline ranges for source-ordered definition queries.
503/// Keep only earlier ranges that still overlap the current definition, and
504/// inspect later ranges only when they actually fall inside that definition.
505/// This also preserves nested ranges and spans crossing several definitions.
506struct 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
537/// Utility functions for detecting and handling code blocks in Markdown
538pub struct CodeBlockUtils;
539
540impl CodeBlockUtils {
541    /// Detect all code blocks in the content (NOT including inline code spans)
542    ///
543    /// Uses pulldown-cmark for spec-compliant CommonMark parsing. This correctly handles:
544    /// - Fenced code blocks (``` and ~~~)
545    /// - Indented code blocks (4 spaces or tab)
546    /// - Code blocks inside lists, blockquotes, and other containers
547    /// - Edge cases like backticks in info strings (which invalidate the fence)
548    ///
549    /// Returns a sorted vector of (start, end) byte offset tuples.
550    pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
551        Self::detect_code_blocks_and_spans(content).code_blocks
552    }
553
554    /// Returns code block ranges, inline code span ranges, and detailed code block info
555    /// in a single pulldown-cmark pass.
556    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    /// Collect structure from an existing parse, sharing its events and source
570    /// line index with other document consumers. Recovery parses still use
571    /// their own options where rumdl's reading differs from the primary parse.
572    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        // The literal text of the code block being read, as a line count: the
585        // newlines seen, and whether the text so far ends in a partial line.
586        let mut literal_newlines = 0usize;
587        let mut literal_partial_line = false;
588        // Set while the event right after an indented block's start is unseen.
589        // When a tab straddles the column where the code begins, the parser
590        // starts the block after the tab and supplies the columns it overshot
591        // as a leading text event with an empty range.
592        let mut awaiting_first_code_event = false;
593        // One entry per open definition: whether `opens_definition` rejects
594        // it. The parser reads a rejected definition as a container, rumdl as
595        // the text it is, so an indented block inside one takes its content
596        // column from a parse without definition lists (`text_reading`).
597        let mut definition_rejected: Vec<bool> = Vec::new();
598        let mut text_reading = Vec::new();
599
600        // Definition structure. `block_stack` holds one entry per open block,
601        // the definition's start for a definition and `None` for anything else,
602        // so the innermost entry says whether content belongs to a definition
603        // directly. Inline tags never touch it.
604        //
605        // Only a definition passing `opens_definition` counts, and a term
606        // waits in `pending_terms` until a definition following it does. Extents
607        // are recorded per item rather than per list: the parser's range for a
608        // list can run over the paragraph after it, and a list can hold items
609        // that do not count between items that do.
610        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        // Containers open at the current event, outermost first, each with the
621        // start of the line it opened on and, for an item, where its content
622        // starts there.
623        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        // List membership tracking for ordered lists
630        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(); // (list_id, is_ordered, start_value)
633        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                            // A parent item that opened on this same line has
660                            // its marker in the prefix, so the prefix is read
661                            // from where that item's content starts.
662                            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                            // The definition's range takes in the blank lines after
709                            // it, quoted ones too, which belong to no block.
710                            let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
711                            // Only ASCII suffix bytes are removed, remaining
712                            // safe even if a parser regression reports a marker
713                            // start inside a UTF-8 character.
714                            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                // Inline content directly under a definition is a tight
757                // definition's text, which the parser does not wrap in a paragraph.
758                _ => {
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                        // Dropping definition lists would also drop an accepted
799                        // definition around the block, so its column is unknown.
800                        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                    // The start event's range already spans the whole block.
860                    html_blocks.push((range.start, range.end));
861                }
862                Event::Code(_) => {
863                    spans.push((range.start, range.end));
864                }
865                _ => {}
866            }
867        }
868
869        // Handle edge case: unclosed code block at end of content
870        // pulldown-cmark should handle this, but be defensive
871        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        // A colon-led definition whose text holds the same inline constructs
896        // either way is read the same by both, and drops out.
897        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        // Sort by start position (should already be sorted, but ensure consistency)
910        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    /// Check if a position is within a code block (for compatibility)
938    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    /// Check if a byte position falls within any of the given sorted, non-overlapping ranges.
943    ///
944    /// Uses binary search on the sorted block ranges for O(log n) lookup.
945    /// The blocks slice must be sorted by start position (as returned by
946    /// `detect_code_blocks` and `detect_code_blocks_and_spans`).
947    pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
948        // Binary search: find the last block whose start <= pos
949        let idx = blocks.partition_point(|&(start, _)| start <= pos);
950        // partition_point returns the first index where start > pos,
951        // so the candidate is at idx - 1
952        idx > 0 && pos < blocks[idx - 1].1
953    }
954
955    /// Analyze code block context relative to list parsing
956    /// This is the core function implementing Design #3's three-tier classification
957    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            // Rule 1: Indentation Analysis - Is it sufficiently indented for list continuation?
964            if line_info.indent >= min_continuation_indent {
965                return CodeBlockContext::Indented;
966            }
967
968            // Rule 2: Blank Line Context - Check for structural separation indicators
969            let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
970
971            // Rule 3: Standalone Detection - Insufficient indentation + blank line separation
972            // This is the key fix: root-level code blocks with blank lines separate lists
973            if prev_blanks > 0 || next_blanks > 0 {
974                return CodeBlockContext::Standalone;
975            }
976
977            // Rule 4: Default - Adjacent (conservative, non-breaking for edge cases)
978            CodeBlockContext::Adjacent
979        } else {
980            // Fallback for invalid line index
981            CodeBlockContext::Adjacent
982        }
983    }
984
985    /// Count blank lines before and after the given line index
986    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        // Count blank lines before (look backwards)
991        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        // Count blank lines after (look forwards)
1004        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    /// Calculate minimum indentation required for code block to continue a list
1020    /// Based on the most recent list item's marker width
1021    pub fn calculate_min_continuation_indent(
1022        content: &str,
1023        lines: &[crate::lint_context::LineInfo],
1024        current_line_idx: usize,
1025    ) -> usize {
1026        // Look backwards to find the most recent list item or separator.
1027        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    /// A list marker sets the continuation indent; a structural separator
1038    /// clears it. Other lines preserve the previous value. Marker precedence
1039    /// matters for list lines whose content also resembles a separator.
1040    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    /// Check if content is a structural separator (headings, horizontal rules, etc.)
1058    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('>') // Blockquotes
1065    }
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        // The paragraph, the first table and the list are top level; the table
1168        // inside the list item is not.
1169        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        // Outer item holds the inner marker; the empty `-` item has no content.
1185        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        // The function detects fenced blocks and inline code spans
1199        // Fence markers (``` at line start) are now skipped in inline span detection
1200
1201        // Basic fenced code block with backticks
1202        let content = "Some text\n```\ncode here\n```\nMore text";
1203        let blocks = CodeBlockUtils::detect_code_blocks(content);
1204        // Should find: 1 fenced block (fences are no longer detected as inline spans)
1205        assert_eq!(blocks.len(), 1);
1206
1207        // Check that we have the fenced block
1208        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        // Fenced code block with tildes (no inline code detection for ~)
1214        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        // Multiple code blocks
1220        let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
1221        let blocks = CodeBlockUtils::detect_code_blocks(content);
1222        // 2 fenced blocks (fence markers no longer detected as inline spans)
1223        assert_eq!(blocks.len(), 2);
1224    }
1225
1226    #[test]
1227    fn test_detect_code_blocks_with_language() {
1228        // Code block with language identifier
1229        let content = "Text\n```rust\nfn main() {}\n```\nMore";
1230        let blocks = CodeBlockUtils::detect_code_blocks(content);
1231        // 1 fenced block (fence markers no longer detected as inline spans)
1232        assert_eq!(blocks.len(), 1);
1233        // Check we have the full fenced block
1234        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        // Unclosed code block should extend to end of content
1241        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        // Basic indented code block
1250        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        // Indented code with tabs
1257        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        // Indented lines without preceding blank line are not code blocks
1265        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        // With blank line, it becomes a code block
1270        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        // Per CommonMark spec: 4-space indented content after blank line IS a code block,
1278        // even if the content looks like list markers. The indentation takes precedence.
1279        // Verified with: echo 'List:\n\n    - Item 1' | npx commonmark
1280        // Output: <pre><code>- Item 1</code></pre>
1281        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); // This IS a code block per spec
1284
1285        // Same for numbered list markers
1286        let content = "List:\n\n    1. First\n    2. Second";
1287        let blocks = CodeBlockUtils::detect_code_blocks(content);
1288        assert_eq!(blocks.len(), 1); // This IS a code block per spec
1289    }
1290
1291    #[test]
1292    fn test_actual_list_items_not_code_blocks() {
1293        // Actual list items (no preceding blank line + 4 spaces) are NOT code blocks
1294        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        // Nested list items
1299        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        // Inline code spans should NOT be detected as code blocks
1307        let content = "Text with `inline code` here";
1308        let blocks = CodeBlockUtils::detect_code_blocks(content);
1309        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1310
1311        // Multiple backtick code span
1312        let content = "Text with ``code with ` backtick`` here";
1313        let blocks = CodeBlockUtils::detect_code_blocks(content);
1314        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1315
1316        // Multiple code spans
1317        let content = "Has `code1` and `code2` spans";
1318        let blocks = CodeBlockUtils::detect_code_blocks(content);
1319        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1320    }
1321
1322    #[test]
1323    fn test_unclosed_code_span() {
1324        // Unclosed code span should not be detected
1325        let content = "Text with `unclosed code span";
1326        let blocks = CodeBlockUtils::detect_code_blocks(content);
1327        assert_eq!(blocks.len(), 0);
1328
1329        // Mismatched backticks
1330        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        // Should only detect the fenced block, NOT the inline spans
1340        assert_eq!(blocks.len(), 1);
1341
1342        // Check we have the fenced block only
1343        assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
1344        // Should NOT detect inline spans
1345        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        // Test positions inside blocks
1354        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        // Test positions at boundaries
1359        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 10)); // Start is inclusive
1360        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 20)); // End is exclusive
1361
1362        // Test positions outside blocks
1363        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        // 1 fenced block (fence markers no longer detected as inline spans)
1379        assert_eq!(blocks.len(), 1);
1380        assert_eq!(blocks[0].0, 0); // Fenced block starts at 0
1381    }
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        // 1 fenced block (fence markers no longer detected as inline spans)
1388        assert_eq!(blocks.len(), 1);
1389        // Check we have the fenced block
1390        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        // Code block containing fence markers as content
1397        let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1398        let blocks = CodeBlockUtils::detect_code_blocks(content);
1399        // Should detect: outer block, inner ```, outer ````
1400        assert!(!blocks.is_empty());
1401        // Check we have the outer block
1402        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        // Indented code blocks can contain blank lines
1409        let content = "Text\n\n    line1\n\n    line2\n\nAfter";
1410        let blocks = CodeBlockUtils::detect_code_blocks(content);
1411        // May have multiple blocks due to blank line handling
1412        assert!(!blocks.is_empty());
1413        // Check that we captured the indented code
1414        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        // Code spans should NOT be detected as code blocks
1425        let content = "Text ` code with spaces ` more";
1426        let blocks = CodeBlockUtils::detect_code_blocks(content);
1427        assert_eq!(blocks.len(), 0); // No blocks, only inline span
1428    }
1429
1430    #[test]
1431    fn test_fenced_block_with_info_string() {
1432        // Fenced code blocks with complex info strings
1433        let content = "```rust,no_run,should_panic\ncode\n```";
1434        let blocks = CodeBlockUtils::detect_code_blocks(content);
1435        // 1 fenced block (fence markers no longer detected as inline spans)
1436        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        // Indented fence markers should still work as fences
1443        let content = "Text\n  ```\n  code\n  ```\nAfter";
1444        let blocks = CodeBlockUtils::detect_code_blocks(content);
1445        // Only 1 fenced block (indented fences still work)
1446        assert_eq!(blocks.len(), 1);
1447    }
1448
1449    // Issue #175: Backticks in info string invalidate the fence
1450    #[test]
1451    fn test_backticks_in_info_string_not_code_block() {
1452        // Per CommonMark spec: "If the info string comes after a backtick fence,
1453        // it may not contain any backtick characters."
1454        // So ```something``` is NOT a valid fence - the backticks are treated as inline code.
1455        // Verified with: echo '```something```' | npx commonmark
1456        // Output: <p><code>something</code></p>
1457        let content = "```something```\n\n```bash\n# comment\n```";
1458        let blocks = CodeBlockUtils::detect_code_blocks(content);
1459        // Should find only the valid ```bash block, NOT the invalid ```something```
1460        assert_eq!(blocks.len(), 1);
1461        // The valid block should contain "# comment"
1462        assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1463    }
1464
1465    #[test]
1466    fn test_issue_175_reproduction() {
1467        // Full reproduction of issue #175
1468        let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐Ÿฆœ\"\n```";
1469        let blocks = CodeBlockUtils::detect_code_blocks(content);
1470        // Only the bash block is a code block
1471        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        // Tilde fences CAN have tildes in info string (only backtick restriction exists)
1478        // ~~~abc~~~ opens an unclosed code block with info string "abc~~~"
1479        let content = "~~~abc~~~\ncode content\n~~~";
1480        let blocks = CodeBlockUtils::detect_code_blocks(content);
1481        // This is a valid tilde fence that opens and closes
1482        assert_eq!(blocks.len(), 1);
1483    }
1484
1485    #[test]
1486    fn test_nested_longer_fence_contains_shorter() {
1487        // Longer fence (````) can contain shorter fence (```) as content
1488        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        // Tilde fence contains backtick markers as content
1497        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            // The unindented paragraph ends the list, so the block is at the margin.
1510            ("1. a\n\nb\n\n       code\n", 0),
1511            // A lazy continuation keeps the item open however long it runs.
1512            ("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            // The second tab straddles column 7, where the code begins.
1516            ("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            // `:warning:` opens no definition, so the block sits at the margin.
1534            ("term\n:warning:\n\n      x\n", Some(0)),
1535            ("- a\n\n  term\n  :warning:\n\n        x\n", Some(2)),
1536            // A definition rumdl accepts is a container.
1537            ("term\n: def\n\n      x\n", Some(2)),
1538            // Under an accepted definition the text reading would drop that
1539            // container too, so the column is unknown.
1540            ("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                        // 0.13.5 fixes the nested marker's offset. Its valid
1558                        // colon is now accepted, while the colon touching `text`
1559                        // remains rejected. No preceding term means no list.
1560                        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        // Issue #276: Indented code block inside a list should be detected by pulldown-cmark
1610        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        // pulldown-cmark SHOULD detect this indented code block inside the list
1620        assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1621
1622        // Verify the detected block contains our code
1623        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    // โ”€โ”€ is_in_code_block binary search tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1635
1636    #[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        // end is exclusive
1652        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        // Before all
1660        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1661        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1662        // In first
1663        assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1664        assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1665        // Gap between first and second
1666        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        // In second
1670        assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1671        assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1672        // Gap between second and third
1673        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1674        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1675        // In third
1676        assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1677        assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1678        // After all
1679        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        // Ranges that are exactly adjacent (end of one == start of next)
1686        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        // Verify binary search produces identical results to linear scan
1707        // for a realistic document layout
1708        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        // Exhaustive boundary testing for every block start/end
1724        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/// `CodeBlockDetail::closed` says whether a closing fence line ends the block,
1749/// which only the parser's reading of the fence rules settles: a last line that
1750/// looks like a fence can be code.
1751#[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            // Last line looks like a fence but is code.
1794            "> ```\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            // A last line of whitespace is inside the parser's range, and has no
1803            // text event of its own.
1804            "> ```\n> ",
1805            "```\n  ",
1806            "> ```\n> ~~~\n> ",
1807            "- ```\nfoo\n",
1808        ] {
1809            assert_eq!(closed_of(content), vec![false], "{content:?}");
1810        }
1811    }
1812}