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.
209///
210/// The parser reports some items, such as a tab-indented list inside a
211/// definition, at an offset before the marker that can fall inside a character.
212/// Such an offset names no marker and yields `None`.
213fn item_content_start(content: &str, marker: usize) -> Option<(StrippedLine, usize)> {
214    let rest = content.get(marker..)?;
215    let line_start = line_start_of(content, marker);
216    let marker_len = match rest.as_bytes().first()? {
217        b'-' | b'+' | b'*' => 1,
218        b'0'..=b'9' => {
219            let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
220            match rest.as_bytes().get(digits)? {
221                b'.' | b')' => digits + 1,
222                _ => return None,
223            }
224        }
225        _ => return None,
226    };
227    let marker_end = marker + marker_len;
228    let marker_col = visual_column(content, marker_end);
229    let line_end = content[marker_end..]
230        .find('\n')
231        .map_or(content.len(), |i| marker_end + i);
232    let after = content[marker_end..line_end].trim_end_matches('\r');
233    let mut col = marker_col;
234    let mut text = None;
235    for (i, c) in after.char_indices() {
236        match c {
237            ' ' => col += 1,
238            '\t' => col += 4 - col % 4,
239            _ => {
240                text = Some(i);
241                break;
242            }
243        }
244    }
245    let mut at = StrippedLine {
246        pos: marker_end - line_start,
247        col: marker_col,
248        pending: 0,
249    };
250    match text {
251        Some(i) if (1..=4).contains(&(col - marker_col)) => {
252            at.pos += i;
253            at.col = col;
254        }
255        // Nothing follows the marker on an empty item, so no later container
256        // reads the position; its content still starts one column on.
257        _ => {
258            if at.take_whitespace(&content[line_start..line_end], 1) == 0 {
259                at.col += 1;
260            }
261        }
262    }
263    Some((at, line_start))
264}
265
266/// Whether the fenced block spanning `start..end` of `content` ends with a
267/// closing fence line, given how many lines its literal text has.
268///
269/// A closing fence is the one line of a block that is not in its literal text,
270/// so a closed block spans two lines more than its literal holds (opener and
271/// closer) and an unclosed one a single line more. A final line holding only
272/// whitespace is inside the parser's range without a text event of its own, so
273/// the last line must also start with a fence character once quote markers and
274/// whitespace are set aside.
275fn fenced_block_is_closed(content: &str, start: usize, end: usize, literal_lines: usize) -> bool {
276    let bytes = content.as_bytes();
277    let end = end.min(bytes.len());
278    // The range of an unclosed block ends after the newline of its last line.
279    let body_end = if end > start && bytes[end - 1] == b'\n' {
280        end - 1
281    } else {
282        end
283    };
284    let body = &bytes[start.min(body_end)..body_end];
285    // Each newline starts another line, and an empty body is one line.
286    let span_lines = body.split(|&b| b == b'\n').count();
287    let line_start = body
288        .iter()
289        .rposition(|&b| b == b'\n')
290        .map_or(start, |at| start + at + 1);
291    let last_line = content.get(line_start..body_end).unwrap_or("");
292    span_lines == literal_lines + 2 && last_line.trim_start_matches(['>', ' ', '\t']).starts_with(['`', '~'])
293}
294
295/// Start of the line holding byte `pos`. Searches bytes, so `pos` may be any
296/// offset the parser reports, including one inside a character or past the end.
297fn line_start_of(content: &str, pos: usize) -> usize {
298    let before = &content.as_bytes()[..pos.min(content.len())];
299    before.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1)
300}
301
302/// Visual column of byte `pos`, counting from the start of its line, with a
303/// tab advancing to the next multiple of four.
304fn visual_column(content: &str, pos: usize) -> usize {
305    let line_start = line_start_of(content, pos);
306    content[line_start..pos]
307        .chars()
308        .fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 })
309}
310
311/// A strong emphasis span captured during parsing
312#[derive(Debug, Clone)]
313pub struct StrongSpanDetail {
314    /// Byte offset where the strong span starts (including **)
315    pub start: usize,
316    /// Byte offset where the strong span ends (including **)
317    pub end: usize,
318    /// Whether this uses asterisk (**) or underscore (__) markers
319    pub is_asterisk: bool,
320}
321
322/// Text a definition holds directly, captured during parsing
323///
324/// One of the definition's paragraphs, or, for a tight definition, the run of
325/// inline content the parser reports with no paragraph around it. Blocks nested
326/// in the definition (lists, blockquotes, code) hold their own text and are not
327/// part of one.
328#[derive(Debug, Clone)]
329pub struct DefinitionTextDetail {
330    /// Byte offset where the text starts
331    pub start: usize,
332    /// Byte offset where the text ends
333    pub end: usize,
334    /// Byte offset where the definition holding the text starts: its `:`
335    /// marker, or indentation before it inside a container
336    pub definition_start: usize,
337}
338
339/// Ordered list membership: maps line number (1-indexed) to list ID
340pub type LineToListMap = std::collections::HashMap<usize, usize>;
341/// Ordered list start values: maps list ID to the start value
342pub type ListStartValues = std::collections::HashMap<usize, u64>;
343
344/// Structure facts from document analysis, capturing data needed by individual rules.
345#[derive(Debug, Default)]
346pub struct ParseResult {
347    /// Code block byte ranges (start, end)
348    pub code_blocks: Vec<(usize, usize)>,
349    /// Inline code span byte ranges (start, end)
350    pub code_spans: Vec<(usize, usize)>,
351    /// Detailed code block info (fenced vs indented, info string)
352    pub code_block_details: Vec<CodeBlockDetail>,
353    /// Strong emphasis span details
354    pub strong_spans: Vec<StrongSpanDetail>,
355    /// Ordered list membership: maps line number (1-indexed) to list ID
356    pub line_to_list: LineToListMap,
357    /// Ordered list start values: maps list ID to start value
358    pub list_start_values: ListStartValues,
359    /// HTML block byte ranges (start, end)
360    ///
361    /// Needed to know how far an unclosed `<!--` reaches. The block ends where
362    /// CommonMark says it does, which is the end of the enclosing container
363    /// rather than the end of the document: an unclosed comment in a blockquote
364    /// stops at the quote, and one in a list item stops at the blank line.
365    pub html_blocks: Vec<(usize, usize)>,
366    /// Definition list item byte ranges (start, end) in document order: a
367    /// definition together with the terms before it, blank lines after it left
368    /// out, items of nested lists too. Only definitions passing
369    /// `opens_definition`, and their terms, count.
370    pub definition_items: Vec<(usize, usize)>,
371    /// Definition list term byte ranges (start, end)
372    pub definition_terms: Vec<(usize, usize)>,
373    /// Text held directly by a definition, in document order
374    pub definition_texts: Vec<DefinitionTextDetail>,
375    /// Definitions failing `opens_definition`, with their terms, in document
376    /// order, where reading them as the paragraph text rumdl takes them for
377    /// changes which inline constructs the text holds. The parse the other
378    /// fields come from reads a definition, which ends every code span, link
379    /// and emphasis running into it.
380    pub colon_definitions: Vec<ColonDefinitionDetail>,
381    /// Byte range of every table the parser reads
382    pub table_ranges: Vec<(usize, usize)>,
383    /// Byte range of every block that is not inside another block, in document
384    /// order: a paragraph, list, blockquote, table, code block, and so on
385    pub top_level_blocks: Vec<(usize, usize)>,
386    /// Byte range (terms included) of every definition failing
387    /// `opens_definition`, before `colon_definitions` drops those whose text
388    /// reads the same either way
389    pub rejected_definition_ranges: Vec<(usize, usize)>,
390    /// Byte range of every list item together with the byte offset where its
391    /// content starts, for items that have content
392    pub list_items: Vec<(usize, usize, usize)>,
393}
394
395/// A definition pulldown-cmark opens on a colon touching its text, together
396/// with the terms before it
397#[derive(Debug, Clone)]
398pub struct ColonDefinitionDetail {
399    /// Byte offset where the first term starts, or the definition when it has none
400    pub start: usize,
401    /// Byte offset where the definition ends, blank lines after it left out
402    pub end: usize,
403    /// Byte offset where the definition starts
404    pub definition_start: usize,
405}
406
407/// Classification of code blocks relative to list contexts
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub enum CodeBlockContext {
410    /// Code block that separates lists (root-level, with blank lines)
411    Standalone,
412    /// Code block that continues a list (properly indented)
413    Indented,
414    /// Code block adjacent to list content (edge case, defaults to non-breaking)
415    Adjacent,
416}
417
418/// Whether a tag opens inline content rather than a block
419fn is_inline_tag(tag: &Tag) -> bool {
420    matches!(
421        tag,
422        Tag::Emphasis
423            | Tag::Strong
424            | Tag::Strikethrough
425            | Tag::Superscript
426            | Tag::Subscript
427            | Tag::Link { .. }
428            | Tag::Image { .. }
429    )
430}
431
432/// Whether a tag end closes inline content rather than a block
433fn is_inline_tag_end(tag_end: TagEnd) -> bool {
434    matches!(
435        tag_end,
436        TagEnd::Emphasis
437            | TagEnd::Strong
438            | TagEnd::Strikethrough
439            | TagEnd::Superscript
440            | TagEnd::Subscript
441            | TagEnd::Link
442            | TagEnd::Image
443    )
444}
445
446/// Whether the definition starting at `start` opens with a colon followed by
447/// whitespace or the end of its line.
448///
449/// That is the marker the definition-list extensions agree on (PHP Markdown
450/// Extra, Python-Markdown, Pandoc). pulldown-cmark also opens a definition on a
451/// colon touching its text, as in `:warning:` or a `:::` fence closing a div,
452/// which those read as prose or as the fence it is.
453fn opens_definition(content: &str, start: usize) -> bool {
454    // Defensively reject offsets inside UTF-8 characters. Before 0.13.5, a
455    // tab crossing a definition's container indent could produce one.
456    let Some(rest) = content.get(start..) else {
457        return false;
458    };
459    rest.trim_start_matches([' ', '\t'])
460        .strip_prefix(':')
461        .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
462}
463
464/// Every indented code block a parse with `options` finds in `content`, as
465/// its byte range and the content column of the container it sits in: four
466/// columns short of where its code begins, less the columns a tab straddling
467/// that point overshot, which the parser supplies as a leading text event
468/// with an empty range.
469fn indented_block_columns(content: &str, options: Options) -> Vec<(usize, usize, usize)> {
470    let mut blocks = Vec::new();
471    let mut open: Option<(usize, usize)> = None;
472    let mut awaiting_first_event = false;
473    for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
474        if std::mem::take(&mut awaiting_first_event)
475            && let Event::Text(text) = &event
476            && range.is_empty()
477            && let Some((_, column)) = &mut open
478        {
479            *column = column.saturating_sub(text.len());
480        }
481        match event {
482            Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
483                open = Some((range.start, visual_column(content, range.start).saturating_sub(4)));
484                awaiting_first_event = true;
485            }
486            Event::End(TagEnd::CodeBlock) => {
487                if let Some((start, column)) = open.take() {
488                    blocks.push((start, range.end, column));
489                }
490            }
491            _ => {}
492        }
493    }
494    blocks
495}
496
497/// The byte ranges of the inline constructs a parse with `options` finds in
498/// `content`, in document order: code spans, math, inline HTML, and the
499/// emphasis, link and image spans holding text.
500fn inline_ranges(content: &str, options: Options) -> Vec<(usize, usize)> {
501    Parser::new_ext(content, options)
502        .into_offset_iter()
503        .filter_map(|(event, range)| match event {
504            Event::Start(tag) if is_inline_tag(&tag) => Some((range.start, range.end)),
505            Event::Code(_) | Event::InlineHtml(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {
506                Some((range.start, range.end))
507            }
508            _ => None,
509        })
510        .collect()
511}
512
513/// Read source-ordered inline ranges for source-ordered definition queries.
514/// Keep only earlier ranges that still overlap the current definition, and
515/// inspect later ranges only when they actually fall inside that definition.
516/// This also preserves nested ranges and spans crossing several definitions.
517struct InlineRangeCursor<'a> {
518    ranges: &'a [(usize, usize)],
519    next: usize,
520    active: Vec<(usize, usize)>,
521}
522
523impl<'a> InlineRangeCursor<'a> {
524    fn new(ranges: &'a [(usize, usize)]) -> Self {
525        Self {
526            ranges,
527            next: 0,
528            active: Vec::new(),
529        }
530    }
531
532    fn overlapping(&mut self, detail: &ColonDefinitionDetail) -> impl Iterator<Item = (usize, usize)> + '_ {
533        while self.next < self.ranges.len() && self.ranges[self.next].0 <= detail.start {
534            self.active.push(self.ranges[self.next]);
535            self.next += 1;
536        }
537        self.active.retain(|&(_, end)| end > detail.start);
538        let end = detail.end;
539        self.active.iter().copied().chain(
540            self.ranges[self.next..]
541                .iter()
542                .take_while(move |&&(start, _)| start < end)
543                .copied(),
544        )
545    }
546}
547
548/// Utility functions for detecting and handling code blocks in Markdown
549pub struct CodeBlockUtils;
550
551impl CodeBlockUtils {
552    /// Detect all code blocks in the content (NOT including inline code spans)
553    ///
554    /// Uses pulldown-cmark for spec-compliant CommonMark parsing. This correctly handles:
555    /// - Fenced code blocks (``` and ~~~)
556    /// - Indented code blocks (4 spaces or tab)
557    /// - Code blocks inside lists, blockquotes, and other containers
558    /// - Edge cases like backticks in info strings (which invalidate the fence)
559    ///
560    /// Returns a sorted vector of (start, end) byte offset tuples.
561    pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
562        Self::detect_code_blocks_and_spans(content).code_blocks
563    }
564
565    /// Returns code block ranges, inline code span ranges, and detailed code block info
566    /// in a single pulldown-cmark pass.
567    pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
568        let line_starts: Vec<usize> = std::iter::once(0)
569            .chain(content.match_indices('\n').map(|(i, _)| i + 1))
570            .collect();
571        let options = rumdl_parser_options_for(content);
572        Self::collect_code_blocks_and_spans(
573            content,
574            &line_starts,
575            options,
576            Parser::new_ext(content, options).into_offset_iter(),
577        )
578    }
579
580    /// Collect structure from an existing parse, sharing its events and source
581    /// line index with other document consumers. Recovery parses still use
582    /// their own options where rumdl's reading differs from the primary parse.
583    pub(crate) fn collect_code_blocks_and_spans<'a>(
584        content: &str,
585        line_starts: &[usize],
586        options: Options,
587        events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
588    ) -> ParseResult {
589        let mut blocks = Vec::new();
590        let mut spans = Vec::new();
591        let mut details = Vec::new();
592        let mut strong_spans = Vec::new();
593        let mut html_blocks = Vec::new();
594        let mut code_block_start: Option<(usize, bool, String, Option<usize>)> = None;
595        // The literal text of the code block being read, as a line count: the
596        // newlines seen, and whether the text so far ends in a partial line.
597        let mut literal_newlines = 0usize;
598        let mut literal_partial_line = false;
599        // Set while the event right after an indented block's start is unseen.
600        // When a tab straddles the column where the code begins, the parser
601        // starts the block after the tab and supplies the columns it overshot
602        // as a leading text event with an empty range.
603        let mut awaiting_first_code_event = false;
604        // One entry per open definition: whether `opens_definition` rejects
605        // it. The parser reads a rejected definition as a container, rumdl as
606        // the text it is, so an indented block inside one takes its content
607        // column from a parse without definition lists (`text_reading`).
608        let mut definition_rejected: Vec<bool> = Vec::new();
609        let mut text_reading = Vec::new();
610
611        // Definition structure. `block_stack` holds one entry per open block,
612        // the definition's start for a definition and `None` for anything else,
613        // so the innermost entry says whether content belongs to a definition
614        // directly. Inline tags never touch it.
615        //
616        // Only a definition passing `opens_definition` counts, and a term
617        // waits in `pending_terms` until a definition following it does. Extents
618        // are recorded per item rather than per list: the parser's range for a
619        // list can run over the paragraph after it, and a list can hold items
620        // that do not count between items that do.
621        let mut definition_items: Vec<(usize, usize)> = Vec::new();
622        let mut pending_terms: Vec<(usize, usize)> = Vec::new();
623        let mut definition_terms = Vec::new();
624        let mut definition_texts = Vec::new();
625        let mut colon_definitions = Vec::new();
626        let mut table_ranges: Vec<(usize, usize)> = Vec::new();
627        let mut top_level_blocks: Vec<(usize, usize)> = Vec::new();
628        let mut rejected_definition_ranges: Vec<(usize, usize)> = Vec::new();
629        let mut list_item_spans: Vec<(usize, usize, Option<usize>)> = Vec::new();
630        let mut open_items: Vec<usize> = Vec::new();
631        // Containers open at the current event, outermost first, each with the
632        // start of the line it opened on and, for an item, where its content
633        // starts there.
634        let mut open_containers: Vec<(Enclosure, usize, StrippedLine)> = Vec::new();
635        let mut code_block_enclosing: Vec<Enclosure> = Vec::new();
636        let mut block_stack: Vec<Option<usize>> = Vec::new();
637        let mut definition_paragraph: Option<(usize, usize)> = None;
638        let mut tight_run: Option<DefinitionTextDetail> = None;
639
640        // List membership tracking for ordered lists
641        let mut line_to_list = LineToListMap::new();
642        let mut list_start_values = ListStartValues::new();
643        let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); // (list_id, is_ordered, start_value)
644        let mut next_list_id: usize = 0;
645
646        let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
647
648        for (event, range) in events {
649            if let Some(&item) = open_items.last()
650                && list_item_spans[item].2.is_none()
651                && !matches!(event, Event::End(_))
652            {
653                list_item_spans[item].2 = Some(range.start);
654            }
655            if matches!(event, Event::Start(Tag::Item)) {
656                open_items.push(list_item_spans.len());
657                list_item_spans.push((range.start, range.end, None));
658            } else if matches!(event, Event::End(TagEnd::Item)) {
659                open_items.pop();
660            }
661            match &event {
662                Event::Start(Tag::BlockQuote(_)) => {
663                    let line_start = line_start_of(content, range.start);
664                    open_containers.push((Enclosure::Quote { end: range.end }, line_start, StrippedLine::default()));
665                }
666                Event::Start(Tag::Item) => {
667                    let entry = item_content_start(content, range.start)
668                        .and_then(|(content_start, line_start)| {
669                            let prefix = &content[line_start..range.start];
670                            // A parent item that opened on this same line has
671                            // its marker in the prefix, so the prefix is read
672                            // from where that item's content starts.
673                            let same_line = open_containers.iter().rposition(|&(parent, parent_line, _)| {
674                                matches!(parent, Enclosure::Item { .. }) && parent_line == line_start
675                            });
676                            let (mut at, rest) = match same_line {
677                                Some(i) => (open_containers[i].2, &open_containers[i + 1..]),
678                                None => (StrippedLine::default(), &open_containers[..]),
679                            };
680                            if !rest.iter().all(|&(parent, _, _)| at.take_container(prefix, parent)) {
681                                return None;
682                            }
683                            let width = content_start.col.checked_sub(at.col)?;
684                            Some((Enclosure::Item { width, end: range.end }, line_start, content_start))
685                        })
686                        .unwrap_or((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
687                    open_containers.push(entry);
688                }
689                Event::Start(Tag::DefinitionListDefinition | Tag::FootnoteDefinition(_)) => {
690                    open_containers.push((Enclosure::Opaque { end: range.end }, 0, StrippedLine::default()));
691                }
692                Event::End(
693                    TagEnd::BlockQuote(_)
694                    | TagEnd::Item
695                    | TagEnd::DefinitionListDefinition
696                    | TagEnd::FootnoteDefinition,
697                ) => {
698                    open_containers.pop();
699                }
700                _ => {}
701            }
702            match &event {
703                Event::Start(tag) if !is_inline_tag(tag) => {
704                    definition_texts.extend(tight_run.take());
705                    if block_stack.is_empty() {
706                        top_level_blocks.push((range.start, range.end));
707                    }
708                    if matches!(tag, Tag::Table(_)) {
709                        table_ranges.push((range.start, range.end));
710                    }
711                    if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
712                        definition_paragraph = Some((range.start, *definition_start));
713                    }
714                    let mut definition_start = None;
715                    match tag {
716                        Tag::DefinitionList => pending_terms.clear(),
717                        Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
718                        Tag::DefinitionListDefinition => {
719                            // The definition's range takes in the blank lines after
720                            // it, quoted ones too, which belong to no block.
721                            let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
722                            // Only ASCII suffix bytes are removed, remaining
723                            // safe even if a parser regression reports a marker
724                            // start inside a UTF-8 character.
725                            let item_end = content.as_bytes()[range.clone()]
726                                .iter()
727                                .rposition(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'>'))
728                                .map_or(range.start, |index| range.start + index + 1);
729                            let accepted = opens_definition(content, range.start);
730                            definition_rejected.push(!accepted);
731                            if accepted {
732                                definition_items.push((item_start, item_end));
733                                definition_terms.append(&mut pending_terms);
734                                definition_start = Some(range.start);
735                            } else {
736                                rejected_definition_ranges.push((item_start, item_end));
737                                colon_definitions.push(ColonDefinitionDetail {
738                                    start: item_start,
739                                    end: item_end,
740                                    definition_start: range.start,
741                                });
742                                pending_terms.clear();
743                            }
744                        }
745                        _ => {}
746                    }
747                    block_stack.push(definition_start);
748                }
749                Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
750                    definition_texts.extend(tight_run.take());
751                    if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
752                    {
753                        definition_texts.push(DefinitionTextDetail {
754                            start,
755                            end: range.end,
756                            definition_start,
757                        });
758                    }
759                    if let TagEnd::DefinitionListDefinition = tag_end {
760                        definition_rejected.pop();
761                    }
762                    if let TagEnd::DefinitionList = tag_end {
763                        pending_terms.clear();
764                    }
765                    block_stack.pop();
766                }
767                // Inline content directly under a definition is a tight
768                // definition's text, which the parser does not wrap in a paragraph.
769                _ => {
770                    if let Some(Some(definition_start)) = block_stack.last() {
771                        match &mut tight_run {
772                            Some(run) => run.end = run.end.max(range.end),
773                            None => {
774                                tight_run = Some(DefinitionTextDetail {
775                                    start: range.start,
776                                    end: range.end,
777                                    definition_start: *definition_start,
778                                });
779                            }
780                        }
781                    }
782                }
783            }
784            if std::mem::take(&mut awaiting_first_code_event)
785                && let Event::Text(text) = &event
786                && range.is_empty()
787                && let Some((_, _, _, Some(column))) = &mut code_block_start
788            {
789                *column = column.saturating_sub(text.len());
790            }
791            if code_block_start.is_some()
792                && let Event::Text(text) = &event
793                && !text.is_empty()
794            {
795                literal_newlines += text.matches('\n').count();
796                literal_partial_line = !text.ends_with('\n');
797            }
798            match event {
799                Event::Start(Tag::CodeBlock(kind)) => {
800                    literal_newlines = 0;
801                    literal_partial_line = false;
802                    let (is_fenced, info_string) = match &kind {
803                        CodeBlockKind::Fenced(info) => (true, info.to_string()),
804                        CodeBlockKind::Indented => (false, String::new()),
805                    };
806                    let mut content_column =
807                        (!is_fenced).then(|| visual_column(content, range.start).saturating_sub(4));
808                    if !is_fenced && definition_rejected.contains(&true) {
809                        // Dropping definition lists would also drop an accepted
810                        // definition around the block, so its column is unknown.
811                        if !block_stack.iter().any(Option::is_some) {
812                            text_reading.push(range.start);
813                        }
814                        content_column = None;
815                    }
816                    awaiting_first_code_event = !is_fenced;
817                    code_block_enclosing = if is_fenced {
818                        open_containers.iter().map(|container| container.0).collect()
819                    } else {
820                        Vec::new()
821                    };
822                    code_block_start = Some((range.start, is_fenced, info_string, content_column));
823                }
824                Event::End(TagEnd::CodeBlock) => {
825                    if let Some((start, is_fenced, info_string, content_column)) = code_block_start.take() {
826                        blocks.push((start, range.end));
827                        let literal_lines = literal_newlines + usize::from(literal_partial_line);
828                        details.push(CodeBlockDetail {
829                            start,
830                            end: range.end,
831                            is_fenced,
832                            info_string,
833                            content_column,
834                            closed: is_fenced && fenced_block_is_closed(content, start, range.end, literal_lines),
835                            enclosing: std::mem::take(&mut code_block_enclosing),
836                        });
837                    }
838                }
839                Event::Start(Tag::Strong) => {
840                    if range.start + 2 <= content.len() {
841                        let is_asterisk = &content[range.start..range.start + 2] == "**";
842                        strong_spans.push(StrongSpanDetail {
843                            start: range.start,
844                            end: range.end,
845                            is_asterisk,
846                        });
847                    }
848                }
849                Event::Start(Tag::List(start_num)) => {
850                    let is_ordered = start_num.is_some();
851                    let start_value = start_num.unwrap_or(1);
852                    list_stack.push((next_list_id, is_ordered, start_value));
853                    if is_ordered {
854                        list_start_values.insert(next_list_id, start_value);
855                    }
856                    next_list_id += 1;
857                }
858                Event::End(TagEnd::List(_)) => {
859                    list_stack.pop();
860                }
861                Event::Start(Tag::Item) => {
862                    if let Some(&(list_id, is_ordered, _)) = list_stack.last()
863                        && is_ordered
864                    {
865                        let line_num = byte_to_line(range.start);
866                        line_to_list.insert(line_num, list_id);
867                    }
868                }
869                Event::Start(Tag::HtmlBlock) => {
870                    // The start event's range already spans the whole block.
871                    html_blocks.push((range.start, range.end));
872                }
873                Event::Code(_) => {
874                    spans.push((range.start, range.end));
875                }
876                _ => {}
877            }
878        }
879
880        // Handle edge case: unclosed code block at end of content
881        // pulldown-cmark should handle this, but be defensive
882        if let Some((start, is_fenced, info_string, content_column)) = code_block_start {
883            blocks.push((start, content.len()));
884            details.push(CodeBlockDetail {
885                start,
886                end: content.len(),
887                is_fenced,
888                info_string,
889                content_column,
890                closed: false,
891                enclosing: std::mem::take(&mut code_block_enclosing),
892            });
893        }
894
895        if !text_reading.is_empty() {
896            let text_blocks = indented_block_columns(content, options - Options::ENABLE_DEFINITION_LIST);
897            for detail in details.iter_mut().filter(|d| text_reading.contains(&d.start)) {
898                let lines = (byte_to_line(detail.start), byte_to_line(detail.end.saturating_sub(1)));
899                detail.content_column = text_blocks
900                    .iter()
901                    .find(|&&(start, end, _)| (byte_to_line(start), byte_to_line(end.saturating_sub(1))) == lines)
902                    .map(|&(_, _, column)| column);
903            }
904        }
905
906        // A colon-led definition whose text holds the same inline constructs
907        // either way is read the same by both, and drops out.
908        if !colon_definitions.is_empty() {
909            let as_definitions = inline_ranges(content, options);
910            let as_text = inline_ranges(content, options - Options::ENABLE_DEFINITION_LIST);
911            let mut definition_ranges = InlineRangeCursor::new(&as_definitions);
912            let mut text_ranges = InlineRangeCursor::new(&as_text);
913            colon_definitions.retain(|detail| {
914                !definition_ranges
915                    .overlapping(detail)
916                    .eq(text_ranges.overlapping(detail))
917            });
918        }
919
920        // Sort by start position (should already be sorted, but ensure consistency)
921        blocks.sort_by_key(|&(start, _)| start);
922        spans.sort_by_key(|&(start, _)| start);
923        details.sort_by_key(|d| d.start);
924        strong_spans.sort_by_key(|s| s.start);
925        html_blocks.sort_by_key(|&(start, _)| start);
926        ParseResult {
927            definition_items,
928            definition_terms,
929            definition_texts,
930            colon_definitions,
931            code_blocks: blocks,
932            code_spans: spans,
933            code_block_details: details,
934            strong_spans,
935            line_to_list,
936            list_start_values,
937            html_blocks,
938            table_ranges,
939            top_level_blocks,
940            rejected_definition_ranges,
941            list_items: list_item_spans
942                .into_iter()
943                .filter_map(|(start, end, content)| content.map(|content| (start, end, content)))
944                .collect(),
945        }
946    }
947
948    /// Check if a position is within a code block (for compatibility)
949    pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
950        Self::is_in_code_block(blocks, pos)
951    }
952
953    /// Check if a byte position falls within any of the given sorted, non-overlapping ranges.
954    ///
955    /// Uses binary search on the sorted block ranges for O(log n) lookup.
956    /// The blocks slice must be sorted by start position (as returned by
957    /// `detect_code_blocks` and `detect_code_blocks_and_spans`).
958    pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
959        // Binary search: find the last block whose start <= pos
960        let idx = blocks.partition_point(|&(start, _)| start <= pos);
961        // partition_point returns the first index where start > pos,
962        // so the candidate is at idx - 1
963        idx > 0 && pos < blocks[idx - 1].1
964    }
965
966    /// Analyze code block context relative to list parsing
967    /// This is the core function implementing Design #3's three-tier classification
968    pub fn analyze_code_block_context(
969        lines: &[crate::lint_context::LineInfo],
970        line_idx: usize,
971        min_continuation_indent: usize,
972    ) -> CodeBlockContext {
973        if let Some(line_info) = lines.get(line_idx) {
974            // Rule 1: Indentation Analysis - Is it sufficiently indented for list continuation?
975            if line_info.indent >= min_continuation_indent {
976                return CodeBlockContext::Indented;
977            }
978
979            // Rule 2: Blank Line Context - Check for structural separation indicators
980            let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
981
982            // Rule 3: Standalone Detection - Insufficient indentation + blank line separation
983            // This is the key fix: root-level code blocks with blank lines separate lists
984            if prev_blanks > 0 || next_blanks > 0 {
985                return CodeBlockContext::Standalone;
986            }
987
988            // Rule 4: Default - Adjacent (conservative, non-breaking for edge cases)
989            CodeBlockContext::Adjacent
990        } else {
991            // Fallback for invalid line index
992            CodeBlockContext::Adjacent
993        }
994    }
995
996    /// Count blank lines before and after the given line index
997    fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
998        let mut prev_blanks = 0;
999        let mut next_blanks = 0;
1000
1001        // Count blank lines before (look backwards)
1002        for i in (0..line_idx).rev() {
1003            if let Some(line) = lines.get(i) {
1004                if line.is_blank {
1005                    prev_blanks += 1;
1006                } else {
1007                    break;
1008                }
1009            } else {
1010                break;
1011            }
1012        }
1013
1014        // Count blank lines after (look forwards)
1015        for i in (line_idx + 1)..lines.len() {
1016            if let Some(line) = lines.get(i) {
1017                if line.is_blank {
1018                    next_blanks += 1;
1019                } else {
1020                    break;
1021                }
1022            } else {
1023                break;
1024            }
1025        }
1026
1027        (prev_blanks, next_blanks)
1028    }
1029
1030    /// Calculate minimum indentation required for code block to continue a list
1031    /// Based on the most recent list item's marker width
1032    pub fn calculate_min_continuation_indent(
1033        content: &str,
1034        lines: &[crate::lint_context::LineInfo],
1035        current_line_idx: usize,
1036    ) -> usize {
1037        // Look backwards to find the most recent list item or separator.
1038        for i in (0..current_line_idx).rev() {
1039            if let Some(line_info) = lines.get(i)
1040                && let Some(indent) = Self::continuation_indent_boundary(content, line_info)
1041            {
1042                return indent;
1043            }
1044        }
1045        0
1046    }
1047
1048    /// A list marker sets the continuation indent; a structural separator
1049    /// clears it. Other lines preserve the previous value. Marker precedence
1050    /// matters for list lines whose content also resembles a separator.
1051    pub(crate) fn continuation_indent_boundary(
1052        content: &str,
1053        line_info: &crate::lint_context::LineInfo,
1054    ) -> Option<usize> {
1055        if let Some(list_item) = &line_info.list_item {
1056            Some(if list_item.is_ordered {
1057                list_item.marker_column + list_item.marker.len() + 1
1058            } else {
1059                list_item.marker_column + 2
1060            })
1061        } else if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
1062            Some(0)
1063        } else {
1064            None
1065        }
1066    }
1067
1068    /// Check if content is a structural separator (headings, horizontal rules, etc.)
1069    fn is_structural_separator(content: &str) -> bool {
1070        let trimmed = content.trim();
1071        trimmed.starts_with("---")
1072            || trimmed.starts_with("***")
1073            || trimmed.starts_with("___")
1074            || crate::utils::skip_context::is_table_line(trimmed)
1075            || trimmed.starts_with('>') // Blockquotes
1076    }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    #[test]
1082    fn test_continued_depth_is_the_longest_prefix_strip_enclosure_accepts() {
1083        use super::{Enclosure, continued_depth, item_runs, strip_enclosure};
1084        let quote = Enclosure::Quote { end: 0 };
1085        let item = |width| Enclosure::Item { width, end: 0 };
1086        let stacks = [
1087            vec![quote, item(2), item(2), quote, item(3)],
1088            vec![item(2), quote, quote, item(4)],
1089            vec![item(2), item(2), item(2)],
1090            vec![quote, Enclosure::Opaque { end: 0 }, item(2)],
1091        ];
1092        let lines = [
1093            "",
1094            "   ",
1095            "\t",
1096            ">",
1097            "> ",
1098            ">\t",
1099            ">    ",
1100            ">     > x",
1101            "> x",
1102            "  > > x",
1103            "  >",
1104            "  >  ",
1105            "    x",
1106            "  x",
1107            "      x",
1108            "\tx",
1109            " \t x",
1110            "lazy",
1111            "> > > x",
1112            ">     >    x",
1113        ];
1114        for stack in &stacks {
1115            let runs = item_runs(stack);
1116            for line in lines {
1117                let expected = (0..=stack.len())
1118                    .rev()
1119                    .find(|&k| strip_enclosure(line, &stack[..k]).is_some())
1120                    .unwrap();
1121                assert_eq!(
1122                    continued_depth(line, stack, &runs),
1123                    expected,
1124                    "line {line:?} in {stack:?}"
1125                );
1126            }
1127        }
1128    }
1129
1130    #[test]
1131    fn test_items_opening_on_one_line_through_a_quote_record_their_widths() {
1132        use super::Enclosure;
1133        let quote = Enclosure::Quote { end: 0 };
1134        let item = |width| Enclosure::Item { width, end: 0 };
1135        let cases = [
1136            ("- > - ```md\n  >   x\n  >   ```\n", vec![item(2), quote, item(2)]),
1137            (
1138                "> - > - ```md\n>   >   x\n>   >   ```\n",
1139                vec![quote, item(2), quote, item(2)],
1140            ),
1141            ("1. > - ```md\n   >   x\n   >   ```\n", vec![item(3), quote, item(2)]),
1142            ("- > 10. ```md\n  >     x\n  >     ```\n", vec![item(2), quote, item(4)]),
1143            ("- >\t- ```md\n  >\t  x\n  >\t  ```\n", vec![item(2), quote, item(2)]),
1144            (
1145                "- > - - ```md\n  >     x\n  >     ```\n",
1146                vec![item(2), quote, item(2), item(2)],
1147            ),
1148            (
1149                "- > > - ```md\n  > >   x\n  > >   ```\n",
1150                vec![item(2), quote, quote, item(2)],
1151            ),
1152        ];
1153        for (content, expected) in cases {
1154            let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1155            let enclosing: Vec<Enclosure> = parsed.code_block_details[0]
1156                .enclosing
1157                .iter()
1158                .map(|&enclosure| match enclosure {
1159                    Enclosure::Quote { .. } => quote,
1160                    Enclosure::Item { width, .. } => item(width),
1161                    Enclosure::Opaque { .. } => Enclosure::Opaque { end: 0 },
1162                })
1163                .collect();
1164            assert_eq!(enclosing, expected, "{content:?}");
1165        }
1166    }
1167
1168    #[test]
1169    fn test_table_ranges_and_top_level_blocks_are_recorded() {
1170        let content = "para\n\n|a|b|\n|-|-|\n|1|2|\n\n- item\n\n  |c|d|\n  |-|-|\n";
1171        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1172        let text = |&(start, end): &(usize, usize)| &content[start..end];
1173
1174        assert_eq!(parsed.table_ranges.len(), 2);
1175        assert_eq!(text(&parsed.table_ranges[0]), "|a|b|\n|-|-|\n|1|2|\n");
1176        assert!(text(&parsed.table_ranges[1]).starts_with("|c|d|"));
1177
1178        // The paragraph, the first table and the list are top level; the table
1179        // inside the list item is not.
1180        let top: Vec<&str> = parsed.top_level_blocks.iter().map(text).collect();
1181        assert_eq!(top.len(), 3, "{top:?}");
1182        assert_eq!(top[0], "para\n");
1183        assert!(top[2].starts_with("- item"));
1184    }
1185
1186    #[test]
1187    fn test_list_items_record_where_their_content_starts() {
1188        let content = "- - x\n\n-   y\n-\n- \n  z\n";
1189        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1190        let starts: Vec<(&str, &str)> = parsed
1191            .list_items
1192            .iter()
1193            .map(|&(start, _, content_start)| (&content[start..=start], &content[content_start..=content_start]))
1194            .collect();
1195        // Outer item holds the inner marker; the empty `-` item has no content.
1196        assert_eq!(starts, [("-", "-"), ("-", "x"), ("-", "y"), ("-", "z")]);
1197    }
1198
1199    #[test]
1200    fn test_item_offset_inside_a_character_is_not_read_as_a_marker() {
1201        // The parser reports the tab-indented item in the definition at byte 5,
1202        // the second byte of the `ยก` on the definition line.
1203        let content = "ยก\n:ยก\n\t* ยก";
1204        assert!(!content.is_char_boundary(5));
1205        assert!(item_content_start(content, 5).is_none());
1206
1207        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
1208        assert_eq!(parsed.list_items.len(), 1);
1209    }
1210
1211    #[test]
1212    fn test_no_table_ranges_without_a_table() {
1213        let parsed = CodeBlockUtils::detect_code_blocks_and_spans("a|b\n|-|\n\n    |a|b|\n    |-|-|\n");
1214        assert!(parsed.table_ranges.is_empty());
1215    }
1216
1217    use super::*;
1218
1219    #[test]
1220    fn test_detect_fenced_code_blocks() {
1221        // The function detects fenced blocks and inline code spans
1222        // Fence markers (``` at line start) are now skipped in inline span detection
1223
1224        // Basic fenced code block with backticks
1225        let content = "Some text\n```\ncode here\n```\nMore text";
1226        let blocks = CodeBlockUtils::detect_code_blocks(content);
1227        // Should find: 1 fenced block (fences are no longer detected as inline spans)
1228        assert_eq!(blocks.len(), 1);
1229
1230        // Check that we have the fenced block
1231        let fenced_block = blocks
1232            .iter()
1233            .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
1234        assert!(fenced_block.is_some());
1235
1236        // Fenced code block with tildes (no inline code detection for ~)
1237        let content = "Some text\n~~~\ncode here\n~~~\nMore text";
1238        let blocks = CodeBlockUtils::detect_code_blocks(content);
1239        assert_eq!(blocks.len(), 1);
1240        assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
1241
1242        // Multiple code blocks
1243        let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
1244        let blocks = CodeBlockUtils::detect_code_blocks(content);
1245        // 2 fenced blocks (fence markers no longer detected as inline spans)
1246        assert_eq!(blocks.len(), 2);
1247    }
1248
1249    #[test]
1250    fn test_detect_code_blocks_with_language() {
1251        // Code block with language identifier
1252        let content = "Text\n```rust\nfn main() {}\n```\nMore";
1253        let blocks = CodeBlockUtils::detect_code_blocks(content);
1254        // 1 fenced block (fence markers no longer detected as inline spans)
1255        assert_eq!(blocks.len(), 1);
1256        // Check we have the full fenced block
1257        let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
1258        assert!(fenced.is_some());
1259    }
1260
1261    #[test]
1262    fn test_unclosed_code_block() {
1263        // Unclosed code block should extend to end of content
1264        let content = "Text\n```\ncode here\nno closing fence";
1265        let blocks = CodeBlockUtils::detect_code_blocks(content);
1266        assert_eq!(blocks.len(), 1);
1267        assert_eq!(blocks[0].1, content.len());
1268    }
1269
1270    #[test]
1271    fn test_indented_code_blocks() {
1272        // Basic indented code block
1273        let content = "Paragraph\n\n    code line 1\n    code line 2\n\nMore text";
1274        let blocks = CodeBlockUtils::detect_code_blocks(content);
1275        assert_eq!(blocks.len(), 1);
1276        assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
1277        assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
1278
1279        // Indented code with tabs
1280        let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
1281        let blocks = CodeBlockUtils::detect_code_blocks(content);
1282        assert_eq!(blocks.len(), 1);
1283    }
1284
1285    #[test]
1286    fn test_indented_code_requires_blank_line() {
1287        // Indented lines without preceding blank line are not code blocks
1288        let content = "Paragraph\n    indented but not code\nMore text";
1289        let blocks = CodeBlockUtils::detect_code_blocks(content);
1290        assert_eq!(blocks.len(), 0);
1291
1292        // With blank line, it becomes a code block
1293        let content = "Paragraph\n\n    now it's code\nMore text";
1294        let blocks = CodeBlockUtils::detect_code_blocks(content);
1295        assert_eq!(blocks.len(), 1);
1296    }
1297
1298    #[test]
1299    fn test_indented_content_with_list_markers_is_code_block() {
1300        // Per CommonMark spec: 4-space indented content after blank line IS a code block,
1301        // even if the content looks like list markers. The indentation takes precedence.
1302        // Verified with: echo 'List:\n\n    - Item 1' | npx commonmark
1303        // Output: <pre><code>- Item 1</code></pre>
1304        let content = "List:\n\n    - Item 1\n    - Item 2\n    * Item 3\n    + Item 4";
1305        let blocks = CodeBlockUtils::detect_code_blocks(content);
1306        assert_eq!(blocks.len(), 1); // This IS a code block per spec
1307
1308        // Same for numbered list markers
1309        let content = "List:\n\n    1. First\n    2. Second";
1310        let blocks = CodeBlockUtils::detect_code_blocks(content);
1311        assert_eq!(blocks.len(), 1); // This IS a code block per spec
1312    }
1313
1314    #[test]
1315    fn test_actual_list_items_not_code_blocks() {
1316        // Actual list items (no preceding blank line + 4 spaces) are NOT code blocks
1317        let content = "- Item 1\n- Item 2\n* Item 3";
1318        let blocks = CodeBlockUtils::detect_code_blocks(content);
1319        assert_eq!(blocks.len(), 0);
1320
1321        // Nested list items
1322        let content = "- Item 1\n  - Nested item\n- Item 2";
1323        let blocks = CodeBlockUtils::detect_code_blocks(content);
1324        assert_eq!(blocks.len(), 0);
1325    }
1326
1327    #[test]
1328    fn test_inline_code_spans_not_detected() {
1329        // Inline code spans should NOT be detected as code blocks
1330        let content = "Text with `inline code` here";
1331        let blocks = CodeBlockUtils::detect_code_blocks(content);
1332        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1333
1334        // Multiple backtick code span
1335        let content = "Text with ``code with ` backtick`` here";
1336        let blocks = CodeBlockUtils::detect_code_blocks(content);
1337        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1338
1339        // Multiple code spans
1340        let content = "Has `code1` and `code2` spans";
1341        let blocks = CodeBlockUtils::detect_code_blocks(content);
1342        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
1343    }
1344
1345    #[test]
1346    fn test_unclosed_code_span() {
1347        // Unclosed code span should not be detected
1348        let content = "Text with `unclosed code span";
1349        let blocks = CodeBlockUtils::detect_code_blocks(content);
1350        assert_eq!(blocks.len(), 0);
1351
1352        // Mismatched backticks
1353        let content = "Text with ``one style` different close";
1354        let blocks = CodeBlockUtils::detect_code_blocks(content);
1355        assert_eq!(blocks.len(), 0);
1356    }
1357
1358    #[test]
1359    fn test_mixed_code_blocks_and_spans() {
1360        let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
1361        let blocks = CodeBlockUtils::detect_code_blocks(content);
1362        // Should only detect the fenced block, NOT the inline spans
1363        assert_eq!(blocks.len(), 1);
1364
1365        // Check we have the fenced block only
1366        assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
1367        // Should NOT detect inline spans
1368        assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
1369        assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
1370    }
1371
1372    #[test]
1373    fn test_is_in_code_block_or_span() {
1374        let blocks = vec![(10, 20), (30, 40), (50, 60)];
1375
1376        // Test positions inside blocks
1377        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
1378        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
1379        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
1380
1381        // Test positions at boundaries
1382        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 10)); // Start is inclusive
1383        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 20)); // End is exclusive
1384
1385        // Test positions outside blocks
1386        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 5));
1387        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
1388        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
1389    }
1390
1391    #[test]
1392    fn test_empty_content() {
1393        let blocks = CodeBlockUtils::detect_code_blocks("");
1394        assert_eq!(blocks.len(), 0);
1395    }
1396
1397    #[test]
1398    fn test_code_block_at_start() {
1399        let content = "```\ncode\n```\nText after";
1400        let blocks = CodeBlockUtils::detect_code_blocks(content);
1401        // 1 fenced block (fence markers no longer detected as inline spans)
1402        assert_eq!(blocks.len(), 1);
1403        assert_eq!(blocks[0].0, 0); // Fenced block starts at 0
1404    }
1405
1406    #[test]
1407    fn test_code_block_at_end() {
1408        let content = "Text before\n```\ncode\n```";
1409        let blocks = CodeBlockUtils::detect_code_blocks(content);
1410        // 1 fenced block (fence markers no longer detected as inline spans)
1411        assert_eq!(blocks.len(), 1);
1412        // Check we have the fenced block
1413        let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
1414        assert!(fenced.is_some());
1415    }
1416
1417    #[test]
1418    fn test_nested_fence_markers() {
1419        // Code block containing fence markers as content
1420        let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1421        let blocks = CodeBlockUtils::detect_code_blocks(content);
1422        // Should detect: outer block, inner ```, outer ````
1423        assert!(!blocks.is_empty());
1424        // Check we have the outer block
1425        let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
1426        assert!(outer.is_some());
1427    }
1428
1429    #[test]
1430    fn test_indented_code_with_blank_lines() {
1431        // Indented code blocks can contain blank lines
1432        let content = "Text\n\n    line1\n\n    line2\n\nAfter";
1433        let blocks = CodeBlockUtils::detect_code_blocks(content);
1434        // May have multiple blocks due to blank line handling
1435        assert!(!blocks.is_empty());
1436        // Check that we captured the indented code
1437        let all_content: String = blocks
1438            .iter()
1439            .map(|(s, e)| &content[*s..*e])
1440            .collect::<Vec<_>>()
1441            .join("");
1442        assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
1443    }
1444
1445    #[test]
1446    fn test_code_span_with_spaces() {
1447        // Code spans should NOT be detected as code blocks
1448        let content = "Text ` code with spaces ` more";
1449        let blocks = CodeBlockUtils::detect_code_blocks(content);
1450        assert_eq!(blocks.len(), 0); // No blocks, only inline span
1451    }
1452
1453    #[test]
1454    fn test_fenced_block_with_info_string() {
1455        // Fenced code blocks with complex info strings
1456        let content = "```rust,no_run,should_panic\ncode\n```";
1457        let blocks = CodeBlockUtils::detect_code_blocks(content);
1458        // 1 fenced block (fence markers no longer detected as inline spans)
1459        assert_eq!(blocks.len(), 1);
1460        assert_eq!(blocks[0].0, 0);
1461    }
1462
1463    #[test]
1464    fn test_indented_fences_not_code_blocks() {
1465        // Indented fence markers should still work as fences
1466        let content = "Text\n  ```\n  code\n  ```\nAfter";
1467        let blocks = CodeBlockUtils::detect_code_blocks(content);
1468        // Only 1 fenced block (indented fences still work)
1469        assert_eq!(blocks.len(), 1);
1470    }
1471
1472    // Issue #175: Backticks in info string invalidate the fence
1473    #[test]
1474    fn test_backticks_in_info_string_not_code_block() {
1475        // Per CommonMark spec: "If the info string comes after a backtick fence,
1476        // it may not contain any backtick characters."
1477        // So ```something``` is NOT a valid fence - the backticks are treated as inline code.
1478        // Verified with: echo '```something```' | npx commonmark
1479        // Output: <p><code>something</code></p>
1480        let content = "```something```\n\n```bash\n# comment\n```";
1481        let blocks = CodeBlockUtils::detect_code_blocks(content);
1482        // Should find only the valid ```bash block, NOT the invalid ```something```
1483        assert_eq!(blocks.len(), 1);
1484        // The valid block should contain "# comment"
1485        assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1486    }
1487
1488    #[test]
1489    fn test_issue_175_reproduction() {
1490        // Full reproduction of issue #175
1491        let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐Ÿฆœ\"\n```";
1492        let blocks = CodeBlockUtils::detect_code_blocks(content);
1493        // Only the bash block is a code block
1494        assert_eq!(blocks.len(), 1);
1495        assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
1496    }
1497
1498    #[test]
1499    fn test_tilde_fence_allows_tildes_in_info_string() {
1500        // Tilde fences CAN have tildes in info string (only backtick restriction exists)
1501        // ~~~abc~~~ opens an unclosed code block with info string "abc~~~"
1502        let content = "~~~abc~~~\ncode content\n~~~";
1503        let blocks = CodeBlockUtils::detect_code_blocks(content);
1504        // This is a valid tilde fence that opens and closes
1505        assert_eq!(blocks.len(), 1);
1506    }
1507
1508    #[test]
1509    fn test_nested_longer_fence_contains_shorter() {
1510        // Longer fence (````) can contain shorter fence (```) as content
1511        let content = "````\n```\nnested content\n```\n````";
1512        let blocks = CodeBlockUtils::detect_code_blocks(content);
1513        assert_eq!(blocks.len(), 1);
1514        assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
1515    }
1516
1517    #[test]
1518    fn test_mixed_fence_types() {
1519        // Tilde fence contains backtick markers as content
1520        let content = "~~~\n```\nmixed content\n~~~";
1521        let blocks = CodeBlockUtils::detect_code_blocks(content);
1522        assert_eq!(blocks.len(), 1);
1523        assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
1524    }
1525
1526    #[test]
1527    fn test_indented_block_content_column_is_its_containers() {
1528        let cases = [
1529            ("Text\n\n    code\n", 0),
1530            ("- a\n\n      code\n", 2),
1531            ("1. a\n\n       code\n", 3),
1532            // The unindented paragraph ends the list, so the block is at the margin.
1533            ("1. a\n\nb\n\n       code\n", 0),
1534            // A lazy continuation keeps the item open however long it runs.
1535            ("1. a\nb\nc\nd\ne\nf\ng\n\n       code\n", 3),
1536            (">     quoted\n", 2),
1537            ("- a\n\n\t  code\n", 2),
1538            // The second tab straddles column 7, where the code begins.
1539            ("1. a\n\n\t\tcode\n", 3),
1540            (">\t\tcode\n", 2),
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, [Some(expected)], "content column for {content:?}");
1546        }
1547
1548        let fenced = CodeBlockUtils::detect_code_blocks_and_spans("- a\n\n  ```\n  code\n  ```\n");
1549        let columns: Vec<_> = fenced.code_block_details.iter().map(|d| d.content_column).collect();
1550        assert_eq!(columns, [None]);
1551    }
1552
1553    #[test]
1554    fn test_indented_block_in_rejected_definition_takes_its_columns_from_the_text_reading() {
1555        let cases = [
1556            // `:warning:` opens no definition, so the block sits at the margin.
1557            ("term\n:warning:\n\n      x\n", Some(0)),
1558            ("- a\n\n  term\n  :warning:\n\n        x\n", Some(2)),
1559            // A definition rumdl accepts is a container.
1560            ("term\n: def\n\n      x\n", Some(2)),
1561            // Under an accepted definition the text reading would drop that
1562            // container too, so the column is unknown.
1563            ("term\n: def\n\n  t2\n  :warning:\n\n        x\n", None),
1564        ];
1565        for (content, expected) in cases {
1566            let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1567            let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1568            assert_eq!(columns, [expected], "content column for {content:?}");
1569        }
1570    }
1571
1572    #[test]
1573    fn tab_indented_definition_markers_after_utf8_keep_valid_ranges() {
1574        for newline in ["\n", "\r", "\r\n"] {
1575            for term in ["", "a", "ยก", "๐Ÿฆ€"] {
1576                for text in ["b", "\u{e000}", "๐Ÿฆ€"] {
1577                    for tail in ["\t:", "\t: next", "\t: *next*", "\t: `next`"] {
1578                        let source = format!("{term}{newline}:{text}{newline}{tail}");
1579                        let result = CodeBlockUtils::detect_code_blocks_and_spans(&source);
1580                        // 0.13.5 fixes the nested marker's offset. Its valid
1581                        // colon is now accepted, while the colon touching `text`
1582                        // remains rejected. No preceding term means no list.
1583                        assert_eq!(
1584                            result.definition_items.len(),
1585                            usize::from(!term.is_empty()),
1586                            "{source:?}"
1587                        );
1588                        assert_eq!(
1589                            result.definition_texts.len(),
1590                            usize::from(!term.is_empty() && tail != "\t:"),
1591                            "{source:?}"
1592                        );
1593                        let parent_colon = term.len() + newline.len();
1594                        let nested_colon = source.len() - tail.len() + 1;
1595                        for &(start, end) in &result.definition_items {
1596                            assert_eq!((start, end), (parent_colon + 1, source.len()), "{source:?}");
1597                            assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1598                        }
1599                        for detail in &result.definition_texts {
1600                            assert!(source.get(detail.start..detail.end).is_some(), "{source:?}: {detail:?}");
1601                            assert_eq!(detail.definition_start, nested_colon, "{source:?}");
1602                        }
1603                        if !term.is_empty() {
1604                            assert!(
1605                                result
1606                                    .rejected_definition_ranges
1607                                    .iter()
1608                                    .any(|&(start, end)| start <= parent_colon && parent_colon < end),
1609                                "{source:?}"
1610                            );
1611                        }
1612                        for &(start, end) in &result.rejected_definition_ranges {
1613                            assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1614                        }
1615                        let ctx = crate::lint_context::LintContext::new(
1616                            &source,
1617                            crate::config::MarkdownFlavor::Standard,
1618                            None,
1619                        );
1620                        assert_eq!(ctx.content, source);
1621                    }
1622                }
1623            }
1624        }
1625        assert!(!super::opens_definition("ยก", 1));
1626        assert!(!super::opens_definition(": text", usize::MAX));
1627        assert!(super::opens_definition("\t: text", 0));
1628    }
1629
1630    #[test]
1631    fn test_indented_code_in_list_issue_276() {
1632        // Issue #276: Indented code block inside a list should be detected by pulldown-cmark
1633        let content = r#"1. First item
16342. Second item with code:
1635
1636        # This is a code block in a list
1637        print("Hello, world!")
1638
16394. Third item"#;
1640
1641        let blocks = CodeBlockUtils::detect_code_blocks(content);
1642        // pulldown-cmark SHOULD detect this indented code block inside the list
1643        assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1644
1645        // Verify the detected block contains our code
1646        let all_content: String = blocks
1647            .iter()
1648            .map(|(s, e)| &content[*s..*e])
1649            .collect::<Vec<_>>()
1650            .join("");
1651        assert!(
1652            all_content.contains("code block in a list") || all_content.contains("print"),
1653            "Detected block should contain the code content: {all_content:?}"
1654        );
1655    }
1656
1657    // โ”€โ”€ is_in_code_block binary search tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1658
1659    #[test]
1660    fn test_is_in_code_block_empty_blocks() {
1661        assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1662        assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1663        assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1664    }
1665
1666    #[test]
1667    fn test_is_in_code_block_single_range() {
1668        let blocks = [(10, 20)];
1669        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1670        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1671        assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1672        assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1673        assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1674        // end is exclusive
1675        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1676        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1677    }
1678
1679    #[test]
1680    fn test_is_in_code_block_multiple_ranges() {
1681        let blocks = [(5, 10), (20, 30), (50, 60)];
1682        // Before all
1683        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1684        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1685        // In first
1686        assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1687        assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1688        // Gap between first and second
1689        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1690        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1691        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1692        // In second
1693        assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1694        assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1695        // Gap between second and third
1696        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1697        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1698        // In third
1699        assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1700        assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1701        // After all
1702        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1703        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1704    }
1705
1706    #[test]
1707    fn test_is_in_code_block_adjacent_ranges() {
1708        // Ranges that are exactly adjacent (end of one == start of next)
1709        let blocks = [(0, 10), (10, 20), (20, 30)];
1710        assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1711        assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1712        assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1713        assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1714        assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1715        assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1716        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1717    }
1718
1719    #[test]
1720    fn test_is_in_code_block_single_byte_range() {
1721        let blocks = [(5, 6)];
1722        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1723        assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1724        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1725    }
1726
1727    #[test]
1728    fn test_is_in_code_block_matches_linear_scan() {
1729        // Verify binary search produces identical results to linear scan
1730        // for a realistic document layout
1731        let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1732        let blocks = CodeBlockUtils::detect_code_blocks(content);
1733
1734        for pos in 0..content.len() {
1735            let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1736            let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1737            assert_eq!(
1738                binary, linear,
1739                "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1740            );
1741        }
1742    }
1743
1744    #[test]
1745    fn test_is_in_code_block_at_range_boundaries() {
1746        // Exhaustive boundary testing for every block start/end
1747        let blocks = [(100, 200), (300, 400), (500, 600)];
1748        for &(start, end) in &blocks {
1749            assert!(
1750                !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1751                "pos={} should be outside",
1752                start - 1
1753            );
1754            assert!(
1755                CodeBlockUtils::is_in_code_block(&blocks, start),
1756                "pos={start} should be inside"
1757            );
1758            assert!(
1759                CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1760                "pos={} should be inside",
1761                end - 1
1762            );
1763            assert!(
1764                !CodeBlockUtils::is_in_code_block(&blocks, end),
1765                "pos={end} should be outside"
1766            );
1767        }
1768    }
1769}
1770
1771/// `CodeBlockDetail::closed` says whether a closing fence line ends the block,
1772/// which only the parser's reading of the fence rules settles: a last line that
1773/// looks like a fence can be code.
1774#[cfg(test)]
1775mod closed_fence_tests {
1776    use super::*;
1777
1778    fn closed_of(content: &str) -> Vec<bool> {
1779        CodeBlockUtils::detect_code_blocks_and_spans(content)
1780            .code_block_details
1781            .iter()
1782            .filter(|detail| detail.is_fenced)
1783            .map(|detail| detail.closed)
1784            .collect()
1785    }
1786
1787    #[test]
1788    fn a_block_ended_by_a_closing_fence_is_closed() {
1789        for content in [
1790            "```\nx\n```\n",
1791            "```\nx\n```",
1792            "```\n```\n",
1793            "~~~\nx\n~~~\n",
1794            "````\nx\n`````\n",
1795            "```\nx\n``` \n",
1796            "> ```\n> x\n> ```\nfoo\n",
1797            "> ```\n> ```\nfoo\n",
1798            "-\n  ```\n  x\n  ```\n",
1799            "- ```\n  x\n\n  ```\n",
1800            "```\n\n\n```\n",
1801            "   ```\nx\n   ```\n",
1802        ] {
1803            assert_eq!(closed_of(content), vec![true], "{content:?}");
1804        }
1805    }
1806
1807    #[test]
1808    fn a_block_the_document_or_container_ends_is_not_closed() {
1809        for content in [
1810            "```\nx\n",
1811            "```\nx",
1812            "```",
1813            "```\n",
1814            "> ```\nfoo\n",
1815            "> ```\n> x\nfoo\n",
1816            // Last line looks like a fence but is code.
1817            "> ```\n> ~~~\nfoo\n",
1818            "```\n~~~\n",
1819            "> ````\n> ```\nfoo\n",
1820            "> ```\n>     ```\nfoo\n",
1821            "```\n    ```\n",
1822            "> ```\n> \u{a0}```\nfoo\n",
1823            "> ```\n> ```x\nfoo\n",
1824            "> ```\n> ```\u{a0}\nfoo\n",
1825            // A last line of whitespace is inside the parser's range, and has no
1826            // text event of its own.
1827            "> ```\n> ",
1828            "```\n  ",
1829            "> ```\n> ~~~\n> ",
1830            "- ```\nfoo\n",
1831        ] {
1832            assert_eq!(closed_of(content), vec![false], "{content:?}");
1833        }
1834    }
1835}