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