Skip to main content

marco_core/parser/blocks/
mod.rs

1//! Block-level parser modules.
2//!
3//! This layer converts block grammar outputs into AST nodes with positions.
4
5/// Shared block parser utilities.
6pub mod shared;
7
8/// CommonMark blockquote parser.
9pub mod cm_blockquote_parser;
10/// CommonMark fenced code block parser.
11pub mod cm_fenced_code_block_parser;
12/// CommonMark heading parsers.
13pub mod cm_heading_parser;
14/// CommonMark HTML block parser.
15pub mod cm_html_blocks_parser;
16/// CommonMark indented code block parser.
17pub mod cm_indented_code_block_parser;
18/// CommonMark link reference definition parser.
19pub mod cm_link_reference_parser;
20/// CommonMark list parser.
21pub mod cm_list_parser;
22/// CommonMark paragraph parser.
23pub mod cm_paragraph_parser;
24/// CommonMark thematic break parser.
25pub mod cm_thematic_break_parser;
26/// GFM alert/admonition post-processing.
27pub mod gfm_admonitions;
28/// GFM footnote definition parser.
29pub mod gfm_footnote_definition_parser;
30/// GFM table parser.
31pub mod gfm_table_parser;
32/// Extended headerless table parser.
33pub mod marco_headerless_table_parser;
34/// Extended slide deck parser.
35pub mod marco_sliders_parser;
36/// Extended tab block parser.
37pub mod marco_tab_blocks_parser;
38/// Shared primitives for deferring inline parsing under `parallel-parse`.
39#[cfg(feature = "parallel-parse")]
40pub(crate) mod parallel_inline;
41
42/// Re-export shared block parser utilities.
43pub use shared::{dedent_list_item_content, to_parser_span, to_parser_span_range, GrammarSpan};
44
45use super::ast::Document;
46use crate::grammar::blocks as grammar;
47use crate::parser::ast::{Node, NodeKind};
48use nom::Input;
49
50// ============================================================================
51// BlockContext: Track open blocks for continuation across blank lines
52// ============================================================================
53
54/// Type of block that's currently open
55#[derive(Debug, Clone, PartialEq)]
56enum BlockContextKind {
57    /// Individual list item within a list
58    /// content_indent: minimum spaces required for content continuation
59    ListItem { content_indent: usize },
60}
61
62/// Represents an open block that can accept continuation content
63#[derive(Debug, Clone)]
64struct BlockContext {
65    kind: BlockContextKind,
66}
67
68impl BlockContext {
69    /// Create a new list item context with the given content indent
70    pub fn new_list_item(content_indent: usize) -> Self {
71        Self {
72            kind: BlockContextKind::ListItem { content_indent },
73        }
74    }
75
76    /// Check if this block can continue at the given indent level
77    fn can_continue_at(&self, indent: usize) -> bool {
78        match self.kind {
79            BlockContextKind::ListItem { content_indent } => {
80                // List item content must be indented at least to content_indent
81                indent >= content_indent
82            }
83        }
84    }
85}
86
87// ============================================================================
88// ParserState: Stack of open blocks for context-aware parsing
89// ============================================================================
90
91/// Track all currently open block contexts
92struct ParserState {
93    blocks: Vec<BlockContext>,
94    allow_tab_blocks: bool,
95    allow_sliders: bool,
96}
97
98impl ParserState {
99    fn new() -> Self {
100        Self {
101            blocks: Vec::new(),
102            allow_tab_blocks: true,
103            allow_sliders: true,
104        }
105    }
106
107    fn new_with_tab_blocks(allow_tab_blocks: bool) -> Self {
108        Self {
109            blocks: Vec::new(),
110            allow_tab_blocks,
111            allow_sliders: true,
112        }
113    }
114
115    fn new_with_sliders(allow_sliders: bool) -> Self {
116        Self {
117            blocks: Vec::new(),
118            allow_tab_blocks: true,
119            allow_sliders,
120        }
121    }
122
123    /// Add a new block context to the stack
124    pub fn push_block(&mut self, context: BlockContext) {
125        self.blocks.push(context);
126    }
127
128    /// Remove and return the most recent block context
129    fn pop_block(&mut self) -> Option<BlockContext> {
130        self.blocks.pop()
131    }
132
133    /// Check if the current context can continue at the given indent
134    fn can_continue_at(&self, indent: usize) -> bool {
135        if let Some(context) = self.blocks.last() {
136            context.can_continue_at(indent)
137        } else {
138            // No context, can't continue
139            false
140        }
141    }
142
143    /// Close blocks that can't continue at the given indent
144    /// Returns the number of blocks closed
145    fn close_blocks_until_indent(&mut self, indent: usize) -> usize {
146        let mut closed = 0;
147
148        // Close blocks from innermost to outermost
149        while let Some(context) = self.blocks.last() {
150            if context.can_continue_at(indent) {
151                // This block can continue, stop closing
152                break;
153            } else {
154                // This block can't continue, close it
155                self.blocks.pop();
156                closed += 1;
157            }
158        }
159
160        closed
161    }
162}
163
164// ============================================================================
165// Main block parser entry point
166// ============================================================================
167
168/// Parse document into block-level structure, returning a Document
169pub fn parse_blocks(input: &str) -> Result<Document, Box<dyn std::error::Error>> {
170    let mut state = ParserState::new();
171    parse_blocks_internal(input, 0, &mut state)
172}
173
174// Internal parser with recursion depth limit and state tracking
175fn parse_blocks_internal(
176    input: &str,
177    depth: usize,
178    state: &mut ParserState,
179) -> Result<Document, Box<dyn std::error::Error>> {
180    // Prevent infinite recursion
181    const MAX_DEPTH: usize = 100;
182    if depth > MAX_DEPTH {
183        log::warn!("Maximum recursion depth reached in block parser");
184        return Ok(Document::new());
185    }
186
187    log::debug!(
188        "Block parser input: {} bytes at depth {}, state depth: {}",
189        input.len(),
190        depth,
191        state.blocks.len()
192    );
193
194    let mut nodes = Vec::new();
195    let mut document = Document::new(); // Create document early to collect references
196    let mut remaining = GrammarSpan::new(input);
197
198    // Paragraphs and footnote-definition bodies (both leaf-level, both
199    // built directly in this loop) defer inline parsing and accumulate
200    // here; resolved and patched back into `nodes` once, right before this
201    // function returns — see `parallel_inline` module docs for why this is
202    // safe and why it's keyed by position, not node identity.
203    #[cfg(feature = "parallel-parse")]
204    let mut pending_leaves: Vec<parallel_inline::PendingLeaf<'_>> = Vec::new();
205
206    // Safety: prevent infinite loops.
207    // This must be high enough for real documents; the progress-check below is the
208    // primary safety mechanism.
209    let max_iterations = input.lines().count().saturating_mul(8).max(1_000);
210    let mut iteration_count = 0;
211    let mut last_offset = 0;
212
213    while !remaining.fragment().is_empty() {
214        iteration_count += 1;
215        if iteration_count > max_iterations {
216            log::error!(
217                "Block parser exceeded iteration limit ({}) at depth {}",
218                max_iterations,
219                depth
220            );
221            break;
222        }
223
224        // Safety: ensure we're making progress
225        let current_offset = remaining.location_offset();
226        if current_offset == last_offset && iteration_count > 1 {
227            log::error!(
228                "Block parser not making progress at offset {}, depth {}",
229                current_offset,
230                depth
231            );
232            // Force skip one character, while preserving span offsets.
233            use nom::bytes::complete::take;
234            let skip_len = remaining
235                .fragment()
236                .chars()
237                .next()
238                .map(|c| c.len_utf8())
239                .unwrap_or(1);
240            if let Ok((rest, _)) =
241                take::<_, _, nom::error::Error<GrammarSpan>>(skip_len as u32)(remaining)
242            {
243                remaining = rest;
244                last_offset = remaining.location_offset();
245                continue;
246            }
247            break;
248        }
249        last_offset = current_offset;
250
251        // ========================================================================
252        // BLANK LINE HANDLING WITH CONTEXT AWARENESS (Example 307 fix)
253        // ========================================================================
254        // Extract the first line to check if it's blank
255        let first_line_end = remaining
256            .fragment()
257            .find('\n')
258            .unwrap_or(remaining.fragment().len());
259        let first_line = &remaining.fragment()[..first_line_end];
260
261        // A line is blank per CommonMark spec: only ASCII space (U+0020) and tab (U+0009).
262        // Notably, U+00A0 NO-BREAK SPACE is NOT a blank line — it produces a spacer paragraph.
263        if first_line.chars().all(|c| c == ' ' || c == '\t') {
264            // Peek at the next non-blank line to determine continuation
265            let peek_offset = if first_line_end < remaining.fragment().len() {
266                first_line_end + 1
267            } else {
268                first_line_end
269            };
270
271            // Find the next non-blank line
272            let mut next_nonblank_indent: Option<usize> = None;
273            let rest_of_input = &remaining.fragment()[peek_offset..];
274
275            for peek_line in rest_of_input.lines() {
276                if !peek_line.trim().is_empty() {
277                    // Count leading spaces (expand tabs)
278                    let mut indent = 0;
279                    for ch in peek_line.chars() {
280                        if ch == ' ' {
281                            indent += 1;
282                        } else if ch == '\t' {
283                            indent += 4 - (indent % 4); // Tab to next multiple of 4
284                        } else {
285                            break;
286                        }
287                    }
288                    next_nonblank_indent = Some(indent);
289                    break;
290                }
291            }
292
293            // Determine if we should preserve context or close blocks
294            let should_continue = if let Some(next_indent) = next_nonblank_indent {
295                // Check if the next content can continue the current context
296                state.can_continue_at(next_indent)
297            } else {
298                // No more content, close all contexts
299                false
300            };
301
302            if should_continue {
303                // Blank line continues the current block
304                // Skip the blank but preserve block context
305                log::debug!(
306                    "Blank line: continuing context at indent {:?}",
307                    next_nonblank_indent
308                );
309
310                use nom::bytes::complete::take;
311                let skip_len = if first_line_end < remaining.fragment().len() {
312                    first_line_end + 1 // Include newline
313                } else {
314                    first_line_end
315                };
316
317                if let Ok((new_remaining, _)) =
318                    take::<_, _, nom::error::Error<GrammarSpan>>(skip_len as u32)(remaining)
319                {
320                    remaining = new_remaining;
321                    continue;
322                } else {
323                    break;
324                }
325            } else {
326                // Blank line ends the current context(s)
327                // Close blocks that can't continue at the next indent
328                if let Some(next_indent) = next_nonblank_indent {
329                    let closed = state.close_blocks_until_indent(next_indent);
330                    log::debug!(
331                        "Blank line: closed {} blocks due to indent {}",
332                        closed,
333                        next_indent
334                    );
335                } else {
336                    // No more content, close everything
337                    log::debug!("Blank line: end of input, closing all blocks");
338                    while state.pop_block().is_some() {}
339                }
340
341                // Skip the blank line and continue parsing
342                use nom::bytes::complete::take;
343                let skip_len = if first_line_end < remaining.fragment().len() {
344                    first_line_end + 1
345                } else {
346                    first_line_end
347                };
348
349                if let Ok((new_remaining, _)) =
350                    take::<_, _, nom::error::Error<GrammarSpan>>(skip_len as u32)(remaining)
351                {
352                    remaining = new_remaining;
353                    continue;
354                } else {
355                    break;
356                }
357            }
358        }
359
360        // Try parsing HTML blocks (types 1-7, in order)
361        // Type 1: Special raw content tags (script, pre, style, textarea)
362        if let Ok((rest, content)) = grammar::html_special_tag(remaining) {
363            nodes.push(cm_html_blocks_parser::parse_html_block(content));
364            remaining = rest;
365            continue;
366        }
367
368        // Type 2: HTML comments
369        if let Ok((rest, content)) = grammar::html_comment(remaining) {
370            nodes.push(cm_html_blocks_parser::parse_html_block(content));
371            remaining = rest;
372            continue;
373        }
374
375        // Type 3: Processing instructions
376        if let Ok((rest, content)) = grammar::html_processing_instruction(remaining) {
377            nodes.push(cm_html_blocks_parser::parse_html_block(content));
378            remaining = rest;
379            continue;
380        }
381
382        // Type 4: Declarations
383        if let Ok((rest, content)) = grammar::html_declaration(remaining) {
384            nodes.push(cm_html_blocks_parser::parse_html_block(content));
385            remaining = rest;
386            continue;
387        }
388
389        // Type 5: CDATA sections
390        if let Ok((rest, content)) = grammar::html_cdata(remaining) {
391            nodes.push(cm_html_blocks_parser::parse_html_block(content));
392            remaining = rest;
393            continue;
394        }
395
396        // Type 6: Standard block tags (div, table, etc.)
397        if let Ok((rest, content)) = grammar::html_block_tag(remaining) {
398            nodes.push(cm_html_blocks_parser::parse_html_block(content));
399            remaining = rest;
400            continue;
401        }
402
403        // Type 7: Complete tags (CANNOT interrupt paragraphs)
404        // Try this but it will fail if we're in the middle of paragraph text
405        if let Ok((rest, content)) = grammar::html_complete_tag(remaining) {
406            nodes.push(cm_html_blocks_parser::parse_html_block(content));
407            remaining = rest;
408            continue;
409        } // Try parsing heading
410        if let Ok((rest, (level, content))) = grammar::heading(remaining) {
411            nodes.push(cm_heading_parser::parse_atx_heading(level, content));
412            remaining = rest;
413            continue;
414        }
415
416        // Try parsing fenced code block
417        if let Ok((rest, (language, content))) = grammar::fenced_code_block(remaining) {
418            nodes.push(cm_fenced_code_block_parser::parse_fenced_code_block(
419                language, content,
420            ));
421            remaining = rest;
422            continue;
423        }
424
425        // Try parsing thematic break (---, ***, ___)
426        if let Ok((rest, content)) = grammar::thematic_break(remaining) {
427            nodes.push(cm_thematic_break_parser::parse_thematic_break(content));
428            remaining = rest;
429            continue;
430        }
431
432        // Try parsing block quote (lines starting with >)
433        if let Ok((rest, content)) = grammar::blockquote(remaining) {
434            let node =
435                cm_blockquote_parser::parse_blockquote(content, depth, |cleaned, new_depth| {
436                    parse_blocks_internal(cleaned, new_depth, state)
437                })?;
438
439            nodes.push(node);
440            remaining = rest;
441            continue;
442        }
443
444        // Try parsing indented code block (4 spaces or 1 tab)
445        // NOTE: Must come BEFORE lists to avoid indented code being consumed as list content
446        if let Ok((rest, content)) = grammar::indented_code_block(remaining) {
447            nodes.push(cm_indented_code_block_parser::parse_indented_code_block(
448                content,
449            ));
450            remaining = rest;
451            continue;
452        }
453
454        // Try parsing list
455        // NOTE: Must come BEFORE setext heading to avoid "---" being parsed as underline
456        if let Ok((rest, items)) = grammar::list(remaining) {
457            let node = cm_list_parser::parse_list(
458                items,
459                depth,
460                parse_blocks_internal,
461                |content_indent| {
462                    let mut item_state = ParserState::new();
463                    item_state.push_block(BlockContext::new_list_item(content_indent));
464                    item_state
465                },
466            )?;
467
468            nodes.push(node);
469            remaining = rest;
470            continue;
471        }
472
473        // Try parsing extended slide decks.
474        // Must come BEFORE setext heading. Otherwise, the internal `---` / `--`
475        // separators can be consumed as setext underlines and the deck is lost.
476        if state.allow_sliders {
477            let deck_start = remaining;
478            if let Ok((rest, deck)) = grammar::marco_slide_deck(remaining) {
479                let node = marco_sliders_parser::parse_marco_slide_deck(
480                    deck,
481                    deck_start,
482                    rest,
483                    depth,
484                    |slide_body, new_depth| {
485                        // Slides support arbitrary markdown, but nested
486                        // `@slidestart` decks are disallowed.
487                        let mut slide_state = ParserState::new_with_sliders(false);
488                        parse_blocks_internal(slide_body, new_depth, &mut slide_state)
489                    },
490                )?;
491
492                nodes.push(node);
493                remaining = rest;
494                continue;
495            }
496        }
497
498        // Try parsing Setext heading (underline style: === or ---)
499        // NOTE: Must come AFTER lists to avoid eating list marker patterns like "- foo\n---"
500        let full_start = remaining;
501        if let Ok((rest, (level, content))) = grammar::setext_heading(remaining) {
502            let full_end = rest;
503            nodes.push(cm_heading_parser::parse_setext_heading(
504                level, content, full_start, full_end,
505            ));
506            remaining = rest;
507            continue;
508        }
509
510        // Try parsing link reference definition
511        // Must come BEFORE paragraph to avoid treating definitions as paragraphs
512        #[cfg(feature = "parallel-parse")]
513        if depth == 0 {
514            if let Some((rest, node, content)) =
515                gfm_footnote_definition_parser::parse_footnote_definition_shape(remaining)
516            {
517                let node_index = nodes.len();
518                if !content.is_empty() {
519                    pending_leaves.push(parallel_inline::PendingLeaf {
520                        node_index,
521                        nested_child_index: Some(0),
522                        segments: vec![parallel_inline::Segment::Pending(
523                            parallel_inline::PendingSpan::Owned(content),
524                        )],
525                    });
526                }
527                nodes.push(node);
528                remaining = rest;
529                continue;
530            }
531        } else if let Some((rest, node)) =
532            gfm_footnote_definition_parser::parse_footnote_definition(remaining)
533        {
534            nodes.push(node);
535            remaining = rest;
536            continue;
537        }
538        #[cfg(not(feature = "parallel-parse"))]
539        if let Some((rest, node)) =
540            gfm_footnote_definition_parser::parse_footnote_definition(remaining)
541        {
542            nodes.push(node);
543            remaining = rest;
544            continue;
545        }
546
547        if let Ok((rest, (label, url, title))) = grammar::link_reference_definition(remaining) {
548            cm_link_reference_parser::parse_link_reference(&mut document, &label, url, title);
549            remaining = rest;
550            continue;
551        }
552
553        // Try parsing GFM pipe table (extension)
554        // Must come BEFORE paragraph so tables aren't consumed as plain text.
555        //
556        // Also try parsing extended "headerless" pipe tables (delimiter-first).
557        // Must come BEFORE paragraph for the same reason.
558        let headerless_table_start = remaining;
559        #[cfg(feature = "parallel-parse")]
560        if depth == 0 {
561            if let Ok((rest, table)) = grammar::headerless_table(remaining) {
562                nodes.push(
563                    marco_headerless_table_parser::parse_marco_headerless_table_parallel(
564                        table,
565                        headerless_table_start,
566                        rest,
567                    ),
568                );
569                remaining = rest;
570                continue;
571            }
572        } else if let Ok((rest, table)) = grammar::headerless_table(remaining) {
573            nodes.push(marco_headerless_table_parser::parse_marco_headerless_table(
574                table,
575                headerless_table_start,
576                rest,
577            ));
578            remaining = rest;
579            continue;
580        }
581        #[cfg(not(feature = "parallel-parse"))]
582        if let Ok((rest, table)) = grammar::headerless_table(remaining) {
583            nodes.push(marco_headerless_table_parser::parse_marco_headerless_table(
584                table,
585                headerless_table_start,
586                rest,
587            ));
588            remaining = rest;
589            continue;
590        }
591
592        let table_start = remaining;
593        #[cfg(feature = "parallel-parse")]
594        if depth == 0 {
595            if let Ok((rest, table)) = grammar::gfm_table(remaining) {
596                nodes.push(gfm_table_parser::parse_gfm_table_parallel(
597                    table,
598                    table_start,
599                    rest,
600                ));
601                remaining = rest;
602                continue;
603            }
604        } else if let Ok((rest, table)) = grammar::gfm_table(remaining) {
605            nodes.push(gfm_table_parser::parse_gfm_table(table, table_start, rest));
606            remaining = rest;
607            continue;
608        }
609        #[cfg(not(feature = "parallel-parse"))]
610        if let Ok((rest, table)) = grammar::gfm_table(remaining) {
611            nodes.push(gfm_table_parser::parse_gfm_table(table, table_start, rest));
612            remaining = rest;
613            continue;
614        }
615
616        // Try parsing extended tab blocks.
617        // Must come BEFORE paragraph so the container isn't consumed as plain text.
618        if state.allow_tab_blocks {
619            let tab_start = remaining;
620            if let Ok((rest, block)) = grammar::marco_tab_block(remaining) {
621                let node = marco_tab_blocks_parser::parse_marco_tab_block(
622                    block,
623                    tab_start,
624                    rest,
625                    depth,
626                    |panel, new_depth| {
627                        // Tabs must support arbitrary markdown in each panel, but nested
628                        // `:::tab` containers are disallowed. We implement that by
629                        // disabling tab parsing while parsing the panel body.
630                        let mut panel_state = ParserState::new_with_tab_blocks(false);
631                        parse_blocks_internal(panel, new_depth, &mut panel_state)
632                    },
633                )?;
634
635                nodes.push(node);
636                remaining = rest;
637                continue;
638            }
639        }
640
641        // Try parsing extended definition lists (Markdown Guide / Markdown Extra-style)
642        // Must come BEFORE paragraph so definition lists aren't consumed as plain text.
643        if let Some((rest, node)) = parse_extended_definition_list(remaining, depth) {
644            nodes.push(node);
645            remaining = rest;
646            continue;
647        }
648
649        // Try parsing paragraph
650        #[cfg(feature = "parallel-parse")]
651        if depth == 0 {
652            if let Ok((rest, content)) = grammar::paragraph(remaining) {
653                let (node, segments) = cm_paragraph_parser::parse_paragraph_shape(content);
654                let node_index = nodes.len();
655                if !segments.is_empty() {
656                    pending_leaves.push(parallel_inline::PendingLeaf {
657                        node_index,
658                        nested_child_index: None,
659                        segments,
660                    });
661                }
662                nodes.push(node);
663                remaining = rest;
664                continue;
665            }
666        } else if let Ok((rest, content)) = grammar::paragraph(remaining) {
667            nodes.push(cm_paragraph_parser::parse_paragraph(content));
668            remaining = rest;
669            continue;
670        }
671        #[cfg(not(feature = "parallel-parse"))]
672        if let Ok((rest, content)) = grammar::paragraph(remaining) {
673            nodes.push(cm_paragraph_parser::parse_paragraph(content));
674            remaining = rest;
675            continue;
676        }
677
678        // If nothing matched, skip one character to avoid infinite loop.
679        // Use `take` so we preserve nom_locate offsets (important for spans/highlights).
680        log::warn!(
681            "Could not parse block at offset {}, skipping character",
682            remaining.location_offset()
683        );
684        use nom::bytes::complete::take;
685        let skip_len = remaining
686            .fragment()
687            .chars()
688            .next()
689            .map(|c| c.len_utf8())
690            .unwrap_or(1);
691        if let Ok((rest, _)) =
692            take::<_, _, nom::error::Error<GrammarSpan>>(skip_len as u32)(remaining)
693        {
694            remaining = rest;
695        } else {
696            break;
697        }
698    }
699
700    log::info!("Parsed {} blocks", nodes.len());
701
702    // Resolve every deferred paragraph/footnote-definition-body in one
703    // shared parallel batch and patch results back into `nodes`, before
704    // this function's children are handed to its (possibly recursive)
705    // caller. Nested containers (blockquote/list/etc.) get their own
706    // independent batch this way, since they call this function again.
707    #[cfg(feature = "parallel-parse")]
708    parallel_inline::apply_pending_leaves(&mut nodes, pending_leaves);
709
710    // Add parsed nodes to document
711    document.children = nodes;
712    Ok(document)
713}
714
715/// Attempt to parse a Markdown Guide extended definition list at the current input.
716///
717/// Syntax (canonical):
718/// ```text
719/// Term
720/// : definition
721///
722/// Another term
723/// : first definition
724/// : second definition
725/// ```
726///
727/// Supported extensions:
728/// - Multiple `: ...` definition lines per term
729/// - Multiple term groups in a single list, with optional blank lines between items
730/// - Multi-line definition bodies via indented continuation lines (>= 2 spaces)
731/// - Nested blocks inside a definition (via recursive block parsing after dedent)
732///
733/// Explicit non-goals / disambiguation:
734/// - Lines starting with `::` are *not* treated as definition markers.
735fn parse_extended_definition_list<'a>(
736    input: GrammarSpan<'a>,
737    depth: usize,
738) -> Option<(GrammarSpan<'a>, Node)> {
739    // We only match at a non-blank line; blank lines are already handled by the main loop.
740    let text = input.fragment();
741    if text.is_empty() {
742        return None;
743    }
744
745    const CONTINUATION_INDENT: usize = 2;
746
747    fn line_bounds(s: &str, start: usize) -> (usize, usize, usize) {
748        // Returns: (line_start, line_end_no_nl, next_start)
749        let rel_end = s[start..].find('\n').map(|i| start + i).unwrap_or(s.len());
750        let next = if rel_end < s.len() {
751            rel_end + 1
752        } else {
753            rel_end
754        };
755        (start, rel_end, next)
756    }
757
758    fn count_indent_columns(line: &str) -> usize {
759        // Count leading indentation, expanding tabs to 4-wide tab stops.
760        let mut indent = 0usize;
761        for ch in line.chars() {
762            if ch == ' ' {
763                indent += 1;
764            } else if ch == '\t' {
765                indent += 4 - (indent % 4);
766            } else {
767                break;
768            }
769        }
770        indent
771    }
772
773    fn def_marker_content_start(line: &str) -> Option<usize> {
774        // Optional leading spaces (up to 3) are allowed.
775        let bytes = line.as_bytes();
776        let mut i = 0usize;
777        for _ in 0..3 {
778            if bytes.get(i) == Some(&b' ') {
779                i += 1;
780            } else {
781                break;
782            }
783        }
784
785        if bytes.get(i) != Some(&b':') {
786            return None;
787        }
788        // Disallow "::" (reserved for other extensions / lookalikes).
789        if bytes.get(i + 1) == Some(&b':') {
790            return None;
791        }
792
793        // Require at least one whitespace after ':' (Markdown Guide uses ': ')
794        match bytes.get(i + 1) {
795            Some(b' ') | Some(b'\t') => {
796                // Strip exactly one whitespace after the marker; any extra stays as content.
797                Some(i + 2)
798            }
799            _ => None,
800        }
801    }
802
803    fn can_start_item_at(text: &str, start: usize) -> bool {
804        if start >= text.len() {
805            return false;
806        }
807        let (_t0s, t0e, t1s) = line_bounds(text, start);
808        let term_line = &text[start..t0e];
809        if term_line.trim().is_empty() {
810            return false;
811        }
812        if t1s >= text.len() {
813            return false;
814        }
815        let (_d0s, d0e, _d1s) = line_bounds(text, t1s);
816        let def_line = &text[t1s..d0e];
817        def_marker_content_start(def_line).is_some()
818    }
819
820    // We build a single <dl> node, potentially containing multiple term groups.
821    let mut children: Vec<Node> = Vec::new();
822    let mut cursor = 0usize;
823    let mut parsed_any = false;
824    // Deferred term inline-parsing, resolved in one batch just before this
825    // function returns — see `parallel_inline` module docs. Description
826    // bodies need no separate handling here: they're recursively
827    // block-parsed below via `parse_blocks_internal`, which does its own
828    // independent batch the same way.
829    #[cfg(feature = "parallel-parse")]
830    let mut pending_leaves: Vec<parallel_inline::PendingLeaf<'a>> = Vec::new();
831
832    // Parse one or more items.
833    loop {
834        if cursor >= text.len() {
835            break;
836        }
837
838        // Parse term line.
839        let (term_start, term_end, after_term) = line_bounds(text, cursor);
840        let term_line = &text[term_start..term_end];
841
842        // If we're at a blank line here, it means we consumed optional blanks between items.
843        // Stop the list; the main loop will handle blanks.
844        if term_line.trim().is_empty() {
845            break;
846        }
847
848        // Term must be followed immediately by at least one definition marker line.
849        if after_term >= text.len() {
850            break;
851        }
852
853        let (def_line_start, def_line_end, _after_def_line) = line_bounds(text, after_term);
854        let first_def_line = &text[def_line_start..def_line_end];
855        if def_marker_content_start(first_def_line).is_none() {
856            break;
857        }
858
859        // Build the <dt> node.
860        let term_start_span = input.take_from(term_start);
861        let (term_after_span, term_taken_span) = term_start_span.take_split(term_end - term_start);
862        let term_span = crate::parser::shared::opt_span_range(term_start_span, term_after_span);
863
864        #[cfg(feature = "parallel-parse")]
865        if depth == 0 {
866            let node_index = children.len();
867            if !term_taken_span.fragment().is_empty() {
868                pending_leaves.push(parallel_inline::PendingLeaf {
869                    node_index,
870                    nested_child_index: None,
871                    segments: vec![parallel_inline::Segment::Pending(
872                        parallel_inline::PendingSpan::Borrowed(term_taken_span),
873                    )],
874                });
875            }
876            children.push(Node {
877                kind: NodeKind::DefinitionTerm,
878                span: term_span,
879                children: Vec::new(),
880            });
881        } else {
882            let term_children =
883                match crate::parser::inlines::parse_inlines_from_span(term_taken_span) {
884                    Ok(children) => children,
885                    Err(e) => {
886                        log::warn!("Failed to parse inline elements in definition term: {}", e);
887                        vec![Node {
888                            kind: NodeKind::Text(term_taken_span.fragment().to_string()),
889                            span: crate::parser::shared::opt_span(term_taken_span),
890                            children: Vec::new(),
891                        }]
892                    }
893                };
894
895            children.push(Node {
896                kind: NodeKind::DefinitionTerm,
897                span: term_span,
898                children: term_children,
899            });
900        }
901        #[cfg(not(feature = "parallel-parse"))]
902        {
903            let term_children =
904                match crate::parser::inlines::parse_inlines_from_span(term_taken_span) {
905                    Ok(children) => children,
906                    Err(e) => {
907                        log::warn!("Failed to parse inline elements in definition term: {}", e);
908                        vec![Node {
909                            kind: NodeKind::Text(term_taken_span.fragment().to_string()),
910                            span: crate::parser::shared::opt_span(term_taken_span),
911                            children: Vec::new(),
912                        }]
913                    }
914                };
915
916            children.push(Node {
917                kind: NodeKind::DefinitionTerm,
918                span: term_span,
919                children: term_children,
920            });
921        }
922
923        // Parse one or more definitions for this term.
924        cursor = after_term;
925        while cursor < text.len() {
926            let (line_start, line_end, next_line_start) = line_bounds(text, cursor);
927            let line = &text[line_start..line_end];
928
929            let content_start_in_line = match def_marker_content_start(line) {
930                Some(i) => i,
931                None => break,
932            };
933
934            // Definition block span starts at the marker line.
935            let def_block_start = line_start;
936            let mut def_block_end = next_line_start;
937
938            // Build raw definition body text: first line after ": ", then indented continuations.
939            let mut raw_lines: Vec<&str> = Vec::new();
940            raw_lines.push(&line[content_start_in_line..]);
941
942            let mut scan = next_line_start;
943            while scan < text.len() {
944                let (ls, le, ln) = line_bounds(text, scan);
945                let l = &text[ls..le];
946
947                // Next definition marker starts a new <dd>.
948                if def_marker_content_start(l).is_some() {
949                    break;
950                }
951
952                if l.trim().is_empty() {
953                    // Only treat a blank line as part of this definition if the
954                    // next non-blank line is indented enough to continue.
955                    let mut look = ln;
956                    let mut next_indent: Option<usize> = None;
957                    while look < text.len() {
958                        let (_pls, ple, pln) = line_bounds(text, look);
959                        let pl = &text[look..ple];
960                        if !pl.trim().is_empty() {
961                            next_indent = Some(count_indent_columns(pl));
962                            break;
963                        }
964                        look = pln;
965                    }
966
967                    if next_indent.unwrap_or(0) >= CONTINUATION_INDENT {
968                        raw_lines.push("");
969                        scan = ln;
970                        def_block_end = scan;
971                        continue;
972                    }
973
974                    break;
975                }
976
977                let indent = count_indent_columns(l);
978                if indent >= CONTINUATION_INDENT {
979                    raw_lines.push(l);
980                    scan = ln;
981                    def_block_end = scan;
982                    continue;
983                }
984
985                break;
986            }
987
988            let raw_body = raw_lines.join("\n");
989            let dedented = dedent_list_item_content(&raw_body, CONTINUATION_INDENT);
990
991            // Parse the definition body as nested blocks.
992            let mut def_state = ParserState::new();
993            def_state.push_block(BlockContext::new_list_item(CONTINUATION_INDENT));
994            let def_children = match parse_blocks_internal(&dedented, depth + 1, &mut def_state) {
995                Ok(doc) => doc.children,
996                Err(e) => {
997                    log::warn!("Failed to parse definition description blocks: {}", e);
998                    Vec::new()
999                }
1000            };
1001
1002            let dd_start_span = input.take_from(def_block_start);
1003            let dd_end_span = input.take_from(def_block_end);
1004            children.push(Node {
1005                kind: NodeKind::DefinitionDescription,
1006                span: crate::parser::shared::opt_span_range(dd_start_span, dd_end_span),
1007                children: def_children,
1008            });
1009
1010            parsed_any = true;
1011            cursor = def_block_end;
1012        }
1013
1014        // Between items, allow blank lines *only if* another valid item follows.
1015        let mut scan = cursor;
1016        while scan < text.len() {
1017            let (_ls, le, ln) = line_bounds(text, scan);
1018            let l = &text[scan..le];
1019            if !l.trim().is_empty() {
1020                break;
1021            }
1022            scan = ln;
1023        }
1024
1025        if scan != cursor && can_start_item_at(text, scan) {
1026            cursor = scan;
1027            continue;
1028        }
1029
1030        break;
1031    }
1032
1033    if !parsed_any {
1034        return None;
1035    }
1036
1037    #[cfg(feature = "parallel-parse")]
1038    parallel_inline::apply_pending_leaves(&mut children, pending_leaves);
1039
1040    let (rest, _taken) = input.take_split(cursor);
1041    let span = crate::parser::shared::opt_span_range(input, rest);
1042    Some((
1043        rest,
1044        Node {
1045            kind: NodeKind::DefinitionList,
1046            span,
1047            children,
1048        },
1049    ))
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::parse_blocks;
1055    use crate::parser::ast::NodeKind;
1056
1057    #[test]
1058    fn smoke_test_block_parser_handles_large_documents() {
1059        // Regression test: we previously had an iteration cap (100) that could truncate
1060        // parsing for realistic documents, which in turn truncated syntax highlighting.
1061        let count = 250;
1062        let mut input = String::new();
1063        for i in 0..count {
1064            input.push_str(&format!("Paragraph {i}\n\n"));
1065        }
1066
1067        let doc = parse_blocks(&input).expect("parse_blocks failed");
1068        assert_eq!(doc.children.len(), count);
1069        assert!(matches!(
1070            doc.children.last().unwrap().kind,
1071            NodeKind::Paragraph
1072        ));
1073    }
1074}