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}
37
38/// Whether the fenced block spanning `start..end` of `content` ends with a
39/// closing fence line, given how many lines its literal text has.
40///
41/// A closing fence is the one line of a block that is not in its literal text,
42/// so a closed block spans two lines more than its literal holds (opener and
43/// closer) and an unclosed one a single line more. A final line holding only
44/// whitespace is inside the parser's range without a text event of its own, so
45/// the last line must also start with a fence character once quote markers and
46/// whitespace are set aside.
47fn fenced_block_is_closed(content: &str, start: usize, end: usize, literal_lines: usize) -> bool {
48    let bytes = content.as_bytes();
49    let end = end.min(bytes.len());
50    // The range of an unclosed block ends after the newline of its last line.
51    let body_end = if end > start && bytes[end - 1] == b'\n' {
52        end - 1
53    } else {
54        end
55    };
56    let body = &bytes[start.min(body_end)..body_end];
57    // Each newline starts another line, and an empty body is one line.
58    let span_lines = body.split(|&b| b == b'\n').count();
59    let line_start = body
60        .iter()
61        .rposition(|&b| b == b'\n')
62        .map_or(start, |at| start + at + 1);
63    let last_line = content.get(line_start..body_end).unwrap_or("");
64    span_lines == literal_lines + 2 && last_line.trim_start_matches(['>', ' ', '\t']).starts_with(['`', '~'])
65}
66
67/// Visual column of byte `pos`, counting from the start of its line, with a
68/// tab advancing to the next multiple of four.
69fn visual_column(content: &str, pos: usize) -> usize {
70    let line_start = content[..pos].rfind('\n').map_or(0, |i| i + 1);
71    content[line_start..pos]
72        .chars()
73        .fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 })
74}
75
76/// A strong emphasis span captured during parsing
77#[derive(Debug, Clone)]
78pub struct StrongSpanDetail {
79    /// Byte offset where the strong span starts (including **)
80    pub start: usize,
81    /// Byte offset where the strong span ends (including **)
82    pub end: usize,
83    /// Whether this uses asterisk (**) or underscore (__) markers
84    pub is_asterisk: bool,
85}
86
87/// Text a definition holds directly, captured during parsing
88///
89/// One of the definition's paragraphs, or, for a tight definition, the run of
90/// inline content the parser reports with no paragraph around it. Blocks nested
91/// in the definition (lists, blockquotes, code) hold their own text and are not
92/// part of one.
93#[derive(Debug, Clone)]
94pub struct DefinitionTextDetail {
95    /// Byte offset where the text starts
96    pub start: usize,
97    /// Byte offset where the text ends
98    pub end: usize,
99    /// Byte offset where the definition holding the text starts: its `:`
100    /// marker, or indentation before it inside a container
101    pub definition_start: usize,
102}
103
104/// Ordered list membership: maps line number (1-indexed) to list ID
105pub type LineToListMap = std::collections::HashMap<usize, usize>;
106/// Ordered list start values: maps list ID to the start value
107pub type ListStartValues = std::collections::HashMap<usize, u64>;
108
109/// Structure facts from document analysis, capturing data needed by individual rules.
110#[derive(Debug, Default)]
111pub struct ParseResult {
112    /// Code block byte ranges (start, end)
113    pub code_blocks: Vec<(usize, usize)>,
114    /// Inline code span byte ranges (start, end)
115    pub code_spans: Vec<(usize, usize)>,
116    /// Detailed code block info (fenced vs indented, info string)
117    pub code_block_details: Vec<CodeBlockDetail>,
118    /// Strong emphasis span details
119    pub strong_spans: Vec<StrongSpanDetail>,
120    /// Ordered list membership: maps line number (1-indexed) to list ID
121    pub line_to_list: LineToListMap,
122    /// Ordered list start values: maps list ID to start value
123    pub list_start_values: ListStartValues,
124    /// HTML block byte ranges (start, end)
125    ///
126    /// Needed to know how far an unclosed `<!--` reaches. The block ends where
127    /// CommonMark says it does, which is the end of the enclosing container
128    /// rather than the end of the document: an unclosed comment in a blockquote
129    /// stops at the quote, and one in a list item stops at the blank line.
130    pub html_blocks: Vec<(usize, usize)>,
131    /// Definition list item byte ranges (start, end) in document order: a
132    /// definition together with the terms before it, blank lines after it left
133    /// out, items of nested lists too. Only definitions passing
134    /// `opens_definition`, and their terms, count.
135    pub definition_items: Vec<(usize, usize)>,
136    /// Definition list term byte ranges (start, end)
137    pub definition_terms: Vec<(usize, usize)>,
138    /// Text held directly by a definition, in document order
139    pub definition_texts: Vec<DefinitionTextDetail>,
140    /// Definitions failing `opens_definition`, with their terms, in document
141    /// order, where reading them as the paragraph text rumdl takes them for
142    /// changes which inline constructs the text holds. The parse the other
143    /// fields come from reads a definition, which ends every code span, link
144    /// and emphasis running into it.
145    pub colon_definitions: Vec<ColonDefinitionDetail>,
146    /// Byte range of every table the parser reads
147    pub table_ranges: Vec<(usize, usize)>,
148    /// Byte range of every block that is not inside another block, in document
149    /// order: a paragraph, list, blockquote, table, code block, and so on
150    pub top_level_blocks: Vec<(usize, usize)>,
151    /// Byte range (terms included) of every definition failing
152    /// `opens_definition`, before `colon_definitions` drops those whose text
153    /// reads the same either way
154    pub rejected_definition_ranges: Vec<(usize, usize)>,
155    /// Byte range of every list item together with the byte offset where its
156    /// content starts, for items that have content
157    pub list_items: Vec<(usize, usize, usize)>,
158}
159
160/// A definition pulldown-cmark opens on a colon touching its text, together
161/// with the terms before it
162#[derive(Debug, Clone)]
163pub struct ColonDefinitionDetail {
164    /// Byte offset where the first term starts, or the definition when it has none
165    pub start: usize,
166    /// Byte offset where the definition ends, blank lines after it left out
167    pub end: usize,
168    /// Byte offset where the definition starts
169    pub definition_start: usize,
170}
171
172/// Classification of code blocks relative to list contexts
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum CodeBlockContext {
175    /// Code block that separates lists (root-level, with blank lines)
176    Standalone,
177    /// Code block that continues a list (properly indented)
178    Indented,
179    /// Code block adjacent to list content (edge case, defaults to non-breaking)
180    Adjacent,
181}
182
183/// Whether a tag opens inline content rather than a block
184fn is_inline_tag(tag: &Tag) -> bool {
185    matches!(
186        tag,
187        Tag::Emphasis
188            | Tag::Strong
189            | Tag::Strikethrough
190            | Tag::Superscript
191            | Tag::Subscript
192            | Tag::Link { .. }
193            | Tag::Image { .. }
194    )
195}
196
197/// Whether a tag end closes inline content rather than a block
198fn is_inline_tag_end(tag_end: TagEnd) -> bool {
199    matches!(
200        tag_end,
201        TagEnd::Emphasis
202            | TagEnd::Strong
203            | TagEnd::Strikethrough
204            | TagEnd::Superscript
205            | TagEnd::Subscript
206            | TagEnd::Link
207            | TagEnd::Image
208    )
209}
210
211/// Whether the definition starting at `start` opens with a colon followed by
212/// whitespace or the end of its line.
213///
214/// That is the marker the definition-list extensions agree on (PHP Markdown
215/// Extra, Python-Markdown, Pandoc). pulldown-cmark also opens a definition on a
216/// colon touching its text, as in `:warning:` or a `:::` fence closing a div,
217/// which those read as prose or as the fence it is.
218fn opens_definition(content: &str, start: usize) -> bool {
219    // A tab crossing a definition's container indent can make the parser's
220    // marker offset land inside the preceding UTF-8 character. Such an offset
221    // does not identify a source marker and belongs to the rejected reading.
222    let Some(rest) = content.get(start..) else {
223        return false;
224    };
225    rest.trim_start_matches([' ', '\t'])
226        .strip_prefix(':')
227        .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r']))
228}
229
230/// Every indented code block a parse with `options` finds in `content`, as
231/// its byte range and the content column of the container it sits in: four
232/// columns short of where its code begins, less the columns a tab straddling
233/// that point overshot, which the parser supplies as a leading text event
234/// with an empty range.
235fn indented_block_columns(content: &str, options: Options) -> Vec<(usize, usize, usize)> {
236    let mut blocks = Vec::new();
237    let mut open: Option<(usize, usize)> = None;
238    let mut awaiting_first_event = false;
239    for (event, range) in Parser::new_ext(content, options).into_offset_iter() {
240        if std::mem::take(&mut awaiting_first_event)
241            && let Event::Text(text) = &event
242            && range.is_empty()
243            && let Some((_, column)) = &mut open
244        {
245            *column = column.saturating_sub(text.len());
246        }
247        match event {
248            Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
249                open = Some((range.start, visual_column(content, range.start).saturating_sub(4)));
250                awaiting_first_event = true;
251            }
252            Event::End(TagEnd::CodeBlock) => {
253                if let Some((start, column)) = open.take() {
254                    blocks.push((start, range.end, column));
255                }
256            }
257            _ => {}
258        }
259    }
260    blocks
261}
262
263/// The byte ranges of the inline constructs a parse with `options` finds in
264/// `content`, in document order: code spans, math, inline HTML, and the
265/// emphasis, link and image spans holding text.
266fn inline_ranges(content: &str, options: Options) -> Vec<(usize, usize)> {
267    Parser::new_ext(content, options)
268        .into_offset_iter()
269        .filter_map(|(event, range)| match event {
270            Event::Start(tag) if is_inline_tag(&tag) => Some((range.start, range.end)),
271            Event::Code(_) | Event::InlineHtml(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {
272                Some((range.start, range.end))
273            }
274            _ => None,
275        })
276        .collect()
277}
278
279/// Read source-ordered inline ranges for source-ordered definition queries.
280/// Keep only earlier ranges that still overlap the current definition, and
281/// inspect later ranges only when they actually fall inside that definition.
282/// This also preserves nested ranges and spans crossing several definitions.
283struct InlineRangeCursor<'a> {
284    ranges: &'a [(usize, usize)],
285    next: usize,
286    active: Vec<(usize, usize)>,
287}
288
289impl<'a> InlineRangeCursor<'a> {
290    fn new(ranges: &'a [(usize, usize)]) -> Self {
291        Self {
292            ranges,
293            next: 0,
294            active: Vec::new(),
295        }
296    }
297
298    fn overlapping(&mut self, detail: &ColonDefinitionDetail) -> impl Iterator<Item = (usize, usize)> + '_ {
299        while self.next < self.ranges.len() && self.ranges[self.next].0 <= detail.start {
300            self.active.push(self.ranges[self.next]);
301            self.next += 1;
302        }
303        self.active.retain(|&(_, end)| end > detail.start);
304        let end = detail.end;
305        self.active.iter().copied().chain(
306            self.ranges[self.next..]
307                .iter()
308                .take_while(move |&&(start, _)| start < end)
309                .copied(),
310        )
311    }
312}
313
314/// Utility functions for detecting and handling code blocks in Markdown
315pub struct CodeBlockUtils;
316
317impl CodeBlockUtils {
318    /// Detect all code blocks in the content (NOT including inline code spans)
319    ///
320    /// Uses pulldown-cmark for spec-compliant CommonMark parsing. This correctly handles:
321    /// - Fenced code blocks (``` and ~~~)
322    /// - Indented code blocks (4 spaces or tab)
323    /// - Code blocks inside lists, blockquotes, and other containers
324    /// - Edge cases like backticks in info strings (which invalidate the fence)
325    ///
326    /// Returns a sorted vector of (start, end) byte offset tuples.
327    pub fn detect_code_blocks(content: &str) -> Vec<(usize, usize)> {
328        Self::detect_code_blocks_and_spans(content).code_blocks
329    }
330
331    /// Returns code block ranges, inline code span ranges, and detailed code block info
332    /// in a single pulldown-cmark pass.
333    pub fn detect_code_blocks_and_spans(content: &str) -> ParseResult {
334        let line_starts: Vec<usize> = std::iter::once(0)
335            .chain(content.match_indices('\n').map(|(i, _)| i + 1))
336            .collect();
337        let options = rumdl_parser_options_for(content);
338        Self::collect_code_blocks_and_spans(
339            content,
340            &line_starts,
341            options,
342            Parser::new_ext(content, options).into_offset_iter(),
343        )
344    }
345
346    /// Collect structure from an existing parse, sharing its events and source
347    /// line index with other document consumers. Recovery parses still use
348    /// their own options where rumdl's reading differs from the primary parse.
349    pub(crate) fn collect_code_blocks_and_spans<'a>(
350        content: &str,
351        line_starts: &[usize],
352        options: Options,
353        events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
354    ) -> ParseResult {
355        let mut blocks = Vec::new();
356        let mut spans = Vec::new();
357        let mut details = Vec::new();
358        let mut strong_spans = Vec::new();
359        let mut html_blocks = Vec::new();
360        let mut code_block_start: Option<(usize, bool, String, Option<usize>)> = None;
361        // The literal text of the code block being read, as a line count: the
362        // newlines seen, and whether the text so far ends in a partial line.
363        let mut literal_newlines = 0usize;
364        let mut literal_partial_line = false;
365        // Set while the event right after an indented block's start is unseen.
366        // When a tab straddles the column where the code begins, the parser
367        // starts the block after the tab and supplies the columns it overshot
368        // as a leading text event with an empty range.
369        let mut awaiting_first_code_event = false;
370        // One entry per open definition: whether `opens_definition` rejects
371        // it. The parser reads a rejected definition as a container, rumdl as
372        // the text it is, so an indented block inside one takes its content
373        // column from a parse without definition lists (`text_reading`).
374        let mut definition_rejected: Vec<bool> = Vec::new();
375        let mut text_reading = Vec::new();
376
377        // Definition structure. `block_stack` holds one entry per open block,
378        // the definition's start for a definition and `None` for anything else,
379        // so the innermost entry says whether content belongs to a definition
380        // directly. Inline tags never touch it.
381        //
382        // Only a definition passing `opens_definition` counts, and a term
383        // waits in `pending_terms` until a definition following it does. Extents
384        // are recorded per item rather than per list: the parser's range for a
385        // list can run over the paragraph after it, and a list can hold items
386        // that do not count between items that do.
387        let mut definition_items: Vec<(usize, usize)> = Vec::new();
388        let mut pending_terms: Vec<(usize, usize)> = Vec::new();
389        let mut definition_terms = Vec::new();
390        let mut definition_texts = Vec::new();
391        let mut colon_definitions = Vec::new();
392        let mut table_ranges: Vec<(usize, usize)> = Vec::new();
393        let mut top_level_blocks: Vec<(usize, usize)> = Vec::new();
394        let mut rejected_definition_ranges: Vec<(usize, usize)> = Vec::new();
395        let mut list_item_spans: Vec<(usize, usize, Option<usize>)> = Vec::new();
396        let mut open_items: Vec<usize> = Vec::new();
397        let mut block_stack: Vec<Option<usize>> = Vec::new();
398        let mut definition_paragraph: Option<(usize, usize)> = None;
399        let mut tight_run: Option<DefinitionTextDetail> = None;
400
401        // List membership tracking for ordered lists
402        let mut line_to_list = LineToListMap::new();
403        let mut list_start_values = ListStartValues::new();
404        let mut list_stack: Vec<(usize, bool, u64)> = Vec::new(); // (list_id, is_ordered, start_value)
405        let mut next_list_id: usize = 0;
406
407        let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) };
408
409        for (event, range) in events {
410            if let Some(&item) = open_items.last()
411                && list_item_spans[item].2.is_none()
412                && !matches!(event, Event::End(_))
413            {
414                list_item_spans[item].2 = Some(range.start);
415            }
416            if matches!(event, Event::Start(Tag::Item)) {
417                open_items.push(list_item_spans.len());
418                list_item_spans.push((range.start, range.end, None));
419            } else if matches!(event, Event::End(TagEnd::Item)) {
420                open_items.pop();
421            }
422            match &event {
423                Event::Start(tag) if !is_inline_tag(tag) => {
424                    definition_texts.extend(tight_run.take());
425                    if block_stack.is_empty() {
426                        top_level_blocks.push((range.start, range.end));
427                    }
428                    if matches!(tag, Tag::Table(_)) {
429                        table_ranges.push((range.start, range.end));
430                    }
431                    if let (Tag::Paragraph, Some(Some(definition_start))) = (tag, block_stack.last()) {
432                        definition_paragraph = Some((range.start, *definition_start));
433                    }
434                    let mut definition_start = None;
435                    match tag {
436                        Tag::DefinitionList => pending_terms.clear(),
437                        Tag::DefinitionListTitle => pending_terms.push((range.start, range.end)),
438                        Tag::DefinitionListDefinition => {
439                            // The definition's range takes in the blank lines after
440                            // it, quoted ones too, which belong to no block.
441                            let item_start = pending_terms.first().map_or(range.start, |&(start, _)| start);
442                            // Only ASCII suffix bytes are removed. The parser
443                            // can report a non-character-boundary marker start
444                            // for a tab crossing the container indentation.
445                            let item_end = content.as_bytes()[range.clone()]
446                                .iter()
447                                .rposition(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'>'))
448                                .map_or(range.start, |index| range.start + index + 1);
449                            let accepted = opens_definition(content, range.start);
450                            definition_rejected.push(!accepted);
451                            if accepted {
452                                definition_items.push((item_start, item_end));
453                                definition_terms.append(&mut pending_terms);
454                                definition_start = Some(range.start);
455                            } else {
456                                rejected_definition_ranges.push((item_start, item_end));
457                                colon_definitions.push(ColonDefinitionDetail {
458                                    start: item_start,
459                                    end: item_end,
460                                    definition_start: range.start,
461                                });
462                                pending_terms.clear();
463                            }
464                        }
465                        _ => {}
466                    }
467                    block_stack.push(definition_start);
468                }
469                Event::End(tag_end) if !is_inline_tag_end(*tag_end) => {
470                    definition_texts.extend(tight_run.take());
471                    if let (TagEnd::Paragraph, Some((start, definition_start))) = (tag_end, definition_paragraph.take())
472                    {
473                        definition_texts.push(DefinitionTextDetail {
474                            start,
475                            end: range.end,
476                            definition_start,
477                        });
478                    }
479                    if let TagEnd::DefinitionListDefinition = tag_end {
480                        definition_rejected.pop();
481                    }
482                    if let TagEnd::DefinitionList = tag_end {
483                        pending_terms.clear();
484                    }
485                    block_stack.pop();
486                }
487                // Inline content directly under a definition is a tight
488                // definition's text, which the parser does not wrap in a paragraph.
489                _ => {
490                    if let Some(Some(definition_start)) = block_stack.last() {
491                        match &mut tight_run {
492                            Some(run) => run.end = run.end.max(range.end),
493                            None => {
494                                tight_run = Some(DefinitionTextDetail {
495                                    start: range.start,
496                                    end: range.end,
497                                    definition_start: *definition_start,
498                                });
499                            }
500                        }
501                    }
502                }
503            }
504            if std::mem::take(&mut awaiting_first_code_event)
505                && let Event::Text(text) = &event
506                && range.is_empty()
507                && let Some((_, _, _, Some(column))) = &mut code_block_start
508            {
509                *column = column.saturating_sub(text.len());
510            }
511            if code_block_start.is_some()
512                && let Event::Text(text) = &event
513                && !text.is_empty()
514            {
515                literal_newlines += text.matches('\n').count();
516                literal_partial_line = !text.ends_with('\n');
517            }
518            match event {
519                Event::Start(Tag::CodeBlock(kind)) => {
520                    literal_newlines = 0;
521                    literal_partial_line = false;
522                    let (is_fenced, info_string) = match &kind {
523                        CodeBlockKind::Fenced(info) => (true, info.to_string()),
524                        CodeBlockKind::Indented => (false, String::new()),
525                    };
526                    let mut content_column =
527                        (!is_fenced).then(|| visual_column(content, range.start).saturating_sub(4));
528                    if !is_fenced && definition_rejected.contains(&true) {
529                        // Dropping definition lists would also drop an accepted
530                        // definition around the block, so its column is unknown.
531                        if !block_stack.iter().any(Option::is_some) {
532                            text_reading.push(range.start);
533                        }
534                        content_column = None;
535                    }
536                    awaiting_first_code_event = !is_fenced;
537                    code_block_start = Some((range.start, is_fenced, info_string, content_column));
538                }
539                Event::End(TagEnd::CodeBlock) => {
540                    if let Some((start, is_fenced, info_string, content_column)) = code_block_start.take() {
541                        blocks.push((start, range.end));
542                        let literal_lines = literal_newlines + usize::from(literal_partial_line);
543                        details.push(CodeBlockDetail {
544                            start,
545                            end: range.end,
546                            is_fenced,
547                            info_string,
548                            content_column,
549                            closed: is_fenced && fenced_block_is_closed(content, start, range.end, literal_lines),
550                        });
551                    }
552                }
553                Event::Start(Tag::Strong) => {
554                    if range.start + 2 <= content.len() {
555                        let is_asterisk = &content[range.start..range.start + 2] == "**";
556                        strong_spans.push(StrongSpanDetail {
557                            start: range.start,
558                            end: range.end,
559                            is_asterisk,
560                        });
561                    }
562                }
563                Event::Start(Tag::List(start_num)) => {
564                    let is_ordered = start_num.is_some();
565                    let start_value = start_num.unwrap_or(1);
566                    list_stack.push((next_list_id, is_ordered, start_value));
567                    if is_ordered {
568                        list_start_values.insert(next_list_id, start_value);
569                    }
570                    next_list_id += 1;
571                }
572                Event::End(TagEnd::List(_)) => {
573                    list_stack.pop();
574                }
575                Event::Start(Tag::Item) => {
576                    if let Some(&(list_id, is_ordered, _)) = list_stack.last()
577                        && is_ordered
578                    {
579                        let line_num = byte_to_line(range.start);
580                        line_to_list.insert(line_num, list_id);
581                    }
582                }
583                Event::Start(Tag::HtmlBlock) => {
584                    // The start event's range already spans the whole block.
585                    html_blocks.push((range.start, range.end));
586                }
587                Event::Code(_) => {
588                    spans.push((range.start, range.end));
589                }
590                _ => {}
591            }
592        }
593
594        // Handle edge case: unclosed code block at end of content
595        // pulldown-cmark should handle this, but be defensive
596        if let Some((start, is_fenced, info_string, content_column)) = code_block_start {
597            blocks.push((start, content.len()));
598            details.push(CodeBlockDetail {
599                start,
600                end: content.len(),
601                is_fenced,
602                info_string,
603                content_column,
604                closed: false,
605            });
606        }
607
608        if !text_reading.is_empty() {
609            let text_blocks = indented_block_columns(content, options - Options::ENABLE_DEFINITION_LIST);
610            for detail in details.iter_mut().filter(|d| text_reading.contains(&d.start)) {
611                let lines = (byte_to_line(detail.start), byte_to_line(detail.end.saturating_sub(1)));
612                detail.content_column = text_blocks
613                    .iter()
614                    .find(|&&(start, end, _)| (byte_to_line(start), byte_to_line(end.saturating_sub(1))) == lines)
615                    .map(|&(_, _, column)| column);
616            }
617        }
618
619        // A colon-led definition whose text holds the same inline constructs
620        // either way is read the same by both, and drops out.
621        if !colon_definitions.is_empty() {
622            let as_definitions = inline_ranges(content, options);
623            let as_text = inline_ranges(content, options - Options::ENABLE_DEFINITION_LIST);
624            let mut definition_ranges = InlineRangeCursor::new(&as_definitions);
625            let mut text_ranges = InlineRangeCursor::new(&as_text);
626            colon_definitions.retain(|detail| {
627                !definition_ranges
628                    .overlapping(detail)
629                    .eq(text_ranges.overlapping(detail))
630            });
631        }
632
633        // Sort by start position (should already be sorted, but ensure consistency)
634        blocks.sort_by_key(|&(start, _)| start);
635        spans.sort_by_key(|&(start, _)| start);
636        details.sort_by_key(|d| d.start);
637        strong_spans.sort_by_key(|s| s.start);
638        html_blocks.sort_by_key(|&(start, _)| start);
639        ParseResult {
640            definition_items,
641            definition_terms,
642            definition_texts,
643            colon_definitions,
644            code_blocks: blocks,
645            code_spans: spans,
646            code_block_details: details,
647            strong_spans,
648            line_to_list,
649            list_start_values,
650            html_blocks,
651            table_ranges,
652            top_level_blocks,
653            rejected_definition_ranges,
654            list_items: list_item_spans
655                .into_iter()
656                .filter_map(|(start, end, content)| content.map(|content| (start, end, content)))
657                .collect(),
658        }
659    }
660
661    /// Check if a position is within a code block (for compatibility)
662    pub fn is_in_code_block_or_span(blocks: &[(usize, usize)], pos: usize) -> bool {
663        Self::is_in_code_block(blocks, pos)
664    }
665
666    /// Check if a byte position falls within any of the given sorted, non-overlapping ranges.
667    ///
668    /// Uses binary search on the sorted block ranges for O(log n) lookup.
669    /// The blocks slice must be sorted by start position (as returned by
670    /// `detect_code_blocks` and `detect_code_blocks_and_spans`).
671    pub fn is_in_code_block(blocks: &[(usize, usize)], pos: usize) -> bool {
672        // Binary search: find the last block whose start <= pos
673        let idx = blocks.partition_point(|&(start, _)| start <= pos);
674        // partition_point returns the first index where start > pos,
675        // so the candidate is at idx - 1
676        idx > 0 && pos < blocks[idx - 1].1
677    }
678
679    /// Analyze code block context relative to list parsing
680    /// This is the core function implementing Design #3's three-tier classification
681    pub fn analyze_code_block_context(
682        lines: &[crate::lint_context::LineInfo],
683        line_idx: usize,
684        min_continuation_indent: usize,
685    ) -> CodeBlockContext {
686        if let Some(line_info) = lines.get(line_idx) {
687            // Rule 1: Indentation Analysis - Is it sufficiently indented for list continuation?
688            if line_info.indent >= min_continuation_indent {
689                return CodeBlockContext::Indented;
690            }
691
692            // Rule 2: Blank Line Context - Check for structural separation indicators
693            let (prev_blanks, next_blanks) = Self::count_surrounding_blank_lines(lines, line_idx);
694
695            // Rule 3: Standalone Detection - Insufficient indentation + blank line separation
696            // This is the key fix: root-level code blocks with blank lines separate lists
697            if prev_blanks > 0 || next_blanks > 0 {
698                return CodeBlockContext::Standalone;
699            }
700
701            // Rule 4: Default - Adjacent (conservative, non-breaking for edge cases)
702            CodeBlockContext::Adjacent
703        } else {
704            // Fallback for invalid line index
705            CodeBlockContext::Adjacent
706        }
707    }
708
709    /// Count blank lines before and after the given line index
710    fn count_surrounding_blank_lines(lines: &[crate::lint_context::LineInfo], line_idx: usize) -> (usize, usize) {
711        let mut prev_blanks = 0;
712        let mut next_blanks = 0;
713
714        // Count blank lines before (look backwards)
715        for i in (0..line_idx).rev() {
716            if let Some(line) = lines.get(i) {
717                if line.is_blank {
718                    prev_blanks += 1;
719                } else {
720                    break;
721                }
722            } else {
723                break;
724            }
725        }
726
727        // Count blank lines after (look forwards)
728        for i in (line_idx + 1)..lines.len() {
729            if let Some(line) = lines.get(i) {
730                if line.is_blank {
731                    next_blanks += 1;
732                } else {
733                    break;
734                }
735            } else {
736                break;
737            }
738        }
739
740        (prev_blanks, next_blanks)
741    }
742
743    /// Calculate minimum indentation required for code block to continue a list
744    /// Based on the most recent list item's marker width
745    pub fn calculate_min_continuation_indent(
746        content: &str,
747        lines: &[crate::lint_context::LineInfo],
748        current_line_idx: usize,
749    ) -> usize {
750        // Look backwards to find the most recent list item
751        for i in (0..current_line_idx).rev() {
752            if let Some(line_info) = lines.get(i) {
753                if let Some(list_item) = &line_info.list_item {
754                    // Calculate minimum continuation indent for this list item
755                    return if list_item.is_ordered {
756                        list_item.marker_column + list_item.marker.len() + 1 // +1 for space after marker
757                    } else {
758                        list_item.marker_column + 2 // Unordered lists need marker + space (min 2)
759                    };
760                }
761
762                // Stop at structural separators that would break list context
763                if line_info.heading.is_some() || Self::is_structural_separator(line_info.content(content)) {
764                    break;
765                }
766            }
767        }
768
769        0 // No list context found
770    }
771
772    /// Check if content is a structural separator (headings, horizontal rules, etc.)
773    fn is_structural_separator(content: &str) -> bool {
774        let trimmed = content.trim();
775        trimmed.starts_with("---")
776            || trimmed.starts_with("***")
777            || trimmed.starts_with("___")
778            || crate::utils::skip_context::is_table_line(trimmed)
779            || trimmed.starts_with('>') // Blockquotes
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    #[test]
786    fn test_table_ranges_and_top_level_blocks_are_recorded() {
787        let content = "para\n\n|a|b|\n|-|-|\n|1|2|\n\n- item\n\n  |c|d|\n  |-|-|\n";
788        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
789        let text = |&(start, end): &(usize, usize)| &content[start..end];
790
791        assert_eq!(parsed.table_ranges.len(), 2);
792        assert_eq!(text(&parsed.table_ranges[0]), "|a|b|\n|-|-|\n|1|2|\n");
793        assert!(text(&parsed.table_ranges[1]).starts_with("|c|d|"));
794
795        // The paragraph, the first table and the list are top level; the table
796        // inside the list item is not.
797        let top: Vec<&str> = parsed.top_level_blocks.iter().map(text).collect();
798        assert_eq!(top.len(), 3, "{top:?}");
799        assert_eq!(top[0], "para\n");
800        assert!(top[2].starts_with("- item"));
801    }
802
803    #[test]
804    fn test_list_items_record_where_their_content_starts() {
805        let content = "- - x\n\n-   y\n-\n- \n  z\n";
806        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
807        let starts: Vec<(&str, &str)> = parsed
808            .list_items
809            .iter()
810            .map(|&(start, _, content_start)| (&content[start..=start], &content[content_start..=content_start]))
811            .collect();
812        // Outer item holds the inner marker; the empty `-` item has no content.
813        assert_eq!(starts, [("-", "-"), ("-", "x"), ("-", "y"), ("-", "z")]);
814    }
815
816    #[test]
817    fn test_no_table_ranges_without_a_table() {
818        let parsed = CodeBlockUtils::detect_code_blocks_and_spans("a|b\n|-|\n\n    |a|b|\n    |-|-|\n");
819        assert!(parsed.table_ranges.is_empty());
820    }
821
822    use super::*;
823
824    #[test]
825    fn test_detect_fenced_code_blocks() {
826        // The function detects fenced blocks and inline code spans
827        // Fence markers (``` at line start) are now skipped in inline span detection
828
829        // Basic fenced code block with backticks
830        let content = "Some text\n```\ncode here\n```\nMore text";
831        let blocks = CodeBlockUtils::detect_code_blocks(content);
832        // Should find: 1 fenced block (fences are no longer detected as inline spans)
833        assert_eq!(blocks.len(), 1);
834
835        // Check that we have the fenced block
836        let fenced_block = blocks
837            .iter()
838            .find(|(start, end)| end - start > 10 && content[*start..*end].contains("code here"));
839        assert!(fenced_block.is_some());
840
841        // Fenced code block with tildes (no inline code detection for ~)
842        let content = "Some text\n~~~\ncode here\n~~~\nMore text";
843        let blocks = CodeBlockUtils::detect_code_blocks(content);
844        assert_eq!(blocks.len(), 1);
845        assert_eq!(&content[blocks[0].0..blocks[0].1], "~~~\ncode here\n~~~");
846
847        // Multiple code blocks
848        let content = "Text\n```\ncode1\n```\nMiddle\n~~~\ncode2\n~~~\nEnd";
849        let blocks = CodeBlockUtils::detect_code_blocks(content);
850        // 2 fenced blocks (fence markers no longer detected as inline spans)
851        assert_eq!(blocks.len(), 2);
852    }
853
854    #[test]
855    fn test_detect_code_blocks_with_language() {
856        // Code block with language identifier
857        let content = "Text\n```rust\nfn main() {}\n```\nMore";
858        let blocks = CodeBlockUtils::detect_code_blocks(content);
859        // 1 fenced block (fence markers no longer detected as inline spans)
860        assert_eq!(blocks.len(), 1);
861        // Check we have the full fenced block
862        let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("fn main"));
863        assert!(fenced.is_some());
864    }
865
866    #[test]
867    fn test_unclosed_code_block() {
868        // Unclosed code block should extend to end of content
869        let content = "Text\n```\ncode here\nno closing fence";
870        let blocks = CodeBlockUtils::detect_code_blocks(content);
871        assert_eq!(blocks.len(), 1);
872        assert_eq!(blocks[0].1, content.len());
873    }
874
875    #[test]
876    fn test_indented_code_blocks() {
877        // Basic indented code block
878        let content = "Paragraph\n\n    code line 1\n    code line 2\n\nMore text";
879        let blocks = CodeBlockUtils::detect_code_blocks(content);
880        assert_eq!(blocks.len(), 1);
881        assert!(content[blocks[0].0..blocks[0].1].contains("code line 1"));
882        assert!(content[blocks[0].0..blocks[0].1].contains("code line 2"));
883
884        // Indented code with tabs
885        let content = "Paragraph\n\n\tcode with tab\n\tanother line\n\nText";
886        let blocks = CodeBlockUtils::detect_code_blocks(content);
887        assert_eq!(blocks.len(), 1);
888    }
889
890    #[test]
891    fn test_indented_code_requires_blank_line() {
892        // Indented lines without preceding blank line are not code blocks
893        let content = "Paragraph\n    indented but not code\nMore text";
894        let blocks = CodeBlockUtils::detect_code_blocks(content);
895        assert_eq!(blocks.len(), 0);
896
897        // With blank line, it becomes a code block
898        let content = "Paragraph\n\n    now it's code\nMore text";
899        let blocks = CodeBlockUtils::detect_code_blocks(content);
900        assert_eq!(blocks.len(), 1);
901    }
902
903    #[test]
904    fn test_indented_content_with_list_markers_is_code_block() {
905        // Per CommonMark spec: 4-space indented content after blank line IS a code block,
906        // even if the content looks like list markers. The indentation takes precedence.
907        // Verified with: echo 'List:\n\n    - Item 1' | npx commonmark
908        // Output: <pre><code>- Item 1</code></pre>
909        let content = "List:\n\n    - Item 1\n    - Item 2\n    * Item 3\n    + Item 4";
910        let blocks = CodeBlockUtils::detect_code_blocks(content);
911        assert_eq!(blocks.len(), 1); // This IS a code block per spec
912
913        // Same for numbered list markers
914        let content = "List:\n\n    1. First\n    2. Second";
915        let blocks = CodeBlockUtils::detect_code_blocks(content);
916        assert_eq!(blocks.len(), 1); // This IS a code block per spec
917    }
918
919    #[test]
920    fn test_actual_list_items_not_code_blocks() {
921        // Actual list items (no preceding blank line + 4 spaces) are NOT code blocks
922        let content = "- Item 1\n- Item 2\n* Item 3";
923        let blocks = CodeBlockUtils::detect_code_blocks(content);
924        assert_eq!(blocks.len(), 0);
925
926        // Nested list items
927        let content = "- Item 1\n  - Nested item\n- Item 2";
928        let blocks = CodeBlockUtils::detect_code_blocks(content);
929        assert_eq!(blocks.len(), 0);
930    }
931
932    #[test]
933    fn test_inline_code_spans_not_detected() {
934        // Inline code spans should NOT be detected as code blocks
935        let content = "Text with `inline code` here";
936        let blocks = CodeBlockUtils::detect_code_blocks(content);
937        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
938
939        // Multiple backtick code span
940        let content = "Text with ``code with ` backtick`` here";
941        let blocks = CodeBlockUtils::detect_code_blocks(content);
942        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
943
944        // Multiple code spans
945        let content = "Has `code1` and `code2` spans";
946        let blocks = CodeBlockUtils::detect_code_blocks(content);
947        assert_eq!(blocks.len(), 0); // No blocks, only inline spans
948    }
949
950    #[test]
951    fn test_unclosed_code_span() {
952        // Unclosed code span should not be detected
953        let content = "Text with `unclosed code span";
954        let blocks = CodeBlockUtils::detect_code_blocks(content);
955        assert_eq!(blocks.len(), 0);
956
957        // Mismatched backticks
958        let content = "Text with ``one style` different close";
959        let blocks = CodeBlockUtils::detect_code_blocks(content);
960        assert_eq!(blocks.len(), 0);
961    }
962
963    #[test]
964    fn test_mixed_code_blocks_and_spans() {
965        let content = "Has `span1` text\n```\nblock\n```\nand `span2`";
966        let blocks = CodeBlockUtils::detect_code_blocks(content);
967        // Should only detect the fenced block, NOT the inline spans
968        assert_eq!(blocks.len(), 1);
969
970        // Check we have the fenced block only
971        assert!(blocks.iter().any(|(s, e)| content[*s..*e].contains("block")));
972        // Should NOT detect inline spans
973        assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span1`"));
974        assert!(!blocks.iter().any(|(s, e)| &content[*s..*e] == "`span2`"));
975    }
976
977    #[test]
978    fn test_is_in_code_block_or_span() {
979        let blocks = vec![(10, 20), (30, 40), (50, 60)];
980
981        // Test positions inside blocks
982        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 15));
983        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 35));
984        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 55));
985
986        // Test positions at boundaries
987        assert!(CodeBlockUtils::is_in_code_block_or_span(&blocks, 10)); // Start is inclusive
988        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 20)); // End is exclusive
989
990        // Test positions outside blocks
991        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 5));
992        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 25));
993        assert!(!CodeBlockUtils::is_in_code_block_or_span(&blocks, 65));
994    }
995
996    #[test]
997    fn test_empty_content() {
998        let blocks = CodeBlockUtils::detect_code_blocks("");
999        assert_eq!(blocks.len(), 0);
1000    }
1001
1002    #[test]
1003    fn test_code_block_at_start() {
1004        let content = "```\ncode\n```\nText after";
1005        let blocks = CodeBlockUtils::detect_code_blocks(content);
1006        // 1 fenced block (fence markers no longer detected as inline spans)
1007        assert_eq!(blocks.len(), 1);
1008        assert_eq!(blocks[0].0, 0); // Fenced block starts at 0
1009    }
1010
1011    #[test]
1012    fn test_code_block_at_end() {
1013        let content = "Text before\n```\ncode\n```";
1014        let blocks = CodeBlockUtils::detect_code_blocks(content);
1015        // 1 fenced block (fence markers no longer detected as inline spans)
1016        assert_eq!(blocks.len(), 1);
1017        // Check we have the fenced block
1018        let fenced = blocks.iter().find(|(s, e)| content[*s..*e].contains("code"));
1019        assert!(fenced.is_some());
1020    }
1021
1022    #[test]
1023    fn test_nested_fence_markers() {
1024        // Code block containing fence markers as content
1025        let content = "Text\n````\n```\nnested\n```\n````\nAfter";
1026        let blocks = CodeBlockUtils::detect_code_blocks(content);
1027        // Should detect: outer block, inner ```, outer ````
1028        assert!(!blocks.is_empty());
1029        // Check we have the outer block
1030        let outer = blocks.iter().find(|(s, e)| content[*s..*e].contains("nested"));
1031        assert!(outer.is_some());
1032    }
1033
1034    #[test]
1035    fn test_indented_code_with_blank_lines() {
1036        // Indented code blocks can contain blank lines
1037        let content = "Text\n\n    line1\n\n    line2\n\nAfter";
1038        let blocks = CodeBlockUtils::detect_code_blocks(content);
1039        // May have multiple blocks due to blank line handling
1040        assert!(!blocks.is_empty());
1041        // Check that we captured the indented code
1042        let all_content: String = blocks
1043            .iter()
1044            .map(|(s, e)| &content[*s..*e])
1045            .collect::<Vec<_>>()
1046            .join("");
1047        assert!(all_content.contains("line1") || content[blocks[0].0..blocks[0].1].contains("line1"));
1048    }
1049
1050    #[test]
1051    fn test_code_span_with_spaces() {
1052        // Code spans should NOT be detected as code blocks
1053        let content = "Text ` code with spaces ` more";
1054        let blocks = CodeBlockUtils::detect_code_blocks(content);
1055        assert_eq!(blocks.len(), 0); // No blocks, only inline span
1056    }
1057
1058    #[test]
1059    fn test_fenced_block_with_info_string() {
1060        // Fenced code blocks with complex info strings
1061        let content = "```rust,no_run,should_panic\ncode\n```";
1062        let blocks = CodeBlockUtils::detect_code_blocks(content);
1063        // 1 fenced block (fence markers no longer detected as inline spans)
1064        assert_eq!(blocks.len(), 1);
1065        assert_eq!(blocks[0].0, 0);
1066    }
1067
1068    #[test]
1069    fn test_indented_fences_not_code_blocks() {
1070        // Indented fence markers should still work as fences
1071        let content = "Text\n  ```\n  code\n  ```\nAfter";
1072        let blocks = CodeBlockUtils::detect_code_blocks(content);
1073        // Only 1 fenced block (indented fences still work)
1074        assert_eq!(blocks.len(), 1);
1075    }
1076
1077    // Issue #175: Backticks in info string invalidate the fence
1078    #[test]
1079    fn test_backticks_in_info_string_not_code_block() {
1080        // Per CommonMark spec: "If the info string comes after a backtick fence,
1081        // it may not contain any backtick characters."
1082        // So ```something``` is NOT a valid fence - the backticks are treated as inline code.
1083        // Verified with: echo '```something```' | npx commonmark
1084        // Output: <p><code>something</code></p>
1085        let content = "```something```\n\n```bash\n# comment\n```";
1086        let blocks = CodeBlockUtils::detect_code_blocks(content);
1087        // Should find only the valid ```bash block, NOT the invalid ```something```
1088        assert_eq!(blocks.len(), 1);
1089        // The valid block should contain "# comment"
1090        assert!(content[blocks[0].0..blocks[0].1].contains("# comment"));
1091    }
1092
1093    #[test]
1094    fn test_issue_175_reproduction() {
1095        // Full reproduction of issue #175
1096        let content = "```something```\n\n```bash\n# Have a parrot\necho \"๐Ÿฆœ\"\n```";
1097        let blocks = CodeBlockUtils::detect_code_blocks(content);
1098        // Only the bash block is a code block
1099        assert_eq!(blocks.len(), 1);
1100        assert!(content[blocks[0].0..blocks[0].1].contains("Have a parrot"));
1101    }
1102
1103    #[test]
1104    fn test_tilde_fence_allows_tildes_in_info_string() {
1105        // Tilde fences CAN have tildes in info string (only backtick restriction exists)
1106        // ~~~abc~~~ opens an unclosed code block with info string "abc~~~"
1107        let content = "~~~abc~~~\ncode content\n~~~";
1108        let blocks = CodeBlockUtils::detect_code_blocks(content);
1109        // This is a valid tilde fence that opens and closes
1110        assert_eq!(blocks.len(), 1);
1111    }
1112
1113    #[test]
1114    fn test_nested_longer_fence_contains_shorter() {
1115        // Longer fence (````) can contain shorter fence (```) as content
1116        let content = "````\n```\nnested content\n```\n````";
1117        let blocks = CodeBlockUtils::detect_code_blocks(content);
1118        assert_eq!(blocks.len(), 1);
1119        assert!(content[blocks[0].0..blocks[0].1].contains("nested content"));
1120    }
1121
1122    #[test]
1123    fn test_mixed_fence_types() {
1124        // Tilde fence contains backtick markers as content
1125        let content = "~~~\n```\nmixed content\n~~~";
1126        let blocks = CodeBlockUtils::detect_code_blocks(content);
1127        assert_eq!(blocks.len(), 1);
1128        assert!(content[blocks[0].0..blocks[0].1].contains("mixed content"));
1129    }
1130
1131    #[test]
1132    fn test_indented_block_content_column_is_its_containers() {
1133        let cases = [
1134            ("Text\n\n    code\n", 0),
1135            ("- a\n\n      code\n", 2),
1136            ("1. a\n\n       code\n", 3),
1137            // The unindented paragraph ends the list, so the block is at the margin.
1138            ("1. a\n\nb\n\n       code\n", 0),
1139            // A lazy continuation keeps the item open however long it runs.
1140            ("1. a\nb\nc\nd\ne\nf\ng\n\n       code\n", 3),
1141            (">     quoted\n", 2),
1142            ("- a\n\n\t  code\n", 2),
1143            // The second tab straddles column 7, where the code begins.
1144            ("1. a\n\n\t\tcode\n", 3),
1145            (">\t\tcode\n", 2),
1146        ];
1147        for (content, expected) in cases {
1148            let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1149            let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1150            assert_eq!(columns, [Some(expected)], "content column for {content:?}");
1151        }
1152
1153        let fenced = CodeBlockUtils::detect_code_blocks_and_spans("- a\n\n  ```\n  code\n  ```\n");
1154        let columns: Vec<_> = fenced.code_block_details.iter().map(|d| d.content_column).collect();
1155        assert_eq!(columns, [None]);
1156    }
1157
1158    #[test]
1159    fn test_indented_block_in_rejected_definition_takes_its_columns_from_the_text_reading() {
1160        let cases = [
1161            // `:warning:` opens no definition, so the block sits at the margin.
1162            ("term\n:warning:\n\n      x\n", Some(0)),
1163            ("- a\n\n  term\n  :warning:\n\n        x\n", Some(2)),
1164            // A definition rumdl accepts is a container.
1165            ("term\n: def\n\n      x\n", Some(2)),
1166            // Under an accepted definition the text reading would drop that
1167            // container too, so the column is unknown.
1168            ("term\n: def\n\n  t2\n  :warning:\n\n        x\n", None),
1169        ];
1170        for (content, expected) in cases {
1171            let result = CodeBlockUtils::detect_code_blocks_and_spans(content);
1172            let columns: Vec<_> = result.code_block_details.iter().map(|d| d.content_column).collect();
1173            assert_eq!(columns, [expected], "content column for {content:?}");
1174        }
1175    }
1176
1177    #[test]
1178    fn definition_marker_inside_utf8_keeps_the_rejected_source_reading() {
1179        for newline in ["\n", "\r", "\r\n"] {
1180            for term in ["", "a", "ยก", "๐Ÿฆ€"] {
1181                for text in ["b", "\u{e000}", "๐Ÿฆ€"] {
1182                    for tail in ["\t:", "\t: next", "\t: *next*", "\t: `next`"] {
1183                        let source = format!("{term}{newline}:{text}{newline}{tail}");
1184                        let result = CodeBlockUtils::detect_code_blocks_and_spans(&source);
1185                        assert!(result.definition_items.is_empty(), "{source:?}");
1186                        assert!(result.definition_texts.is_empty(), "{source:?}");
1187                        for &(start, end) in &result.rejected_definition_ranges {
1188                            assert!(source.get(start..end).is_some(), "{source:?}: {start}..{end}");
1189                        }
1190                        let ctx = crate::lint_context::LintContext::new(
1191                            &source,
1192                            crate::config::MarkdownFlavor::Standard,
1193                            None,
1194                        );
1195                        assert_eq!(ctx.content, source);
1196                    }
1197                }
1198            }
1199        }
1200        assert!(!super::opens_definition("ยก", 1));
1201        assert!(!super::opens_definition(": text", usize::MAX));
1202        assert!(super::opens_definition("\t: text", 0));
1203    }
1204
1205    #[test]
1206    fn test_indented_code_in_list_issue_276() {
1207        // Issue #276: Indented code block inside a list should be detected by pulldown-cmark
1208        let content = r#"1. First item
12092. Second item with code:
1210
1211        # This is a code block in a list
1212        print("Hello, world!")
1213
12144. Third item"#;
1215
1216        let blocks = CodeBlockUtils::detect_code_blocks(content);
1217        // pulldown-cmark SHOULD detect this indented code block inside the list
1218        assert!(!blocks.is_empty(), "Should detect indented code block inside list");
1219
1220        // Verify the detected block contains our code
1221        let all_content: String = blocks
1222            .iter()
1223            .map(|(s, e)| &content[*s..*e])
1224            .collect::<Vec<_>>()
1225            .join("");
1226        assert!(
1227            all_content.contains("code block in a list") || all_content.contains("print"),
1228            "Detected block should contain the code content: {all_content:?}"
1229        );
1230    }
1231
1232    // โ”€โ”€ is_in_code_block binary search tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1233
1234    #[test]
1235    fn test_is_in_code_block_empty_blocks() {
1236        assert!(!CodeBlockUtils::is_in_code_block(&[], 0));
1237        assert!(!CodeBlockUtils::is_in_code_block(&[], 100));
1238        assert!(!CodeBlockUtils::is_in_code_block(&[], usize::MAX));
1239    }
1240
1241    #[test]
1242    fn test_is_in_code_block_single_range() {
1243        let blocks = [(10, 20)];
1244        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1245        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 9));
1246        assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1247        assert!(CodeBlockUtils::is_in_code_block(&blocks, 15));
1248        assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1249        // end is exclusive
1250        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 20));
1251        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 21));
1252    }
1253
1254    #[test]
1255    fn test_is_in_code_block_multiple_ranges() {
1256        let blocks = [(5, 10), (20, 30), (50, 60)];
1257        // Before all
1258        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 0));
1259        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1260        // In first
1261        assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1262        assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1263        // Gap between first and second
1264        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 10));
1265        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 15));
1266        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 19));
1267        // In second
1268        assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1269        assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1270        // Gap between second and third
1271        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1272        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 49));
1273        // In third
1274        assert!(CodeBlockUtils::is_in_code_block(&blocks, 50));
1275        assert!(CodeBlockUtils::is_in_code_block(&blocks, 59));
1276        // After all
1277        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 60));
1278        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 1000));
1279    }
1280
1281    #[test]
1282    fn test_is_in_code_block_adjacent_ranges() {
1283        // Ranges that are exactly adjacent (end of one == start of next)
1284        let blocks = [(0, 10), (10, 20), (20, 30)];
1285        assert!(CodeBlockUtils::is_in_code_block(&blocks, 0));
1286        assert!(CodeBlockUtils::is_in_code_block(&blocks, 9));
1287        assert!(CodeBlockUtils::is_in_code_block(&blocks, 10));
1288        assert!(CodeBlockUtils::is_in_code_block(&blocks, 19));
1289        assert!(CodeBlockUtils::is_in_code_block(&blocks, 20));
1290        assert!(CodeBlockUtils::is_in_code_block(&blocks, 29));
1291        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 30));
1292    }
1293
1294    #[test]
1295    fn test_is_in_code_block_single_byte_range() {
1296        let blocks = [(5, 6)];
1297        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 4));
1298        assert!(CodeBlockUtils::is_in_code_block(&blocks, 5));
1299        assert!(!CodeBlockUtils::is_in_code_block(&blocks, 6));
1300    }
1301
1302    #[test]
1303    fn test_is_in_code_block_matches_linear_scan() {
1304        // Verify binary search produces identical results to linear scan
1305        // for a realistic document layout
1306        let content = "# Heading\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nSome text\n\n```\nmore code\n```\n\nEnd\n";
1307        let blocks = CodeBlockUtils::detect_code_blocks(content);
1308
1309        for pos in 0..content.len() {
1310            let binary = CodeBlockUtils::is_in_code_block(&blocks, pos);
1311            let linear = blocks.iter().any(|&(s, e)| pos >= s && pos < e);
1312            assert_eq!(
1313                binary, linear,
1314                "Mismatch at pos {pos}: binary={binary}, linear={linear}, blocks={blocks:?}"
1315            );
1316        }
1317    }
1318
1319    #[test]
1320    fn test_is_in_code_block_at_range_boundaries() {
1321        // Exhaustive boundary testing for every block start/end
1322        let blocks = [(100, 200), (300, 400), (500, 600)];
1323        for &(start, end) in &blocks {
1324            assert!(
1325                !CodeBlockUtils::is_in_code_block(&blocks, start - 1),
1326                "pos={} should be outside",
1327                start - 1
1328            );
1329            assert!(
1330                CodeBlockUtils::is_in_code_block(&blocks, start),
1331                "pos={start} should be inside"
1332            );
1333            assert!(
1334                CodeBlockUtils::is_in_code_block(&blocks, end - 1),
1335                "pos={} should be inside",
1336                end - 1
1337            );
1338            assert!(
1339                !CodeBlockUtils::is_in_code_block(&blocks, end),
1340                "pos={end} should be outside"
1341            );
1342        }
1343    }
1344}
1345
1346/// `CodeBlockDetail::closed` says whether a closing fence line ends the block,
1347/// which only the parser's reading of the fence rules settles: a last line that
1348/// looks like a fence can be code.
1349#[cfg(test)]
1350mod closed_fence_tests {
1351    use super::*;
1352
1353    fn closed_of(content: &str) -> Vec<bool> {
1354        CodeBlockUtils::detect_code_blocks_and_spans(content)
1355            .code_block_details
1356            .iter()
1357            .filter(|detail| detail.is_fenced)
1358            .map(|detail| detail.closed)
1359            .collect()
1360    }
1361
1362    #[test]
1363    fn a_block_ended_by_a_closing_fence_is_closed() {
1364        for content in [
1365            "```\nx\n```\n",
1366            "```\nx\n```",
1367            "```\n```\n",
1368            "~~~\nx\n~~~\n",
1369            "````\nx\n`````\n",
1370            "```\nx\n``` \n",
1371            "> ```\n> x\n> ```\nfoo\n",
1372            "> ```\n> ```\nfoo\n",
1373            "-\n  ```\n  x\n  ```\n",
1374            "- ```\n  x\n\n  ```\n",
1375            "```\n\n\n```\n",
1376            "   ```\nx\n   ```\n",
1377        ] {
1378            assert_eq!(closed_of(content), vec![true], "{content:?}");
1379        }
1380    }
1381
1382    #[test]
1383    fn a_block_the_document_or_container_ends_is_not_closed() {
1384        for content in [
1385            "```\nx\n",
1386            "```\nx",
1387            "```",
1388            "```\n",
1389            "> ```\nfoo\n",
1390            "> ```\n> x\nfoo\n",
1391            // Last line looks like a fence but is code.
1392            "> ```\n> ~~~\nfoo\n",
1393            "```\n~~~\n",
1394            "> ````\n> ```\nfoo\n",
1395            "> ```\n>     ```\nfoo\n",
1396            "```\n    ```\n",
1397            "> ```\n> \u{a0}```\nfoo\n",
1398            "> ```\n> ```x\nfoo\n",
1399            "> ```\n> ```\u{a0}\nfoo\n",
1400            // A last line of whitespace is inside the parser's range, and has no
1401            // text event of its own.
1402            "> ```\n> ",
1403            "```\n  ",
1404            "> ```\n> ~~~\n> ",
1405            "- ```\nfoo\n",
1406        ] {
1407            assert_eq!(closed_of(content), vec![false], "{content:?}");
1408        }
1409    }
1410}