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