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