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