Skip to main content

panache_parser/parser/
core.rs

1use crate::options::ParserOptions;
2use crate::syntax::{SyntaxKind, SyntaxNode};
3use rowan::GreenNodeBuilder;
4
5use super::block_dispatcher::{
6    BlockContext, BlockDetectionResult, BlockEffect, BlockParserRegistry, BlockQuotePrepared,
7    PreparedBlockMatch,
8};
9use super::blocks::blockquotes;
10use super::blocks::code_blocks;
11use super::blocks::definition_lists;
12use super::blocks::fenced_divs;
13use super::blocks::headings::{
14    emit_atx_heading, emit_setext_heading, emit_setext_heading_body, try_parse_atx_heading,
15    try_parse_setext_heading,
16};
17use super::blocks::horizontal_rules::try_parse_horizontal_rule;
18use super::blocks::line_blocks;
19use super::blocks::lists;
20use super::blocks::paragraphs;
21use super::blocks::raw_blocks::{extract_environment_name, is_inline_math_environment};
22use super::utils::container_stack;
23use super::utils::helpers::{is_blank_line, split_lines_inclusive, strip_newline};
24use super::utils::inline_emission;
25use super::utils::marker_utils;
26use super::utils::text_buffer;
27
28use super::blocks::blockquotes::strip_n_blockquote_markers;
29use super::utils::continuation::ContinuationPolicy;
30use container_stack::{Container, ContainerStack, byte_index_at_column, leading_indent};
31use definition_lists::{emit_definition_marker, emit_term};
32use line_blocks::{parse_line_block, try_parse_line_block_start};
33use lists::{
34    ListItemEmissionInput, ListMarker, is_content_nested_bullet_marker, start_nested_list,
35    try_parse_list_marker,
36};
37use marker_utils::{count_blockquote_markers, parse_blockquote_marker_info};
38use text_buffer::TextBuffer;
39
40const GITHUB_ALERT_MARKERS: [&str; 5] = [
41    "[!TIP]",
42    "[!WARNING]",
43    "[!IMPORTANT]",
44    "[!CAUTION]",
45    "[!NOTE]",
46];
47
48pub struct Parser<'a> {
49    lines: Vec<&'a str>,
50    pos: usize,
51    builder: GreenNodeBuilder<'static>,
52    containers: ContainerStack,
53    config: &'a ParserOptions,
54    block_registry: BlockParserRegistry,
55    /// True when the previous block was a metadata block (YAML, Pandoc title, or MMD title).
56    /// The first line after a metadata block is treated as if it has a blank line before it,
57    /// matching Pandoc's behavior of allowing headings etc. directly after frontmatter.
58    after_metadata_block: bool,
59}
60
61impl<'a> Parser<'a> {
62    pub fn new(input: &'a str, config: &'a ParserOptions) -> Self {
63        // Use split_lines_inclusive to preserve line endings (both LF and CRLF)
64        let lines = split_lines_inclusive(input);
65        Self {
66            lines,
67            pos: 0,
68            builder: GreenNodeBuilder::new(),
69            containers: ContainerStack::new(),
70            config,
71            block_registry: BlockParserRegistry::new(),
72            after_metadata_block: false,
73        }
74    }
75
76    pub fn parse(mut self) -> SyntaxNode {
77        self.parse_document_stack();
78
79        SyntaxNode::new_root(self.builder.finish())
80    }
81
82    /// Close enclosing list items (and their containing list) whose
83    /// `content_col` exceeds the given indent. Used by CommonMark when an
84    /// interrupting block (HR, ATX heading, fenced code, ...) appears at a
85    /// column shallower than the current list-item content column — per
86    /// §5.2 the line cannot continue the item, so the item and the
87    /// surrounding list close before the new block is emitted at the
88    /// outer level. Pandoc-markdown reaches this branch only when the
89    /// dispatcher already required a blank line before the interrupter,
90    /// at which point the blank-line handler has already closed the list;
91    /// gating on dialect at the call site keeps Pandoc unaffected.
92    fn close_lists_above_indent(&mut self, indent_cols: usize) {
93        while let Some(Container::ListItem { content_col, .. }) = self.containers.last() {
94            if indent_cols >= *content_col {
95                break;
96            }
97            self.close_containers_to(self.containers.depth() - 1);
98            if matches!(self.containers.last(), Some(Container::List { .. })) {
99                self.close_containers_to(self.containers.depth() - 1);
100            }
101        }
102    }
103
104    /// Emit buffered PLAIN content if Definition container has open PLAIN.
105    /// Close containers down to `keep`, emitting buffered content first.
106    fn close_containers_to(&mut self, keep: usize) {
107        // Emit buffered PARAGRAPH/PLAIN content before closing
108        while self.containers.depth() > keep {
109            match self.containers.stack.last() {
110                // Handle ListItem with buffering
111                Some(Container::ListItem { buffer, .. }) if !buffer.is_empty() => {
112                    // Clone buffer to avoid borrow issues
113                    let buffer_clone = buffer.clone();
114
115                    log::trace!(
116                        "Closing ListItem with buffer (is_empty={}, segment_count={})",
117                        buffer_clone.is_empty(),
118                        buffer_clone.segment_count()
119                    );
120
121                    // Determine if this should be Plain or PARAGRAPH:
122                    // 1. Check if parent LIST has blank lines between items (list-level loose)
123                    // 2. OR check if this item has blank lines within its content (item-level loose)
124                    let parent_list_is_loose = self
125                        .containers
126                        .stack
127                        .iter()
128                        .rev()
129                        .find_map(|c| match c {
130                            Container::List {
131                                has_blank_between_items,
132                                ..
133                            } => Some(*has_blank_between_items),
134                            _ => None,
135                        })
136                        .unwrap_or(false);
137
138                    let use_paragraph =
139                        parent_list_is_loose || buffer_clone.has_blank_lines_between_content();
140
141                    log::trace!(
142                        "Emitting ListItem buffer: use_paragraph={} (parent_list_is_loose={}, item_has_blanks={})",
143                        use_paragraph,
144                        parent_list_is_loose,
145                        buffer_clone.has_blank_lines_between_content()
146                    );
147
148                    // Pop container first
149                    self.containers.stack.pop();
150                    // Emit buffered content as Plain or PARAGRAPH
151                    buffer_clone.emit_as_block(&mut self.builder, use_paragraph, self.config);
152                    self.builder.finish_node(); // Close LIST_ITEM
153                }
154                // Handle ListItem without content
155                Some(Container::ListItem { .. }) => {
156                    log::trace!("Closing empty ListItem (no buffer content)");
157                    // Just close normally (empty list item)
158                    self.containers.stack.pop();
159                    self.builder.finish_node();
160                }
161                // Handle Paragraph with buffering
162                Some(Container::Paragraph {
163                    buffer,
164                    start_checkpoint,
165                    ..
166                }) if !buffer.is_empty() => {
167                    // Clone buffer to avoid borrow issues
168                    let buffer_clone = buffer.clone();
169                    let checkpoint = *start_checkpoint;
170                    // Pop container first
171                    self.containers.stack.pop();
172                    // Retroactively wrap as PARAGRAPH and emit buffered content
173                    self.builder
174                        .start_node_at(checkpoint, SyntaxKind::PARAGRAPH.into());
175                    buffer_clone.emit_with_inlines(&mut self.builder, self.config);
176                    self.builder.finish_node();
177                }
178                // Handle Paragraph without content
179                Some(Container::Paragraph {
180                    start_checkpoint, ..
181                }) => {
182                    let checkpoint = *start_checkpoint;
183                    // Just close normally — emit empty PARAGRAPH wrapper
184                    self.containers.stack.pop();
185                    self.builder
186                        .start_node_at(checkpoint, SyntaxKind::PARAGRAPH.into());
187                    self.builder.finish_node();
188                }
189                // Handle Definition with buffered PLAIN
190                Some(Container::Definition {
191                    plain_open: true,
192                    plain_buffer,
193                    ..
194                }) if !plain_buffer.is_empty() => {
195                    let text = plain_buffer.get_accumulated_text();
196                    emit_definition_plain_or_heading(&mut self.builder, &text, self.config);
197
198                    // Mark PLAIN as closed and clear buffer
199                    if let Some(Container::Definition {
200                        plain_open,
201                        plain_buffer,
202                        ..
203                    }) = self.containers.stack.last_mut()
204                    {
205                        plain_buffer.clear();
206                        *plain_open = false;
207                    }
208
209                    // Pop container and finish node
210                    self.containers.stack.pop();
211                    self.builder.finish_node();
212                }
213                // Handle Definition with PLAIN open but empty buffer
214                Some(Container::Definition {
215                    plain_open: true, ..
216                }) => {
217                    // Mark PLAIN as closed
218                    if let Some(Container::Definition {
219                        plain_open,
220                        plain_buffer,
221                        ..
222                    }) = self.containers.stack.last_mut()
223                    {
224                        plain_buffer.clear();
225                        *plain_open = false;
226                    }
227
228                    // Pop container and finish node
229                    self.containers.stack.pop();
230                    self.builder.finish_node();
231                }
232                // All other containers
233                _ => {
234                    self.containers.stack.pop();
235                    self.builder.finish_node();
236                }
237            }
238        }
239    }
240
241    /// Emit buffered PLAIN content if there's an open PLAIN in a Definition.
242    /// This is used when we need to close PLAIN but keep the Definition container open.
243    fn emit_buffered_plain_if_needed(&mut self) {
244        // Check if we have an open PLAIN with buffered content
245        if let Some(Container::Definition {
246            plain_open: true,
247            plain_buffer,
248            ..
249        }) = self.containers.stack.last()
250            && !plain_buffer.is_empty()
251        {
252            let text = plain_buffer.get_accumulated_text();
253            emit_definition_plain_or_heading(&mut self.builder, &text, self.config);
254        }
255
256        // Mark PLAIN as closed and clear buffer
257        if let Some(Container::Definition {
258            plain_open,
259            plain_buffer,
260            ..
261        }) = self.containers.stack.last_mut()
262            && *plain_open
263        {
264            plain_buffer.clear();
265            *plain_open = false;
266        }
267    }
268
269    /// Close blockquotes down to a target depth.
270    ///
271    /// Must use `Parser::close_containers_to` (not `ContainerStack::close_to`) so list/paragraph
272    /// buffers are emitted for losslessness.
273    fn close_blockquotes_to_depth(&mut self, target_depth: usize) {
274        let mut current = self.current_blockquote_depth();
275        while current > target_depth {
276            while !matches!(self.containers.last(), Some(Container::BlockQuote { .. })) {
277                if self.containers.depth() == 0 {
278                    break;
279                }
280                self.close_containers_to(self.containers.depth() - 1);
281            }
282            if matches!(self.containers.last(), Some(Container::BlockQuote { .. })) {
283                self.close_containers_to(self.containers.depth() - 1);
284                current -= 1;
285            } else {
286                break;
287            }
288        }
289    }
290
291    fn active_alert_blockquote_depth(&self) -> Option<usize> {
292        self.containers.stack.iter().rev().find_map(|c| match c {
293            Container::Alert { blockquote_depth } => Some(*blockquote_depth),
294            _ => None,
295        })
296    }
297
298    fn in_active_alert(&self) -> bool {
299        self.active_alert_blockquote_depth().is_some()
300    }
301
302    fn previous_block_requires_blank_before_heading(&self) -> bool {
303        matches!(
304            self.containers.last(),
305            Some(Container::Paragraph { .. })
306                | Some(Container::ListItem { .. })
307                | Some(Container::Definition { .. })
308                | Some(Container::DefinitionItem { .. })
309                | Some(Container::FootnoteDefinition { .. })
310        )
311    }
312
313    fn alert_marker_from_content(content: &str) -> Option<&'static str> {
314        let (without_newline, _) = strip_newline(content);
315        let trimmed = without_newline.trim();
316        GITHUB_ALERT_MARKERS
317            .into_iter()
318            .find(|marker| *marker == trimmed)
319    }
320
321    /// Emit buffered list item content if we're in a ListItem and it has content.
322    /// This is used before starting block-level elements inside list items.
323    fn emit_list_item_buffer_if_needed(&mut self) {
324        if let Some(Container::ListItem { buffer, .. }) = self.containers.stack.last_mut()
325            && !buffer.is_empty()
326        {
327            let buffer_clone = buffer.clone();
328            buffer.clear();
329            let use_paragraph = buffer_clone.has_blank_lines_between_content();
330            buffer_clone.emit_as_block(&mut self.builder, use_paragraph, self.config);
331        }
332    }
333
334    /// CommonMark §5.2: when a list item's first line (after the marker) is a
335    /// fenced code block opener, the content of the item *is* the code block —
336    /// not buffered text. The list-item open path normally pushes the
337    /// post-marker text into the item's buffer; this helper detects an opening
338    /// fence in that buffered first line and converts it into a CODE_BLOCK
339    /// inside the LIST_ITEM, consuming subsequent lines until the closing
340    /// fence (or end of document under CommonMark dialect, per §4.5).
341    ///
342    /// Pandoc-markdown also reaches this path: a bare fence still requires a
343    /// matching closer to register as a code block, matching
344    /// `FencedCodeBlockParser::detect_prepared` (`bare_fence_in_list_with_closer`).
345    fn maybe_open_fenced_code_in_new_list_item(&mut self) {
346        let Some(Container::ListItem {
347            content_col,
348            buffer,
349            ..
350        }) = self.containers.stack.last()
351        else {
352            return;
353        };
354        let content_col = *content_col;
355        let Some(text) = buffer.first_text() else {
356            return;
357        };
358        if buffer.segment_count() != 1 {
359            return;
360        }
361        let text_owned = text.to_string();
362        let Some(fence) = code_blocks::try_parse_fence_open(&text_owned) else {
363            return;
364        };
365        let common_mark_dialect = self.config.dialect == crate::options::Dialect::CommonMark;
366        let has_info = !fence.info_string.trim().is_empty();
367        let bq_depth = self.current_blockquote_depth();
368        let has_matching_closer = self.has_matching_fence_closer(&fence, bq_depth, content_col);
369        if !(has_info || has_matching_closer || common_mark_dialect) {
370            return;
371        }
372        // Gate fences by extension flags, mirroring the dispatcher.
373        if (fence.fence_char == '`' && !self.config.extensions.backtick_code_blocks)
374            || (fence.fence_char == '~' && !self.config.extensions.fenced_code_blocks)
375        {
376            return;
377        }
378        if let Some(Container::ListItem { buffer, .. }) = self.containers.stack.last_mut() {
379            buffer.clear();
380        }
381        let new_pos = code_blocks::parse_fenced_code_block(
382            &mut self.builder,
383            &self.lines,
384            self.pos,
385            fence,
386            bq_depth,
387            content_col,
388            Some(&text_owned),
389        );
390        // The dispatcher caller will advance pos by lines_consumed (= 1 from
391        // the list parser) after we return. Compensate so the final pos lands
392        // on `new_pos`.
393        self.pos = new_pos.saturating_sub(1);
394    }
395
396    /// CommonMark §5.2 rule #2: when a list marker is followed by ≥ 5 columns
397    /// of whitespace and non-empty content, the content begins as an indented
398    /// code block on the marker line. The marker parser collapses the post-
399    /// marker whitespace to "marker + 1 (possibly virtual) space" and leaves
400    /// the surplus in the post-marker text. This helper detects such a single-
401    /// line indented-code first-line and converts the buffered text into a
402    /// CODE_BLOCK > CODE_CONTENT inside the LIST_ITEM.
403    ///
404    /// Multi-line accumulation (subsequent indented-code lines on continuation
405    /// lines) is handled by the regular block-detection path.
406    fn maybe_open_indented_code_in_new_list_item(&mut self) {
407        let Some(Container::ListItem {
408            content_col,
409            buffer,
410            marker_only,
411            virtual_marker_space,
412        }) = self.containers.stack.last()
413        else {
414            return;
415        };
416        if *marker_only {
417            return;
418        }
419        if buffer.segment_count() != 1 {
420            return;
421        }
422        let Some(text) = buffer.first_text() else {
423            return;
424        };
425        let content_col = *content_col;
426        let virtual_marker_space = *virtual_marker_space;
427        let text_owned = text.to_string();
428
429        // Single-line content only for now.
430        let mut iter = text_owned.split_inclusive('\n');
431        let line_with_nl = iter.next().unwrap_or("").to_string();
432        if iter.next().is_some() {
433            return;
434        }
435
436        let line_no_nl = line_with_nl
437            .strip_suffix("\r\n")
438            .or_else(|| line_with_nl.strip_suffix('\n'))
439            .unwrap_or(&line_with_nl);
440        let nl_suffix = &line_with_nl[line_no_nl.len()..];
441
442        let buffer_start_col = if virtual_marker_space {
443            content_col.saturating_sub(1)
444        } else {
445            content_col
446        };
447
448        let target = content_col + 4;
449        let (cols_walked, ws_bytes) =
450            super::utils::container_stack::leading_indent_from(line_no_nl, buffer_start_col);
451
452        if buffer_start_col + cols_walked < target {
453            return;
454        }
455        if ws_bytes >= line_no_nl.len() {
456            return;
457        }
458
459        if let Some(Container::ListItem { buffer, .. }) = self.containers.stack.last_mut() {
460            buffer.clear();
461        }
462
463        self.builder.start_node(SyntaxKind::CODE_BLOCK.into());
464        self.builder.start_node(SyntaxKind::CODE_CONTENT.into());
465        if ws_bytes > 0 {
466            self.builder
467                .token(SyntaxKind::WHITESPACE.into(), &line_no_nl[..ws_bytes]);
468        }
469        let rest = &line_no_nl[ws_bytes..];
470        if !rest.is_empty() {
471            self.builder.token(SyntaxKind::TEXT.into(), rest);
472        }
473        if !nl_suffix.is_empty() {
474            self.builder.token(SyntaxKind::NEWLINE.into(), nl_suffix);
475        }
476        self.builder.finish_node();
477        self.builder.finish_node();
478    }
479
480    fn has_matching_fence_closer(
481        &self,
482        fence: &code_blocks::FenceInfo,
483        bq_depth: usize,
484        content_col: usize,
485    ) -> bool {
486        for raw_line in self.lines.iter().skip(self.pos + 1) {
487            let (line_bq_depth, inner) = count_blockquote_markers(raw_line);
488            if line_bq_depth < bq_depth {
489                break;
490            }
491            let candidate = if content_col > 0 && !inner.is_empty() {
492                let idx = byte_index_at_column(inner, content_col);
493                if idx <= inner.len() {
494                    &inner[idx..]
495                } else {
496                    inner
497                }
498            } else {
499                inner
500            };
501            if code_blocks::is_closing_fence(candidate, fence) {
502                return true;
503            }
504        }
505        false
506    }
507
508    /// Check if a paragraph is currently open.
509    fn is_paragraph_open(&self) -> bool {
510        matches!(self.containers.last(), Some(Container::Paragraph { .. }))
511    }
512
513    /// Fold an open paragraph's buffered content into a setext heading and emit it.
514    ///
515    /// Used for CommonMark multi-line setext: when a setext underline is matched
516    /// and a paragraph is already open with buffered text, the entire paragraph
517    /// (buffer + current text line) becomes the heading content. The HEADING node
518    /// is wrapped retroactively from the paragraph's start checkpoint so the
519    /// emitted bytes appear in source order.
520    fn emit_setext_heading_folding_paragraph(
521        &mut self,
522        text_line: &str,
523        underline_line: &str,
524        level: usize,
525    ) {
526        let (buffered_text, checkpoint) = match self.containers.stack.last() {
527            Some(Container::Paragraph {
528                buffer,
529                start_checkpoint,
530                ..
531            }) => (buffer.get_text_for_parsing(), Some(*start_checkpoint)),
532            _ => (String::new(), None),
533        };
534
535        if checkpoint.is_some() {
536            self.containers.stack.pop();
537        }
538
539        let combined_text = if buffered_text.is_empty() {
540            text_line.to_string()
541        } else {
542            format!("{}{}", buffered_text, text_line)
543        };
544
545        let cp = checkpoint.expect(
546            "emit_setext_heading_folding_paragraph requires an open paragraph; \
547             single-line setext should go through the regular dispatcher path",
548        );
549        self.builder.start_node_at(cp, SyntaxKind::HEADING.into());
550        emit_setext_heading_body(
551            &mut self.builder,
552            &combined_text,
553            underline_line,
554            level,
555            self.config,
556        );
557        self.builder.finish_node();
558    }
559
560    /// Try to fold a list item's buffered first-line text and the current line
561    /// into a setext HEADING node, returning true on success.
562    ///
563    /// CommonMark §4.3 / Pandoc-markdown both treat the marker line of a list
564    /// item as a fresh start for setext detection — i.e. `- Bar\n  ---\n` is a
565    /// setext h2 inside the list item. The dispatcher path can't see this
566    /// because the list parser consumes the marker line and buffers the
567    /// post-marker text; by the time `  ---` reaches the dispatcher, the
568    /// candidate text line is already inside the buffer rather than the line
569    /// stream. This helper bridges that gap: when the innermost container is a
570    /// `ListItem` with a single buffered text segment and the current
571    /// (list-item-content-stripped) line is a setext underline, emit the
572    /// folded heading directly and clear the buffer.
573    ///
574    /// Multi-line setext (multiple buffered text segments) is *not* handled
575    /// here because Pandoc-markdown disagrees with CommonMark on whether
576    /// `- Foo\n  Bar\n  ---\n` forms a setext heading.
577    fn try_fold_list_item_buffer_into_setext(&mut self, content: &str) -> bool {
578        let Some(Container::ListItem {
579            buffer,
580            content_col,
581            ..
582        }) = self.containers.stack.last()
583        else {
584            return false;
585        };
586        if buffer.segment_count() != 1 {
587            return false;
588        }
589        let Some(text_line) = buffer.first_text() else {
590            return false;
591        };
592
593        // CommonMark §5.2: the underline must be indented to at least the
594        // list item's content column. A bare `---` at column 0 escapes the
595        // item and becomes a thematic break (CMark spec example #94/#99); a
596        // bare `-` at column 0 is a sibling list marker (#281/#282).
597        let content_col = *content_col;
598        let (underline_indent_cols, _) = leading_indent(content);
599        if underline_indent_cols < content_col {
600            return false;
601        }
602
603        let lines = [text_line, content];
604        let Some((level, _)) = try_parse_setext_heading(&lines, 0) else {
605            return false;
606        };
607
608        let (text_no_newline, _) = strip_newline(text_line);
609        if text_no_newline.trim().is_empty() {
610            return false;
611        }
612        if try_parse_horizontal_rule(text_no_newline).is_some() {
613            return false;
614        }
615
616        let text_owned = text_line.to_string();
617        if let Some(Container::ListItem { buffer, .. }) = self.containers.stack.last_mut() {
618            buffer.clear();
619        }
620        emit_setext_heading(&mut self.builder, &text_owned, content, level, self.config);
621        self.pos += 1;
622        true
623    }
624
625    /// Close paragraph if one is currently open.
626    fn close_paragraph_if_open(&mut self) {
627        if self.is_paragraph_open() {
628            self.close_containers_to(self.containers.depth() - 1);
629        }
630    }
631
632    /// Close an open `Container::Paragraph` at the top of the stack, retagging
633    /// the wrapper as `PLAIN` instead of `PARAGRAPH`. Mirrors pandoc's
634    /// `[Plain[foo], RawBlock<p>]` shape when a paragraph terminates because
635    /// the next line opens an HTML strict-block / verbatim block.
636    ///
637    /// Caller is responsible for ensuring the paragraph is at the top of the
638    /// container stack (i.e. no other deeper containers above it). All other
639    /// closing-related semantics (list-item buffering, blockquote depth) are
640    /// unchanged from `close_paragraph_if_open`; this method only changes the
641    /// emitted wrapper kind.
642    fn close_paragraph_as_plain_if_open(&mut self) {
643        if !self.is_paragraph_open() {
644            return;
645        }
646        let Some(Container::Paragraph {
647            buffer,
648            start_checkpoint,
649            ..
650        }) = self.containers.stack.last()
651        else {
652            return;
653        };
654        let buffer_clone = buffer.clone();
655        let checkpoint = *start_checkpoint;
656        self.containers.stack.pop();
657        self.builder
658            .start_node_at(checkpoint, SyntaxKind::PLAIN.into());
659        if !buffer_clone.is_empty() {
660            buffer_clone.emit_with_inlines(&mut self.builder, self.config);
661        }
662        self.builder.finish_node();
663    }
664
665    /// Whether an HTML block about to interrupt an open paragraph should
666    /// retag the paragraph wrapper as `PLAIN` (pandoc's
667    /// `[Plain[foo], RawBlock<p>]` rule). Fires only under Pandoc dialect
668    /// when the YesCanInterrupt match is an HTML `BlockTag` — by
669    /// construction this is a strict-block (`PANDOC_BLOCK_TAGS`) or
670    /// verbatim (`VERBATIM_TAGS`) tag, since inline-block / void block
671    /// tags and Type7 / comments take the `cannot_interrupt` path and
672    /// never reach this site.
673    fn html_block_demotes_paragraph_to_plain(&self, block_match: &PreparedBlockMatch) -> bool {
674        if self.config.dialect != crate::options::Dialect::Pandoc {
675            return false;
676        }
677        if self.block_registry.parser_name(block_match) != "html_block" {
678            return false;
679        }
680        let html_block_type = block_match
681            .payload
682            .as_ref()
683            .and_then(|p| p.downcast_ref::<crate::parser::blocks::html_blocks::HtmlBlockType>());
684        matches!(
685            html_block_type,
686            Some(crate::parser::blocks::html_blocks::HtmlBlockType::BlockTag { .. })
687        )
688    }
689
690    /// Prepare for a block-level element by flushing buffers and closing paragraphs.
691    /// This is a common pattern before starting tables, code blocks, divs, etc.
692    fn prepare_for_block_element(&mut self) {
693        self.emit_list_item_buffer_if_needed();
694        self.close_paragraph_if_open();
695    }
696
697    /// Close any open `FootnoteDefinition` container before a new footnote definition
698    /// is emitted into the green tree. Without this, a back-to-back `[^a]:`/`[^b]:`
699    /// pair would nest the second `FOOTNOTE_DEFINITION` node inside the first.
700    fn close_open_footnote_definition(&mut self) {
701        while matches!(
702            self.containers.last(),
703            Some(Container::FootnoteDefinition { .. })
704        ) {
705            self.close_containers_to(self.containers.depth() - 1);
706        }
707    }
708
709    fn handle_footnote_open_effect(
710        &mut self,
711        block_match: &super::block_dispatcher::PreparedBlockMatch,
712        content: &str,
713    ) {
714        let content_start = block_match
715            .payload
716            .as_ref()
717            .and_then(|p| p.downcast_ref::<super::block_dispatcher::FootnoteDefinitionPrepared>())
718            .map(|p| p.content_start)
719            .unwrap_or(0);
720
721        let content_col = 4;
722        self.containers
723            .push(Container::FootnoteDefinition { content_col });
724
725        if content_start == 0 {
726            return;
727        }
728        let first_line_content = &content[content_start..];
729        if first_line_content.trim().is_empty() {
730            let (_, newline_str) = strip_newline(content);
731            if !newline_str.is_empty() {
732                self.builder.token(SyntaxKind::NEWLINE.into(), newline_str);
733            }
734            return;
735        }
736
737        if self.config.extensions.definition_lists
738            && let Some(blank_count) = footnote_first_line_term_lookahead(
739                &self.lines,
740                self.pos,
741                content_col,
742                self.config.extensions.table_captions,
743            )
744        {
745            self.builder.start_node(SyntaxKind::DEFINITION_LIST.into());
746            self.containers.push(Container::DefinitionList {});
747            self.builder.start_node(SyntaxKind::DEFINITION_ITEM.into());
748            self.containers.push(Container::DefinitionItem {});
749            emit_term(&mut self.builder, first_line_content, self.config);
750            for i in 0..blank_count {
751                let blank_pos = self.pos + 1 + i;
752                if blank_pos < self.lines.len() {
753                    let blank_line = self.lines[blank_pos];
754                    self.builder.start_node(SyntaxKind::BLANK_LINE.into());
755                    self.builder
756                        .token(SyntaxKind::BLANK_LINE.into(), blank_line);
757                    self.builder.finish_node();
758                }
759            }
760            self.pos += blank_count;
761            return;
762        }
763
764        paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
765        paragraphs::append_paragraph_line(
766            &mut self.containers,
767            &mut self.builder,
768            first_line_content,
769            self.config,
770        );
771    }
772
773    /// CommonMark spec example #312: handle a detected list marker that's
774    /// actually lazy continuation rather than a new list item. Returns true
775    /// when the line was consumed as continuation (caller should advance pos
776    /// without calling `handle_list_open_effect`).
777    ///
778    /// A marker line whose leading indent is ≥ 4 columns isn't a real list
779    /// marker when (a) the indent doesn't reach the deepest open list item's
780    /// content column (so it can't open a child list), and (b) no open list
781    /// level matches the indent (so it can't be a sibling). In that case the
782    /// content is just text that lazily extends the deepest open paragraph
783    /// or list item.
784    fn try_lazy_list_continuation(
785        &mut self,
786        block_match: &super::block_dispatcher::PreparedBlockMatch,
787        content: &str,
788    ) -> bool {
789        use super::block_dispatcher::ListPrepared;
790
791        let Some(prepared) = block_match
792            .payload
793            .as_ref()
794            .and_then(|p| p.downcast_ref::<ListPrepared>())
795        else {
796            return false;
797        };
798
799        if prepared.indent_cols < 4 || !lists::in_list(&self.containers) {
800            return false;
801        }
802
803        let current_content_col = paragraphs::current_content_col(&self.containers);
804        if prepared.indent_cols >= current_content_col {
805            return false;
806        }
807
808        if lists::find_matching_list_level(
809            &self.containers,
810            &prepared.marker,
811            prepared.indent_cols,
812            self.config.dialect,
813        )
814        .is_some()
815        {
816            return false;
817        }
818
819        match self.containers.last() {
820            Some(Container::Paragraph { .. }) => {
821                paragraphs::append_paragraph_line(
822                    &mut self.containers,
823                    &mut self.builder,
824                    content,
825                    self.config,
826                );
827                true
828            }
829            Some(Container::ListItem { .. }) => {
830                if let Some(Container::ListItem {
831                    buffer,
832                    marker_only,
833                    ..
834                }) = self.containers.stack.last_mut()
835                {
836                    buffer.push_text(content);
837                    if !content.trim().is_empty() {
838                        *marker_only = false;
839                    }
840                }
841                true
842            }
843            _ => false,
844        }
845    }
846
847    fn handle_list_open_effect(
848        &mut self,
849        block_match: &super::block_dispatcher::PreparedBlockMatch,
850        content: &str,
851        indent_to_emit: Option<&str>,
852    ) {
853        use super::block_dispatcher::ListPrepared;
854
855        let prepared = block_match
856            .payload
857            .as_ref()
858            .and_then(|p| p.downcast_ref::<ListPrepared>());
859        let Some(prepared) = prepared else {
860            return;
861        };
862
863        if prepared.indent_cols >= 4 && !lists::in_list(&self.containers) {
864            paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
865            paragraphs::append_paragraph_line(
866                &mut self.containers,
867                &mut self.builder,
868                content,
869                self.config,
870            );
871            return;
872        }
873
874        if self.is_paragraph_open() {
875            if !block_match.detection.eq(&BlockDetectionResult::Yes) {
876                paragraphs::append_paragraph_line(
877                    &mut self.containers,
878                    &mut self.builder,
879                    content,
880                    self.config,
881                );
882                return;
883            }
884            self.close_containers_to(self.containers.depth() - 1);
885        }
886
887        if matches!(
888            self.containers.last(),
889            Some(Container::Definition {
890                plain_open: true,
891                ..
892            })
893        ) {
894            self.emit_buffered_plain_if_needed();
895        }
896
897        let matched_level = lists::find_matching_list_level(
898            &self.containers,
899            &prepared.marker,
900            prepared.indent_cols,
901            self.config.dialect,
902        );
903        let list_item = ListItemEmissionInput {
904            content,
905            marker_len: prepared.marker_len,
906            spaces_after_cols: prepared.spaces_after_cols,
907            spaces_after_bytes: prepared.spaces_after,
908            indent_cols: prepared.indent_cols,
909            indent_bytes: prepared.indent_bytes,
910            virtual_marker_space: prepared.virtual_marker_space,
911        };
912        let current_content_col = paragraphs::current_content_col(&self.containers);
913        let deep_ordered_matched_level = matched_level
914            .and_then(|level| self.containers.stack.get(level).map(|c| (level, c)))
915            .and_then(|(level, container)| match container {
916                Container::List {
917                    marker: list_marker,
918                    base_indent_cols,
919                    ..
920                } if matches!(
921                    (&prepared.marker, list_marker),
922                    (ListMarker::Ordered(_), ListMarker::Ordered(_))
923                ) && prepared.indent_cols >= 4
924                    && *base_indent_cols >= 4
925                    && prepared.indent_cols.abs_diff(*base_indent_cols) <= 3 =>
926                {
927                    Some(level)
928                }
929                _ => None,
930            });
931
932        if deep_ordered_matched_level.is_none()
933            && current_content_col > 0
934            && prepared.indent_cols >= current_content_col
935        {
936            if let Some(level) = matched_level
937                && let Some(Container::List {
938                    base_indent_cols, ..
939                }) = self.containers.stack.get(level)
940                && prepared.indent_cols == *base_indent_cols
941            {
942                let num_parent_lists = self.containers.stack[..level]
943                    .iter()
944                    .filter(|c| matches!(c, Container::List { .. }))
945                    .count();
946
947                if num_parent_lists > 0 {
948                    self.close_containers_to(level + 1);
949
950                    if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
951                        self.close_containers_to(self.containers.depth() - 1);
952                    }
953                    if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
954                        self.close_containers_to(self.containers.depth() - 1);
955                    }
956
957                    if let Some(indent_str) = indent_to_emit {
958                        self.builder
959                            .token(SyntaxKind::WHITESPACE.into(), indent_str);
960                    }
961
962                    if let Some(nested_marker) = prepared.nested_marker {
963                        lists::add_list_item_with_nested_empty_list(
964                            &mut self.containers,
965                            &mut self.builder,
966                            &list_item,
967                            nested_marker,
968                        );
969                    } else {
970                        lists::add_list_item(
971                            &mut self.containers,
972                            &mut self.builder,
973                            &list_item,
974                            self.config,
975                        );
976                    }
977                    self.maybe_open_fenced_code_in_new_list_item();
978                    self.maybe_open_indented_code_in_new_list_item();
979                    return;
980                }
981            }
982
983            self.emit_list_item_buffer_if_needed();
984
985            start_nested_list(
986                &mut self.containers,
987                &mut self.builder,
988                &prepared.marker,
989                &list_item,
990                indent_to_emit,
991                self.config,
992            );
993            self.maybe_open_fenced_code_in_new_list_item();
994            self.maybe_open_indented_code_in_new_list_item();
995            return;
996        }
997
998        if let Some(level) = matched_level {
999            self.close_containers_to(level + 1);
1000
1001            if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1002                self.close_containers_to(self.containers.depth() - 1);
1003            }
1004            if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
1005                self.close_containers_to(self.containers.depth() - 1);
1006            }
1007
1008            if let Some(indent_str) = indent_to_emit {
1009                self.builder
1010                    .token(SyntaxKind::WHITESPACE.into(), indent_str);
1011            }
1012
1013            if let Some(nested_marker) = prepared.nested_marker {
1014                lists::add_list_item_with_nested_empty_list(
1015                    &mut self.containers,
1016                    &mut self.builder,
1017                    &list_item,
1018                    nested_marker,
1019                );
1020            } else {
1021                lists::add_list_item(
1022                    &mut self.containers,
1023                    &mut self.builder,
1024                    &list_item,
1025                    self.config,
1026                );
1027            }
1028            self.maybe_open_fenced_code_in_new_list_item();
1029            self.maybe_open_indented_code_in_new_list_item();
1030            return;
1031        }
1032
1033        if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1034            self.close_containers_to(self.containers.depth() - 1);
1035        }
1036        while matches!(
1037            self.containers.last(),
1038            Some(Container::ListItem { .. } | Container::List { .. })
1039        ) {
1040            self.close_containers_to(self.containers.depth() - 1);
1041        }
1042
1043        self.builder.start_node(SyntaxKind::LIST.into());
1044        if let Some(indent_str) = indent_to_emit {
1045            self.builder
1046                .token(SyntaxKind::WHITESPACE.into(), indent_str);
1047        }
1048        self.containers.push(Container::List {
1049            marker: prepared.marker.clone(),
1050            base_indent_cols: prepared.indent_cols,
1051            has_blank_between_items: false,
1052        });
1053
1054        if let Some(nested_marker) = prepared.nested_marker {
1055            lists::add_list_item_with_nested_empty_list(
1056                &mut self.containers,
1057                &mut self.builder,
1058                &list_item,
1059                nested_marker,
1060            );
1061        } else {
1062            lists::add_list_item(
1063                &mut self.containers,
1064                &mut self.builder,
1065                &list_item,
1066                self.config,
1067            );
1068        }
1069        self.maybe_open_fenced_code_in_new_list_item();
1070        self.maybe_open_indented_code_in_new_list_item();
1071    }
1072
1073    fn handle_definition_list_effect(
1074        &mut self,
1075        block_match: &super::block_dispatcher::PreparedBlockMatch,
1076        content: &str,
1077        indent_to_emit: Option<&str>,
1078    ) {
1079        use super::block_dispatcher::DefinitionPrepared;
1080
1081        let prepared = block_match
1082            .payload
1083            .as_ref()
1084            .and_then(|p| p.downcast_ref::<DefinitionPrepared>());
1085        let Some(prepared) = prepared else {
1086            return;
1087        };
1088
1089        match prepared {
1090            DefinitionPrepared::Definition {
1091                marker_char,
1092                indent,
1093                spaces_after,
1094                spaces_after_cols,
1095                has_content,
1096            } => {
1097                self.emit_buffered_plain_if_needed();
1098
1099                while matches!(self.containers.last(), Some(Container::ListItem { .. })) {
1100                    self.close_containers_to(self.containers.depth() - 1);
1101                }
1102                while matches!(self.containers.last(), Some(Container::List { .. })) {
1103                    self.close_containers_to(self.containers.depth() - 1);
1104                }
1105
1106                if matches!(self.containers.last(), Some(Container::Definition { .. })) {
1107                    self.close_containers_to(self.containers.depth() - 1);
1108                }
1109
1110                if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1111                    self.close_containers_to(self.containers.depth() - 1);
1112                }
1113
1114                // A definition marker cannot start a new definition item without a term.
1115                // If the preceding term/item was closed by a blank line but we are still
1116                // inside the same definition list, reopen a definition item for continuation.
1117                if definition_lists::in_definition_list(&self.containers)
1118                    && !matches!(
1119                        self.containers.last(),
1120                        Some(Container::DefinitionItem { .. })
1121                    )
1122                {
1123                    self.builder.start_node(SyntaxKind::DEFINITION_ITEM.into());
1124                    self.containers.push(Container::DefinitionItem {});
1125                }
1126
1127                if !definition_lists::in_definition_list(&self.containers) {
1128                    self.builder.start_node(SyntaxKind::DEFINITION_LIST.into());
1129                    self.containers.push(Container::DefinitionList {});
1130                }
1131
1132                if !matches!(
1133                    self.containers.last(),
1134                    Some(Container::DefinitionItem { .. })
1135                ) {
1136                    self.builder.start_node(SyntaxKind::DEFINITION_ITEM.into());
1137                    self.containers.push(Container::DefinitionItem {});
1138                }
1139
1140                self.builder.start_node(SyntaxKind::DEFINITION.into());
1141
1142                if let Some(indent_str) = indent_to_emit {
1143                    self.builder
1144                        .token(SyntaxKind::WHITESPACE.into(), indent_str);
1145                }
1146
1147                emit_definition_marker(&mut self.builder, *marker_char, *indent);
1148                let indent_bytes = byte_index_at_column(content, *indent);
1149                if *spaces_after > 0 {
1150                    let space_start = indent_bytes + 1;
1151                    let space_end = space_start + *spaces_after;
1152                    if space_end <= content.len() {
1153                        self.builder.token(
1154                            SyntaxKind::WHITESPACE.into(),
1155                            &content[space_start..space_end],
1156                        );
1157                    }
1158                }
1159
1160                if !*has_content {
1161                    let current_line = self.lines[self.pos];
1162                    let (_, newline_str) = strip_newline(current_line);
1163                    if !newline_str.is_empty() {
1164                        self.builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1165                    }
1166                }
1167
1168                let content_col = *indent + 1 + *spaces_after_cols;
1169                let content_start_bytes = indent_bytes + 1 + *spaces_after;
1170                let after_marker_and_spaces = content.get(content_start_bytes..).unwrap_or("");
1171                let mut plain_buffer = TextBuffer::new();
1172                let mut definition_pushed = false;
1173
1174                if *has_content {
1175                    let current_line = self.lines[self.pos];
1176                    let (trimmed_line, _) = strip_newline(current_line);
1177
1178                    let content_start = content_start_bytes.min(trimmed_line.len());
1179                    let content_slice = &trimmed_line[content_start..];
1180                    let content_line = &current_line[content_start_bytes.min(current_line.len())..];
1181
1182                    let (blockquote_depth, inner_blockquote_content) =
1183                        count_blockquote_markers(content_line);
1184
1185                    let should_start_list_from_first_line = self
1186                        .lines
1187                        .get(self.pos + 1)
1188                        .map(|next_line| {
1189                            let (next_without_newline, _) = strip_newline(next_line);
1190                            if next_without_newline.trim().is_empty() {
1191                                return true;
1192                            }
1193
1194                            let (next_indent_cols, _) = leading_indent(next_without_newline);
1195                            next_indent_cols >= content_col
1196                        })
1197                        .unwrap_or(true);
1198
1199                    if blockquote_depth > 0 {
1200                        self.containers.push(Container::Definition {
1201                            content_col,
1202                            plain_open: false,
1203                            plain_buffer: TextBuffer::new(),
1204                        });
1205                        definition_pushed = true;
1206
1207                        let marker_info = parse_blockquote_marker_info(content_line);
1208                        for level in 0..blockquote_depth {
1209                            self.builder.start_node(SyntaxKind::BLOCK_QUOTE.into());
1210                            if let Some(info) = marker_info.get(level) {
1211                                blockquotes::emit_one_blockquote_marker(
1212                                    &mut self.builder,
1213                                    info.leading_spaces,
1214                                    info.has_trailing_space,
1215                                );
1216                            }
1217                            self.containers.push(Container::BlockQuote {});
1218                        }
1219
1220                        if !inner_blockquote_content.trim().is_empty() {
1221                            paragraphs::start_paragraph_if_needed(
1222                                &mut self.containers,
1223                                &mut self.builder,
1224                            );
1225                            paragraphs::append_paragraph_line(
1226                                &mut self.containers,
1227                                &mut self.builder,
1228                                inner_blockquote_content,
1229                                self.config,
1230                            );
1231                        }
1232                    } else if let Some(marker_match) =
1233                        try_parse_list_marker(content_slice, self.config)
1234                        && should_start_list_from_first_line
1235                    {
1236                        self.containers.push(Container::Definition {
1237                            content_col,
1238                            plain_open: false,
1239                            plain_buffer: TextBuffer::new(),
1240                        });
1241                        definition_pushed = true;
1242
1243                        let (indent_cols, indent_bytes) = leading_indent(content_line);
1244                        self.builder.start_node(SyntaxKind::LIST.into());
1245                        self.containers.push(Container::List {
1246                            marker: marker_match.marker.clone(),
1247                            base_indent_cols: indent_cols,
1248                            has_blank_between_items: false,
1249                        });
1250
1251                        let list_item = ListItemEmissionInput {
1252                            content: content_line,
1253                            marker_len: marker_match.marker_len,
1254                            spaces_after_cols: marker_match.spaces_after_cols,
1255                            spaces_after_bytes: marker_match.spaces_after_bytes,
1256                            indent_cols,
1257                            indent_bytes,
1258                            virtual_marker_space: marker_match.virtual_marker_space,
1259                        };
1260
1261                        if let Some(nested_marker) = is_content_nested_bullet_marker(
1262                            content_line,
1263                            marker_match.marker_len,
1264                            marker_match.spaces_after_bytes,
1265                        ) {
1266                            lists::add_list_item_with_nested_empty_list(
1267                                &mut self.containers,
1268                                &mut self.builder,
1269                                &list_item,
1270                                nested_marker,
1271                            );
1272                        } else {
1273                            lists::add_list_item(
1274                                &mut self.containers,
1275                                &mut self.builder,
1276                                &list_item,
1277                                self.config,
1278                            );
1279                        }
1280                    } else if let Some(fence) = code_blocks::try_parse_fence_open(content_slice) {
1281                        self.containers.push(Container::Definition {
1282                            content_col,
1283                            plain_open: false,
1284                            plain_buffer: TextBuffer::new(),
1285                        });
1286                        definition_pushed = true;
1287
1288                        let bq_depth = self.current_blockquote_depth();
1289                        if let Some(indent_str) = indent_to_emit {
1290                            self.builder
1291                                .token(SyntaxKind::WHITESPACE.into(), indent_str);
1292                        }
1293                        let fence_line = current_line[content_start..].to_string();
1294                        let new_pos = if self.config.extensions.tex_math_gfm
1295                            && code_blocks::is_gfm_math_fence(&fence)
1296                        {
1297                            code_blocks::parse_fenced_math_block(
1298                                &mut self.builder,
1299                                &self.lines,
1300                                self.pos,
1301                                fence,
1302                                bq_depth,
1303                                content_col,
1304                                Some(&fence_line),
1305                            )
1306                        } else {
1307                            code_blocks::parse_fenced_code_block(
1308                                &mut self.builder,
1309                                &self.lines,
1310                                self.pos,
1311                                fence,
1312                                bq_depth,
1313                                content_col,
1314                                Some(&fence_line),
1315                            )
1316                        };
1317                        self.pos = new_pos - 1;
1318                    } else {
1319                        let (_, newline_str) = strip_newline(current_line);
1320                        let (content_without_newline, _) = strip_newline(after_marker_and_spaces);
1321                        if content_without_newline.is_empty() {
1322                            plain_buffer.push_line(newline_str);
1323                        } else {
1324                            let line_with_newline = if !newline_str.is_empty() {
1325                                format!("{}{}", content_without_newline, newline_str)
1326                            } else {
1327                                content_without_newline.to_string()
1328                            };
1329                            plain_buffer.push_line(line_with_newline);
1330                        }
1331                    }
1332                }
1333
1334                if !definition_pushed {
1335                    self.containers.push(Container::Definition {
1336                        content_col,
1337                        plain_open: *has_content,
1338                        plain_buffer,
1339                    });
1340                }
1341            }
1342            DefinitionPrepared::Term { blank_count } => {
1343                self.emit_buffered_plain_if_needed();
1344
1345                if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1346                    self.close_containers_to(self.containers.depth() - 1);
1347                }
1348
1349                if !definition_lists::in_definition_list(&self.containers) {
1350                    self.builder.start_node(SyntaxKind::DEFINITION_LIST.into());
1351                    self.containers.push(Container::DefinitionList {});
1352                }
1353
1354                while matches!(
1355                    self.containers.last(),
1356                    Some(Container::Definition { .. }) | Some(Container::DefinitionItem { .. })
1357                ) {
1358                    self.close_containers_to(self.containers.depth() - 1);
1359                }
1360
1361                self.builder.start_node(SyntaxKind::DEFINITION_ITEM.into());
1362                self.containers.push(Container::DefinitionItem {});
1363
1364                emit_term(&mut self.builder, content, self.config);
1365
1366                for i in 0..*blank_count {
1367                    let blank_pos = self.pos + 1 + i;
1368                    if blank_pos < self.lines.len() {
1369                        let blank_line = self.lines[blank_pos];
1370                        self.builder.start_node(SyntaxKind::BLANK_LINE.into());
1371                        self.builder
1372                            .token(SyntaxKind::BLANK_LINE.into(), blank_line);
1373                        self.builder.finish_node();
1374                    }
1375                }
1376                self.pos += *blank_count;
1377            }
1378        }
1379    }
1380
1381    /// Get current blockquote depth from container stack.
1382    fn blockquote_marker_info(
1383        &self,
1384        payload: Option<&BlockQuotePrepared>,
1385        line: &str,
1386    ) -> Vec<marker_utils::BlockQuoteMarkerInfo> {
1387        payload
1388            .map(|payload| payload.marker_info.clone())
1389            .unwrap_or_else(|| parse_blockquote_marker_info(line))
1390    }
1391
1392    /// Build blockquote marker metadata for the current source line.
1393    ///
1394    /// When a blockquote marker is detected at a shifted list content column
1395    /// (e.g. `    > ...` inside a list item), the prefix indentation must be
1396    /// folded into the first marker's leading spaces for lossless emission.
1397    fn marker_info_for_line(
1398        &self,
1399        payload: Option<&BlockQuotePrepared>,
1400        raw_line: &str,
1401        marker_line: &str,
1402        shifted_prefix: &str,
1403        used_shifted: bool,
1404    ) -> Vec<marker_utils::BlockQuoteMarkerInfo> {
1405        let mut marker_info = if used_shifted {
1406            parse_blockquote_marker_info(marker_line)
1407        } else {
1408            self.blockquote_marker_info(payload, raw_line)
1409        };
1410        if used_shifted && !shifted_prefix.is_empty() {
1411            let (prefix_cols, _) = leading_indent(shifted_prefix);
1412            if let Some(first) = marker_info.first_mut() {
1413                first.leading_spaces += prefix_cols;
1414            }
1415        }
1416        marker_info
1417    }
1418
1419    /// Detect blockquote markers that begin at list-content indentation instead
1420    /// of column 0 on the physical line.
1421    fn shifted_blockquote_from_list<'b>(
1422        &self,
1423        line: &'b str,
1424    ) -> Option<(usize, &'b str, &'b str, &'b str)> {
1425        let list_content_col = paragraphs::current_content_col(&self.containers);
1426        let content_container_indent = self.content_container_indent_to_strip();
1427        let marker_col = list_content_col.saturating_add(content_container_indent);
1428        if marker_col == 0 {
1429            return None;
1430        }
1431
1432        let (indent_cols, _) = leading_indent(line);
1433        if indent_cols < marker_col {
1434            return None;
1435        }
1436
1437        let idx = byte_index_at_column(line, marker_col);
1438        if idx > line.len() {
1439            return None;
1440        }
1441
1442        let candidate = &line[idx..];
1443        let (candidate_depth, candidate_inner) = count_blockquote_markers(candidate);
1444        if candidate_depth == 0 {
1445            return None;
1446        }
1447
1448        Some((candidate_depth, candidate_inner, candidate, &line[..idx]))
1449    }
1450
1451    fn emit_blockquote_markers(
1452        &mut self,
1453        marker_info: &[marker_utils::BlockQuoteMarkerInfo],
1454        depth: usize,
1455    ) {
1456        for i in 0..depth {
1457            if let Some(info) = marker_info.get(i) {
1458                blockquotes::emit_one_blockquote_marker(
1459                    &mut self.builder,
1460                    info.leading_spaces,
1461                    info.has_trailing_space,
1462                );
1463            }
1464        }
1465    }
1466
1467    fn current_blockquote_depth(&self) -> usize {
1468        blockquotes::current_blockquote_depth(&self.containers)
1469    }
1470
1471    /// Emit or buffer a blockquote marker depending on parser state.
1472    ///
1473    /// If a paragraph is open and we're using integrated parsing, buffer the marker.
1474    /// Otherwise emit it directly to the builder.
1475    fn emit_or_buffer_blockquote_marker(
1476        &mut self,
1477        leading_spaces: usize,
1478        has_trailing_space: bool,
1479    ) {
1480        if let Some(Container::ListItem {
1481            buffer,
1482            marker_only,
1483            ..
1484        }) = self.containers.stack.last_mut()
1485        {
1486            buffer.push_blockquote_marker(leading_spaces, has_trailing_space);
1487            *marker_only = false;
1488            return;
1489        }
1490
1491        // If paragraph is open, buffer the marker (it will be emitted at correct position)
1492        if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1493            // Buffer the marker in the paragraph
1494            paragraphs::append_paragraph_marker(
1495                &mut self.containers,
1496                leading_spaces,
1497                has_trailing_space,
1498            );
1499        } else {
1500            // Emit directly
1501            blockquotes::emit_one_blockquote_marker(
1502                &mut self.builder,
1503                leading_spaces,
1504                has_trailing_space,
1505            );
1506        }
1507    }
1508
1509    fn parse_document_stack(&mut self) {
1510        self.builder.start_node(SyntaxKind::DOCUMENT.into());
1511
1512        log::trace!("Starting document parse");
1513
1514        // Pandoc title block is handled via the block dispatcher.
1515
1516        while self.pos < self.lines.len() {
1517            let line = self.lines[self.pos];
1518
1519            log::trace!("Parsing line {}: {}", self.pos + 1, line);
1520
1521            if self.parse_line(line) {
1522                continue;
1523            }
1524            self.pos += 1;
1525        }
1526
1527        self.close_containers_to(0);
1528        self.builder.finish_node(); // DOCUMENT
1529    }
1530
1531    /// Returns true if the line was consumed.
1532    fn parse_line(&mut self, line: &str) -> bool {
1533        // Count blockquote markers on this line. Inside list items, blockquotes can begin
1534        // at the list content column (e.g. `    > ...` after `1. `), not at column 0.
1535        let (mut bq_depth, mut inner_content) = count_blockquote_markers(line);
1536        let mut bq_marker_line = line;
1537        let mut shifted_bq_prefix = "";
1538        let mut used_shifted_bq = false;
1539        if bq_depth == 0
1540            && let Some((candidate_depth, candidate_inner, candidate_line, candidate_prefix)) =
1541                self.shifted_blockquote_from_list(line)
1542        {
1543            bq_depth = candidate_depth;
1544            inner_content = candidate_inner;
1545            bq_marker_line = candidate_line;
1546            shifted_bq_prefix = candidate_prefix;
1547            used_shifted_bq = true;
1548        }
1549        let current_bq_depth = self.current_blockquote_depth();
1550
1551        let has_blank_before = self.pos == 0 || is_blank_line(self.lines[self.pos - 1]);
1552        let mut blockquote_match: Option<PreparedBlockMatch> = None;
1553        let dispatcher_ctx = if current_bq_depth == 0 {
1554            Some(BlockContext {
1555                content: line,
1556                has_blank_before,
1557                has_blank_before_strict: has_blank_before,
1558                at_document_start: self.pos == 0,
1559                in_fenced_div: self.in_fenced_div(),
1560                blockquote_depth: current_bq_depth,
1561                config: self.config,
1562                content_indent: 0,
1563                indent_to_emit: None,
1564                list_indent_info: None,
1565                in_list: lists::in_list(&self.containers),
1566                in_marker_only_list_item: matches!(
1567                    self.containers.last(),
1568                    Some(Container::ListItem {
1569                        marker_only: true,
1570                        ..
1571                    })
1572                ),
1573                paragraph_open: self.is_paragraph_open(),
1574                next_line: if self.pos + 1 < self.lines.len() {
1575                    Some(self.lines[self.pos + 1])
1576                } else {
1577                    None
1578                },
1579            })
1580        } else {
1581            None
1582        };
1583
1584        let blockquote_payload = if let Some(dispatcher_ctx) = dispatcher_ctx.as_ref() {
1585            self.block_registry
1586                .detect_prepared(dispatcher_ctx, &self.lines, self.pos)
1587                .and_then(|prepared| {
1588                    if matches!(prepared.effect, BlockEffect::OpenBlockQuote) {
1589                        blockquote_match = Some(prepared);
1590                        blockquote_match.as_ref().and_then(|prepared| {
1591                            prepared
1592                                .payload
1593                                .as_ref()
1594                                .and_then(|payload| payload.downcast_ref::<BlockQuotePrepared>())
1595                                .cloned()
1596                        })
1597                    } else {
1598                        None
1599                    }
1600                })
1601        } else {
1602            None
1603        };
1604
1605        log::trace!(
1606            "parse_line [{}]: bq_depth={}, current_bq={}, depth={}, line={:?}",
1607            self.pos,
1608            bq_depth,
1609            current_bq_depth,
1610            self.containers.depth(),
1611            line.trim_end()
1612        );
1613
1614        // Handle blank lines specially (including blank lines inside blockquotes)
1615        // A line like ">" with nothing after is a blank line inside a blockquote —
1616        // but only when we're already inside one (or one can legitimately start
1617        // here under the active blank_before_blockquote rule). Otherwise treating
1618        // it as blank would silently open a blockquote mid-paragraph, diverging
1619        // from pandoc which keeps the whole thing as one paragraph.
1620        let inner_blank_in_blockquote = bq_depth > 0
1621            && is_blank_line(inner_content)
1622            && (current_bq_depth > 0
1623                || !self.config.extensions.blank_before_blockquote
1624                || blockquotes::can_start_blockquote(self.pos, &self.lines));
1625        let is_blank = is_blank_line(line) || inner_blank_in_blockquote;
1626
1627        if is_blank {
1628            if self.is_paragraph_open()
1629                && paragraphs::has_open_inline_math_environment(&self.containers)
1630            {
1631                paragraphs::append_paragraph_line(
1632                    &mut self.containers,
1633                    &mut self.builder,
1634                    line,
1635                    self.config,
1636                );
1637                self.pos += 1;
1638                return true;
1639            }
1640
1641            // Close paragraph if open
1642            self.close_paragraph_if_open();
1643
1644            // Close Plain node in Definition if open
1645            // Blank lines should close Plain, allowing subsequent content to be siblings
1646            // Emit buffered PLAIN content before continuing
1647            self.emit_buffered_plain_if_needed();
1648
1649            // Note: Blank lines between terms and definitions are now preserved
1650            // and emitted as part of the term parsing logic
1651
1652            // For blank lines inside blockquotes, we need to handle them at the right depth.
1653            // If a shifted blockquote marker was detected in list-item content, preserve the
1654            // leading shifted indentation before the first marker for losslessness.
1655            // First, adjust blockquote depth if needed
1656            if bq_depth > current_bq_depth {
1657                // Open blockquotes
1658                for _ in current_bq_depth..bq_depth {
1659                    self.builder.start_node(SyntaxKind::BLOCK_QUOTE.into());
1660                    self.containers.push(Container::BlockQuote {});
1661                }
1662            } else if bq_depth < current_bq_depth {
1663                // Close blockquotes down to bq_depth (must use Parser close to emit buffers)
1664                self.close_blockquotes_to_depth(bq_depth);
1665            }
1666
1667            // Peek ahead to determine what containers to keep open. Skip
1668            // truly blank lines and, when this blank line is inside a
1669            // blockquote, blank-inside-blockquote lines too (e.g. `>` or
1670            // `>   `) so multiple consecutive `>`-blank lines don't make
1671            // the next non-blank line look like it's outside the
1672            // blockquote's continuation context.
1673            let mut peek = self.pos + 1;
1674            while peek < self.lines.len() {
1675                let peek_line = self.lines[peek];
1676                if is_blank_line(peek_line) {
1677                    peek += 1;
1678                    continue;
1679                }
1680                if bq_depth > 0 {
1681                    let (peek_bq, _) = count_blockquote_markers(peek_line);
1682                    if peek_bq >= bq_depth {
1683                        let peek_inner =
1684                            blockquotes::strip_n_blockquote_markers(peek_line, bq_depth);
1685                        if is_blank_line(peek_inner) {
1686                            peek += 1;
1687                            continue;
1688                        }
1689                    }
1690                }
1691                break;
1692            }
1693
1694            // Determine what containers to keep open based on next line
1695            let levels_to_keep = if peek < self.lines.len() {
1696                ContinuationPolicy::new(self.config, &self.block_registry).compute_levels_to_keep(
1697                    self.current_blockquote_depth(),
1698                    &self.containers,
1699                    &self.lines,
1700                    peek,
1701                    self.lines[peek],
1702                )
1703            } else {
1704                0
1705            };
1706            log::trace!(
1707                "Blank line: depth={}, levels_to_keep={}, next='{}'",
1708                self.containers.depth(),
1709                levels_to_keep,
1710                if peek < self.lines.len() {
1711                    self.lines[peek]
1712                } else {
1713                    "<EOF>"
1714                }
1715            );
1716
1717            // Check if blank line should be buffered in a ListItem BEFORE closing containers
1718
1719            // Close containers down to the level we want to keep
1720            while self.containers.depth() > levels_to_keep {
1721                match self.containers.last() {
1722                    Some(Container::ListItem { .. }) => {
1723                        // levels_to_keep wants to close the ListItem - blank line is between items
1724                        log::trace!(
1725                            "Closing ListItem at blank line (levels_to_keep={} < depth={})",
1726                            levels_to_keep,
1727                            self.containers.depth()
1728                        );
1729                        self.close_containers_to(self.containers.depth() - 1);
1730                    }
1731                    Some(Container::List { .. })
1732                    | Some(Container::FootnoteDefinition { .. })
1733                    | Some(Container::Alert { .. })
1734                    | Some(Container::Paragraph { .. })
1735                    | Some(Container::Definition { .. })
1736                    | Some(Container::DefinitionItem { .. })
1737                    | Some(Container::DefinitionList { .. }) => {
1738                        log::trace!(
1739                            "Closing {:?} at blank line (depth {} > levels_to_keep {})",
1740                            self.containers.last(),
1741                            self.containers.depth(),
1742                            levels_to_keep
1743                        );
1744
1745                        self.close_containers_to(self.containers.depth() - 1);
1746                    }
1747                    _ => break,
1748                }
1749            }
1750
1751            // If we kept a list item open, its first-line text may still be buffered.
1752            // Flush it *before* emitting the blank line node (and its blockquote markers)
1753            // so byte order matches the source.
1754            if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
1755                self.emit_list_item_buffer_if_needed();
1756            }
1757
1758            // Emit blockquote markers for this blank line if inside blockquotes
1759            if bq_depth > 0 {
1760                let marker_info = self.marker_info_for_line(
1761                    blockquote_payload.as_ref(),
1762                    line,
1763                    bq_marker_line,
1764                    shifted_bq_prefix,
1765                    used_shifted_bq,
1766                );
1767                self.emit_blockquote_markers(&marker_info, bq_depth);
1768            }
1769
1770            self.builder.start_node(SyntaxKind::BLANK_LINE.into());
1771            self.builder
1772                .token(SyntaxKind::BLANK_LINE.into(), inner_content);
1773            self.builder.finish_node();
1774
1775            self.pos += 1;
1776            return true;
1777        }
1778
1779        // Handle blockquote depth changes
1780        if bq_depth > current_bq_depth {
1781            // Need to open new blockquote(s)
1782            // But first check blank_before_blockquote requirement
1783            if self.config.extensions.blank_before_blockquote
1784                && current_bq_depth == 0
1785                && !used_shifted_bq
1786                && !blockquote_payload
1787                    .as_ref()
1788                    .map(|payload| payload.can_start)
1789                    .unwrap_or_else(|| blockquotes::can_start_blockquote(self.pos, &self.lines))
1790            {
1791                // Can't start blockquote without blank line - treat as paragraph
1792                // Flush any pending list-item inline buffer first so this line
1793                // stays in source order relative to buffered list text.
1794                self.emit_list_item_buffer_if_needed();
1795                paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
1796                paragraphs::append_paragraph_line(
1797                    &mut self.containers,
1798                    &mut self.builder,
1799                    line,
1800                    self.config,
1801                );
1802                self.pos += 1;
1803                return true;
1804            }
1805
1806            // For nested blockquotes, also need blank line before (blank_before_blockquote)
1807            // Check if previous line inside the blockquote was blank
1808            let can_nest = if current_bq_depth > 0 {
1809                if self.config.extensions.blank_before_blockquote {
1810                    // Check if we're right after a blank line or at start of blockquote
1811                    matches!(self.containers.last(), Some(Container::BlockQuote { .. }))
1812                        || (self.pos > 0 && {
1813                            let prev_line = self.lines[self.pos - 1];
1814                            let (prev_bq_depth, prev_inner) = count_blockquote_markers(prev_line);
1815                            prev_bq_depth >= current_bq_depth && is_blank_line(prev_inner)
1816                        })
1817                } else {
1818                    true
1819                }
1820            } else {
1821                blockquote_payload
1822                    .as_ref()
1823                    .map(|payload| payload.can_nest)
1824                    .unwrap_or(true)
1825            };
1826
1827            if !can_nest {
1828                // Can't nest deeper - treat extra > as content
1829                // Only strip markers up to current depth
1830                let content_at_current_depth =
1831                    blockquotes::strip_n_blockquote_markers(line, current_bq_depth);
1832
1833                // Emit blockquote markers for current depth (for losslessness)
1834                let marker_info = self.marker_info_for_line(
1835                    blockquote_payload.as_ref(),
1836                    line,
1837                    bq_marker_line,
1838                    shifted_bq_prefix,
1839                    used_shifted_bq,
1840                );
1841                for i in 0..current_bq_depth {
1842                    if let Some(info) = marker_info.get(i) {
1843                        self.emit_or_buffer_blockquote_marker(
1844                            info.leading_spaces,
1845                            info.has_trailing_space,
1846                        );
1847                    }
1848                }
1849
1850                if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1851                    // Lazy continuation with the extra > as content
1852                    paragraphs::append_paragraph_line(
1853                        &mut self.containers,
1854                        &mut self.builder,
1855                        content_at_current_depth,
1856                        self.config,
1857                    );
1858                    self.pos += 1;
1859                    return true;
1860                } else {
1861                    // Start new paragraph with the extra > as content
1862                    paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
1863                    paragraphs::append_paragraph_line(
1864                        &mut self.containers,
1865                        &mut self.builder,
1866                        content_at_current_depth,
1867                        self.config,
1868                    );
1869                    self.pos += 1;
1870                    return true;
1871                }
1872            }
1873
1874            // Preserve source order when a deeper blockquote line arrives while
1875            // list-item text is still buffered (e.g. issue #174).
1876            self.emit_list_item_buffer_if_needed();
1877
1878            // Close paragraph before opening blockquote
1879            if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1880                self.close_containers_to(self.containers.depth() - 1);
1881            }
1882
1883            // Parse marker information for all levels
1884            let marker_info = self.marker_info_for_line(
1885                blockquote_payload.as_ref(),
1886                line,
1887                bq_marker_line,
1888                shifted_bq_prefix,
1889                used_shifted_bq,
1890            );
1891
1892            if let (Some(dispatcher_ctx), Some(prepared)) =
1893                (dispatcher_ctx.as_ref(), blockquote_match.as_ref())
1894            {
1895                let _ = self.block_registry.parse_prepared(
1896                    prepared,
1897                    dispatcher_ctx,
1898                    &mut self.builder,
1899                    &self.lines,
1900                    self.pos,
1901                );
1902                for _ in 0..bq_depth {
1903                    self.containers.push(Container::BlockQuote {});
1904                }
1905            } else {
1906                // First, emit markers for existing blockquote levels (before opening new ones)
1907                for level in 0..current_bq_depth {
1908                    if let Some(info) = marker_info.get(level) {
1909                        self.emit_or_buffer_blockquote_marker(
1910                            info.leading_spaces,
1911                            info.has_trailing_space,
1912                        );
1913                    }
1914                }
1915
1916                // Then open new blockquotes and emit their markers
1917                for level in current_bq_depth..bq_depth {
1918                    self.builder.start_node(SyntaxKind::BLOCK_QUOTE.into());
1919
1920                    // Emit the marker for this new level
1921                    if let Some(info) = marker_info.get(level) {
1922                        blockquotes::emit_one_blockquote_marker(
1923                            &mut self.builder,
1924                            info.leading_spaces,
1925                            info.has_trailing_space,
1926                        );
1927                    }
1928
1929                    self.containers.push(Container::BlockQuote {});
1930                }
1931            }
1932
1933            // Now parse the inner content
1934            // Pass inner_content as line_to_append since markers are already stripped
1935            return self.parse_inner_content(inner_content, Some(inner_content));
1936        } else if bq_depth < current_bq_depth {
1937            // Need to close some blockquotes, but first check for lazy continuation
1938            // Lazy continuation: line with fewer (or zero) > markers continues
1939            // a paragraph that started at a deeper blockquote level. CommonMark
1940            // §5.1 explicitly allows this regardless of how many `>` markers
1941            // are on the lazy line.
1942            if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
1943                // CommonMark §5.1: lazy continuation does *not* fire if
1944                // the line would itself be a paragraph-interrupting block
1945                // (e.g. a thematic break) — instead the paragraph closes,
1946                // any open blockquotes close, and the line opens that
1947                // block at the outer level. Pandoc keeps the lazy text
1948                // append in this case.
1949                let is_commonmark = self.config.dialect == crate::options::Dialect::CommonMark;
1950                let interrupts_via_hr = is_commonmark && try_parse_horizontal_rule(line).is_some();
1951                let interrupts_via_fence =
1952                    is_commonmark && code_blocks::try_parse_fence_open(line).is_some();
1953                if !interrupts_via_hr && !interrupts_via_fence {
1954                    if bq_depth > 0 {
1955                        // Buffer the explicit `>` markers we have into the
1956                        // paragraph (it's at the deeper blockquote level, so
1957                        // structurally the markers belong to outer levels but
1958                        // they're tucked inside the paragraph for losslessness;
1959                        // the formatter re-emits prefixes from container nesting).
1960                        let marker_info = self.marker_info_for_line(
1961                            blockquote_payload.as_ref(),
1962                            line,
1963                            bq_marker_line,
1964                            shifted_bq_prefix,
1965                            used_shifted_bq,
1966                        );
1967                        for i in 0..bq_depth {
1968                            if let Some(info) = marker_info.get(i) {
1969                                paragraphs::append_paragraph_marker(
1970                                    &mut self.containers,
1971                                    info.leading_spaces,
1972                                    info.has_trailing_space,
1973                                );
1974                            }
1975                        }
1976                        paragraphs::append_paragraph_line(
1977                            &mut self.containers,
1978                            &mut self.builder,
1979                            inner_content,
1980                            self.config,
1981                        );
1982                    } else {
1983                        paragraphs::append_paragraph_line(
1984                            &mut self.containers,
1985                            &mut self.builder,
1986                            line,
1987                            self.config,
1988                        );
1989                    }
1990                    self.pos += 1;
1991                    return true;
1992                }
1993            }
1994            // Lazy continuation of a list item's open content (its
1995            // Plain/Para). Pandoc and CommonMark both fold a no-`>`
1996            // (or short-`>`) plain-text line into the deepest open
1997            // ListItem when the line is not itself a list marker or a
1998            // paragraph-interrupting block. The ListItemBuffer is the
1999            // analogue of an open Paragraph for items whose content
2000            // hasn't been wrapped yet.
2001            if matches!(self.containers.last(), Some(Container::ListItem { .. }))
2002                && lists::in_blockquote_list(&self.containers)
2003                && try_parse_list_marker(line, self.config).is_none()
2004            {
2005                let is_commonmark = self.config.dialect == crate::options::Dialect::CommonMark;
2006                let interrupts_via_hr = is_commonmark && try_parse_horizontal_rule(line).is_some();
2007                let interrupts_via_fence =
2008                    is_commonmark && code_blocks::try_parse_fence_open(line).is_some();
2009                if !interrupts_via_hr && !interrupts_via_fence {
2010                    if bq_depth > 0 {
2011                        let marker_info = self.marker_info_for_line(
2012                            blockquote_payload.as_ref(),
2013                            line,
2014                            bq_marker_line,
2015                            shifted_bq_prefix,
2016                            used_shifted_bq,
2017                        );
2018                        if let Some(Container::ListItem {
2019                            buffer,
2020                            marker_only,
2021                            ..
2022                        }) = self.containers.stack.last_mut()
2023                        {
2024                            for i in 0..bq_depth {
2025                                if let Some(info) = marker_info.get(i) {
2026                                    buffer.push_blockquote_marker(
2027                                        info.leading_spaces,
2028                                        info.has_trailing_space,
2029                                    );
2030                                }
2031                            }
2032                            buffer.push_text(inner_content);
2033                            if !inner_content.trim().is_empty() {
2034                                *marker_only = false;
2035                            }
2036                        }
2037                    } else if let Some(Container::ListItem {
2038                        buffer,
2039                        marker_only,
2040                        ..
2041                    }) = self.containers.stack.last_mut()
2042                    {
2043                        buffer.push_text(line);
2044                        if !line.trim().is_empty() {
2045                            *marker_only = false;
2046                        }
2047                    }
2048                    self.pos += 1;
2049                    return true;
2050                }
2051            }
2052            // CommonMark §5.1: a no-`>` line that begins a list marker
2053            // closes the blockquote and starts a fresh list at the outer
2054            // level rather than continuing the inner list. Pandoc keeps
2055            // the inner list going (lazy list continuation across
2056            // blockquote depth).
2057            if bq_depth == 0 && self.config.dialect != crate::options::Dialect::CommonMark {
2058                // Check for lazy list continuation - if we're in a list item and
2059                // this line looks like a list item with matching marker
2060                if lists::in_blockquote_list(&self.containers)
2061                    && let Some(marker_match) = try_parse_list_marker(line, self.config)
2062                {
2063                    let (indent_cols, indent_bytes) = leading_indent(line);
2064                    if let Some(level) = lists::find_matching_list_level(
2065                        &self.containers,
2066                        &marker_match.marker,
2067                        indent_cols,
2068                        self.config.dialect,
2069                    ) {
2070                        // Continue the list inside the blockquote
2071                        // Close containers to the target level, emitting buffers properly
2072                        self.close_containers_to(level + 1);
2073
2074                        // Close any open paragraph or list item at this level
2075                        if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
2076                            self.close_containers_to(self.containers.depth() - 1);
2077                        }
2078                        if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
2079                            self.close_containers_to(self.containers.depth() - 1);
2080                        }
2081
2082                        // Check if content is a nested bullet marker
2083                        if let Some(nested_marker) = is_content_nested_bullet_marker(
2084                            line,
2085                            marker_match.marker_len,
2086                            marker_match.spaces_after_bytes,
2087                        ) {
2088                            let list_item = ListItemEmissionInput {
2089                                content: line,
2090                                marker_len: marker_match.marker_len,
2091                                spaces_after_cols: marker_match.spaces_after_cols,
2092                                spaces_after_bytes: marker_match.spaces_after_bytes,
2093                                indent_cols,
2094                                indent_bytes,
2095                                virtual_marker_space: marker_match.virtual_marker_space,
2096                            };
2097                            lists::add_list_item_with_nested_empty_list(
2098                                &mut self.containers,
2099                                &mut self.builder,
2100                                &list_item,
2101                                nested_marker,
2102                            );
2103                        } else {
2104                            let list_item = ListItemEmissionInput {
2105                                content: line,
2106                                marker_len: marker_match.marker_len,
2107                                spaces_after_cols: marker_match.spaces_after_cols,
2108                                spaces_after_bytes: marker_match.spaces_after_bytes,
2109                                indent_cols,
2110                                indent_bytes,
2111                                virtual_marker_space: marker_match.virtual_marker_space,
2112                            };
2113                            lists::add_list_item(
2114                                &mut self.containers,
2115                                &mut self.builder,
2116                                &list_item,
2117                                self.config,
2118                            );
2119                        }
2120                        self.pos += 1;
2121                        return true;
2122                    }
2123                }
2124            }
2125
2126            // Not lazy continuation - close paragraph if open
2127            if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
2128                self.close_containers_to(self.containers.depth() - 1);
2129            }
2130
2131            // Close blockquotes down to the new depth (must use Parser close to emit buffers)
2132            self.close_blockquotes_to_depth(bq_depth);
2133
2134            // Parse the inner content at the new depth
2135            if bq_depth > 0 {
2136                // Emit markers at current depth before parsing content
2137                let marker_info = self.marker_info_for_line(
2138                    blockquote_payload.as_ref(),
2139                    line,
2140                    bq_marker_line,
2141                    shifted_bq_prefix,
2142                    used_shifted_bq,
2143                );
2144                for i in 0..bq_depth {
2145                    if let Some(info) = marker_info.get(i) {
2146                        self.emit_or_buffer_blockquote_marker(
2147                            info.leading_spaces,
2148                            info.has_trailing_space,
2149                        );
2150                    }
2151                }
2152                // Content with markers stripped - use inner_content for paragraph appending
2153                return self.parse_inner_content(inner_content, Some(inner_content));
2154            } else {
2155                // Not inside blockquotes - use original line
2156                return self.parse_inner_content(line, None);
2157            }
2158        } else if bq_depth > 0 {
2159            // Same blockquote depth - emit markers and continue parsing inner content
2160            let mut list_item_continuation = false;
2161            let same_depth_marker_info = self.marker_info_for_line(
2162                blockquote_payload.as_ref(),
2163                line,
2164                bq_marker_line,
2165                shifted_bq_prefix,
2166                used_shifted_bq,
2167            );
2168            let has_explicit_same_depth_marker = same_depth_marker_info.len() >= bq_depth;
2169
2170            // Sibling-list-marker continuation across BQ prefix: when the
2171            // BQ-stripped content is a list marker that matches an open
2172            // inner LIST in the container stack, add a sibling LIST_ITEM
2173            // at that level. Pandoc tracks columns through BQ markers, so
2174            // a line like `   > - 2:` (column-aligned) and `> - 2:` (lazy,
2175            // dropped outer continuation indent) are both siblings of an
2176            // open inner LIST inside the BQ. Without this, the dispatcher
2177            // sees the post-strip `- 2:` at column 0 and incorrectly
2178            // opens a new outer-level LIST_ITEM. The lazy form is what
2179            // our own formatter emits — without this branch round-trips
2180            // would not be idempotent.
2181            let (inner_indent_cols_raw, inner_indent_bytes) = leading_indent(inner_content);
2182            if let Some(marker_match) = try_parse_list_marker(inner_content, self.config) {
2183                // Don't steal lines whose leading whitespace inside the BQ
2184                // would push the marker into the previous inner LIST_ITEM's
2185                // content area — those are nested lists, not siblings.
2186                let inner_content_threshold =
2187                    marker_match.marker_len + marker_match.spaces_after_cols;
2188                let is_sibling_candidate = inner_indent_cols_raw < inner_content_threshold;
2189                let sibling_list_level = if is_sibling_candidate {
2190                    self.containers
2191                        .stack
2192                        .iter()
2193                        .enumerate()
2194                        .rev()
2195                        .find_map(|(i, c)| match c {
2196                            Container::List { marker, .. }
2197                                if lists::markers_match(
2198                                    &marker_match.marker,
2199                                    marker,
2200                                    self.config.dialect,
2201                                ) && self.containers.stack[..i]
2202                                    .iter()
2203                                    .filter(|x| matches!(x, Container::BlockQuote { .. }))
2204                                    .count()
2205                                    == bq_depth =>
2206                            {
2207                                Some(i)
2208                            }
2209                            _ => None,
2210                        })
2211                } else {
2212                    None
2213                };
2214                if let Some(list_level) = sibling_list_level {
2215                    // Read the matched LIST's base column before mutating
2216                    // the stack. We use it as the new sibling item's
2217                    // `indent_cols` so subsequent lines can match by
2218                    // source column even when the current line was lazy
2219                    // (its source column wouldn't have lined up).
2220                    let sibling_base_indent_cols = match self.containers.stack.get(list_level) {
2221                        Some(Container::List {
2222                            base_indent_cols, ..
2223                        }) => *base_indent_cols,
2224                        _ => 0,
2225                    };
2226
2227                    // Flush any pending ListItem buffer before closing.
2228                    self.emit_list_item_buffer_if_needed();
2229                    // Close down to the inner LIST level (closing the open
2230                    // inner LIST_ITEM and anything nested inside it).
2231                    self.close_containers_to(list_level + 1);
2232
2233                    // Emit the BQ markers as direct children of the inner
2234                    // LIST node (the builder is currently positioned inside
2235                    // it).
2236                    for i in 0..bq_depth {
2237                        if let Some(info) = same_depth_marker_info.get(i) {
2238                            self.emit_or_buffer_blockquote_marker(
2239                                info.leading_spaces,
2240                                info.has_trailing_space,
2241                            );
2242                        }
2243                    }
2244
2245                    // Add the new sibling LIST_ITEM to the inner LIST.
2246                    let list_item = ListItemEmissionInput {
2247                        content: inner_content,
2248                        marker_len: marker_match.marker_len,
2249                        spaces_after_cols: marker_match.spaces_after_cols,
2250                        spaces_after_bytes: marker_match.spaces_after_bytes,
2251                        indent_cols: sibling_base_indent_cols,
2252                        indent_bytes: inner_indent_bytes,
2253                        virtual_marker_space: marker_match.virtual_marker_space,
2254                    };
2255                    lists::add_list_item(
2256                        &mut self.containers,
2257                        &mut self.builder,
2258                        &list_item,
2259                        self.config,
2260                    );
2261                    self.maybe_open_fenced_code_in_new_list_item();
2262                    self.maybe_open_indented_code_in_new_list_item();
2263                    self.pos += 1;
2264                    return true;
2265                }
2266            }
2267
2268            // Check if we should close the ListItem
2269            // ListItem should continue if the line is properly indented for continuation
2270            if matches!(
2271                self.containers.last(),
2272                Some(Container::ListItem { content_col: _, .. })
2273            ) {
2274                let (indent_cols, _) = leading_indent(inner_content);
2275                let content_indent = self.content_container_indent_to_strip();
2276                let effective_indent = indent_cols.saturating_sub(content_indent);
2277                let content_col = match self.containers.last() {
2278                    Some(Container::ListItem { content_col, .. }) => *content_col,
2279                    _ => 0,
2280                };
2281
2282                // Check if this line starts a new list item at outer level
2283                let is_new_item_at_outer_level =
2284                    if try_parse_list_marker(inner_content, self.config).is_some() {
2285                        effective_indent < content_col
2286                    } else {
2287                        false
2288                    };
2289
2290                // Close ListItem if:
2291                // 1. It's a new list item at an outer (or same) level, OR
2292                // 2. The line is not indented enough to continue the current item
2293                if is_new_item_at_outer_level
2294                    || (effective_indent < content_col && !has_explicit_same_depth_marker)
2295                {
2296                    log::trace!(
2297                        "Closing ListItem: is_new_item={}, effective_indent={} < content_col={}",
2298                        is_new_item_at_outer_level,
2299                        effective_indent,
2300                        content_col
2301                    );
2302                    self.close_containers_to(self.containers.depth() - 1);
2303                } else {
2304                    log::trace!(
2305                        "Keeping ListItem: effective_indent={} >= content_col={}",
2306                        effective_indent,
2307                        content_col
2308                    );
2309                    list_item_continuation = true;
2310                }
2311            }
2312
2313            // Fenced code blocks inside list items need marker emission in this branch.
2314            // If we keep continuation buffering for these lines, opening fence markers in
2315            // blockquote contexts can be dropped from CST text.
2316            if list_item_continuation && code_blocks::try_parse_fence_open(inner_content).is_some()
2317            {
2318                list_item_continuation = false;
2319            }
2320
2321            let continuation_has_explicit_marker = list_item_continuation && {
2322                if has_explicit_same_depth_marker {
2323                    for i in 0..bq_depth {
2324                        if let Some(info) = same_depth_marker_info.get(i) {
2325                            self.emit_or_buffer_blockquote_marker(
2326                                info.leading_spaces,
2327                                info.has_trailing_space,
2328                            );
2329                        }
2330                    }
2331                    true
2332                } else {
2333                    false
2334                }
2335            };
2336
2337            if !list_item_continuation {
2338                let marker_info = self.marker_info_for_line(
2339                    blockquote_payload.as_ref(),
2340                    line,
2341                    bq_marker_line,
2342                    shifted_bq_prefix,
2343                    used_shifted_bq,
2344                );
2345                for i in 0..bq_depth {
2346                    if let Some(info) = marker_info.get(i) {
2347                        self.emit_or_buffer_blockquote_marker(
2348                            info.leading_spaces,
2349                            info.has_trailing_space,
2350                        );
2351                    }
2352                }
2353            }
2354            let line_to_append = if list_item_continuation {
2355                if continuation_has_explicit_marker {
2356                    Some(inner_content)
2357                } else {
2358                    Some(line)
2359                }
2360            } else {
2361                Some(inner_content)
2362            };
2363            return self.parse_inner_content(inner_content, line_to_append);
2364        }
2365
2366        // No blockquote markers - parse as regular content
2367        // But check for lazy continuation first
2368        if current_bq_depth > 0 {
2369            // Check for lazy paragraph continuation
2370            if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
2371                paragraphs::append_paragraph_line(
2372                    &mut self.containers,
2373                    &mut self.builder,
2374                    line,
2375                    self.config,
2376                );
2377                self.pos += 1;
2378                return true;
2379            }
2380
2381            // Check for lazy list continuation
2382            if lists::in_blockquote_list(&self.containers)
2383                && let Some(marker_match) = try_parse_list_marker(line, self.config)
2384            {
2385                let (indent_cols, indent_bytes) = leading_indent(line);
2386                if let Some(level) = lists::find_matching_list_level(
2387                    &self.containers,
2388                    &marker_match.marker,
2389                    indent_cols,
2390                    self.config.dialect,
2391                ) {
2392                    // Close containers to the target level, emitting buffers properly
2393                    self.close_containers_to(level + 1);
2394
2395                    // Close any open paragraph or list item at this level
2396                    if matches!(self.containers.last(), Some(Container::Paragraph { .. })) {
2397                        self.close_containers_to(self.containers.depth() - 1);
2398                    }
2399                    if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
2400                        self.close_containers_to(self.containers.depth() - 1);
2401                    }
2402
2403                    // Check if content is a nested bullet marker
2404                    if let Some(nested_marker) = is_content_nested_bullet_marker(
2405                        line,
2406                        marker_match.marker_len,
2407                        marker_match.spaces_after_bytes,
2408                    ) {
2409                        let list_item = ListItemEmissionInput {
2410                            content: line,
2411                            marker_len: marker_match.marker_len,
2412                            spaces_after_cols: marker_match.spaces_after_cols,
2413                            spaces_after_bytes: marker_match.spaces_after_bytes,
2414                            indent_cols,
2415                            indent_bytes,
2416                            virtual_marker_space: marker_match.virtual_marker_space,
2417                        };
2418                        lists::add_list_item_with_nested_empty_list(
2419                            &mut self.containers,
2420                            &mut self.builder,
2421                            &list_item,
2422                            nested_marker,
2423                        );
2424                    } else {
2425                        let list_item = ListItemEmissionInput {
2426                            content: line,
2427                            marker_len: marker_match.marker_len,
2428                            spaces_after_cols: marker_match.spaces_after_cols,
2429                            spaces_after_bytes: marker_match.spaces_after_bytes,
2430                            indent_cols,
2431                            indent_bytes,
2432                            virtual_marker_space: marker_match.virtual_marker_space,
2433                        };
2434                        lists::add_list_item(
2435                            &mut self.containers,
2436                            &mut self.builder,
2437                            &list_item,
2438                            self.config,
2439                        );
2440                    }
2441                    self.pos += 1;
2442                    return true;
2443                }
2444            }
2445        }
2446
2447        // No blockquote markers - use original line
2448        self.parse_inner_content(line, None)
2449    }
2450
2451    /// Get the total indentation to strip from content containers (footnotes + definitions).
2452    fn content_container_indent_to_strip(&self) -> usize {
2453        self.containers
2454            .stack
2455            .iter()
2456            .filter_map(|c| match c {
2457                Container::FootnoteDefinition { content_col, .. } => Some(*content_col),
2458                Container::Definition { content_col, .. } => Some(*content_col),
2459                _ => None,
2460            })
2461            .sum()
2462    }
2463
2464    /// Parse content inside blockquotes (or at top level).
2465    ///
2466    /// `content` - The content to parse (may have indent/markers stripped)
2467    /// `line_to_append` - Optional line to use when appending to paragraphs.
2468    ///                    If None, uses self.lines[self.pos]
2469    fn parse_inner_content(&mut self, content: &str, line_to_append: Option<&str>) -> bool {
2470        log::trace!(
2471            "parse_inner_content [{}]: depth={}, last={:?}, content={:?}",
2472            self.pos,
2473            self.containers.depth(),
2474            self.containers.last(),
2475            content.trim_end()
2476        );
2477        // Calculate how much indentation should be stripped for content containers
2478        // (definitions, footnotes) FIRST, so we can check for block markers correctly
2479        let content_indent = self.content_container_indent_to_strip();
2480        let (stripped_content, indent_to_emit) = if content_indent > 0 {
2481            let (indent_cols, _) = leading_indent(content);
2482            if indent_cols >= content_indent {
2483                let idx = byte_index_at_column(content, content_indent);
2484                (&content[idx..], Some(&content[..idx]))
2485            } else {
2486                // Line has less indent than required - preserve leading whitespace
2487                let trimmed_start = content.trim_start();
2488                let ws_len = content.len() - trimmed_start.len();
2489                if ws_len > 0 {
2490                    (trimmed_start, Some(&content[..ws_len]))
2491                } else {
2492                    (content, None)
2493                }
2494            }
2495        } else {
2496            (content, None)
2497        };
2498
2499        if self.config.extensions.alerts
2500            && self.current_blockquote_depth() > 0
2501            && !self.in_active_alert()
2502            && !self.is_paragraph_open()
2503            && let Some(marker) = Self::alert_marker_from_content(stripped_content)
2504        {
2505            let (_, newline_str) = strip_newline(stripped_content);
2506            self.builder.start_node(SyntaxKind::ALERT.into());
2507            self.builder.token(SyntaxKind::ALERT_MARKER.into(), marker);
2508            if !newline_str.is_empty() {
2509                self.builder.token(SyntaxKind::NEWLINE.into(), newline_str);
2510            }
2511            self.containers.push(Container::Alert {
2512                blockquote_depth: self.current_blockquote_depth(),
2513            });
2514            self.pos += 1;
2515            return true;
2516        }
2517
2518        // Check if we're in a Definition container (with or without an open PLAIN)
2519        // Continuation lines should be added to PLAIN, not treated as new blocks
2520        // BUT: Don't treat lines with block element markers as continuations
2521        if matches!(self.containers.last(), Some(Container::Definition { .. })) {
2522            let is_definition_marker =
2523                definition_lists::try_parse_definition_marker(stripped_content).is_some()
2524                    && !stripped_content.starts_with(':');
2525            if content_indent == 0 && is_definition_marker {
2526                // Definition markers at top-level should start a new definition.
2527            } else {
2528                let policy = ContinuationPolicy::new(self.config, &self.block_registry);
2529
2530                if policy.definition_plain_can_continue(
2531                    stripped_content,
2532                    content,
2533                    content_indent,
2534                    &BlockContext {
2535                        content: stripped_content,
2536                        has_blank_before: self.pos == 0 || is_blank_line(self.lines[self.pos - 1]),
2537                        has_blank_before_strict: self.pos == 0
2538                            || is_blank_line(self.lines[self.pos - 1]),
2539                        at_document_start: self.pos == 0 && self.current_blockquote_depth() == 0,
2540                        in_fenced_div: self.in_fenced_div(),
2541                        blockquote_depth: self.current_blockquote_depth(),
2542                        config: self.config,
2543                        content_indent,
2544                        indent_to_emit: None,
2545                        list_indent_info: None,
2546                        in_list: lists::in_list(&self.containers),
2547                        in_marker_only_list_item: matches!(
2548                            self.containers.last(),
2549                            Some(Container::ListItem {
2550                                marker_only: true,
2551                                ..
2552                            })
2553                        ),
2554                        paragraph_open: self.is_paragraph_open(),
2555                        next_line: if self.pos + 1 < self.lines.len() {
2556                            Some(self.lines[self.pos + 1])
2557                        } else {
2558                            None
2559                        },
2560                    },
2561                    &self.lines,
2562                    self.pos,
2563                ) {
2564                    let content_line = stripped_content;
2565                    let (text_without_newline, newline_str) = strip_newline(content_line);
2566                    let indent_prefix = if !text_without_newline.trim().is_empty() {
2567                        indent_to_emit.unwrap_or("")
2568                    } else {
2569                        ""
2570                    };
2571                    let content_line = format!("{}{}", indent_prefix, text_without_newline);
2572
2573                    if let Some(Container::Definition {
2574                        plain_open,
2575                        plain_buffer,
2576                        ..
2577                    }) = self.containers.stack.last_mut()
2578                    {
2579                        let line_with_newline = if !newline_str.is_empty() {
2580                            format!("{}{}", content_line, newline_str)
2581                        } else {
2582                            content_line
2583                        };
2584                        plain_buffer.push_line(line_with_newline);
2585                        *plain_open = true;
2586                    }
2587
2588                    self.pos += 1;
2589                    return true;
2590                }
2591            }
2592        }
2593
2594        // Handle blockquotes that appear after stripping content-container indentation
2595        // (e.g. `    > quote` inside a definition list item).
2596        if content_indent > 0 {
2597            let (bq_depth, inner_content) = count_blockquote_markers(stripped_content);
2598            let current_bq_depth = self.current_blockquote_depth();
2599            let in_footnote_definition = self
2600                .containers
2601                .stack
2602                .iter()
2603                .any(|container| matches!(container, Container::FootnoteDefinition { .. }));
2604
2605            if bq_depth > 0 {
2606                if in_footnote_definition
2607                    && self.config.extensions.blank_before_blockquote
2608                    && current_bq_depth == 0
2609                    && !blockquotes::can_start_blockquote(self.pos, &self.lines)
2610                {
2611                    // Respect blank_before_blockquote even when `>` appears only
2612                    // after stripping content-container indentation (e.g. footnotes).
2613                    // In that case the marker should be treated as paragraph text.
2614                } else {
2615                    // If definition/list plain text is buffered, flush it before opening nested
2616                    // blockquotes so block order remains lossless and stable across reparse.
2617                    self.emit_buffered_plain_if_needed();
2618                    self.emit_list_item_buffer_if_needed();
2619
2620                    // Blockquotes can nest inside content containers; preserve the stripped indentation
2621                    // as WHITESPACE before the first marker for losslessness.
2622                    self.close_paragraph_if_open();
2623
2624                    if bq_depth > current_bq_depth {
2625                        let marker_info = parse_blockquote_marker_info(stripped_content);
2626
2627                        // Open new blockquotes and emit their markers.
2628                        for level in current_bq_depth..bq_depth {
2629                            self.builder.start_node(SyntaxKind::BLOCK_QUOTE.into());
2630
2631                            if level == current_bq_depth
2632                                && let Some(indent_str) = indent_to_emit
2633                            {
2634                                self.builder
2635                                    .token(SyntaxKind::WHITESPACE.into(), indent_str);
2636                            }
2637
2638                            if let Some(info) = marker_info.get(level) {
2639                                blockquotes::emit_one_blockquote_marker(
2640                                    &mut self.builder,
2641                                    info.leading_spaces,
2642                                    info.has_trailing_space,
2643                                );
2644                            }
2645
2646                            self.containers.push(Container::BlockQuote {});
2647                        }
2648                    } else if bq_depth < current_bq_depth {
2649                        self.close_blockquotes_to_depth(bq_depth);
2650                    } else {
2651                        // Same depth: emit markers for losslessness.
2652                        let marker_info = parse_blockquote_marker_info(stripped_content);
2653                        self.emit_blockquote_markers(&marker_info, bq_depth);
2654                    }
2655
2656                    return self.parse_inner_content(inner_content, Some(inner_content));
2657                }
2658            }
2659        }
2660
2661        // Store the stripped content for later use
2662        let content = stripped_content;
2663
2664        if self.is_paragraph_open()
2665            && (paragraphs::has_open_inline_math_environment(&self.containers)
2666                || paragraphs::has_open_display_math_dollars(&self.containers))
2667        {
2668            paragraphs::append_paragraph_line(
2669                &mut self.containers,
2670                &mut self.builder,
2671                line_to_append.unwrap_or(self.lines[self.pos]),
2672                self.config,
2673            );
2674            self.pos += 1;
2675            return true;
2676        }
2677
2678        // Precompute dispatcher match once per line (reused by multiple branches below).
2679        // This covers: blocks requiring blank lines, blocks that can interrupt paragraphs,
2680        // and blocks that can appear without blank lines (e.g. reference definitions).
2681        use super::blocks::lists;
2682        use super::blocks::paragraphs;
2683        let list_indent_info = if lists::in_list(&self.containers) {
2684            let content_col = paragraphs::current_content_col(&self.containers);
2685            if content_col > 0 {
2686                Some(super::block_dispatcher::ListIndentInfo { content_col })
2687            } else {
2688                None
2689            }
2690        } else {
2691            None
2692        };
2693
2694        let next_line = if self.pos + 1 < self.lines.len() {
2695            // For lookahead-based blocks (e.g. setext headings), the dispatcher expects
2696            // `ctx.next_line` to be in the same “inner content” form as `ctx.content`.
2697            Some(count_blockquote_markers(self.lines[self.pos + 1]).1)
2698        } else {
2699            None
2700        };
2701
2702        let current_bq_depth = self.current_blockquote_depth();
2703        if let Some(alert_bq_depth) = self.active_alert_blockquote_depth()
2704            && current_bq_depth < alert_bq_depth
2705        {
2706            while matches!(self.containers.last(), Some(Container::Alert { .. })) {
2707                self.close_containers_to(self.containers.depth() - 1);
2708            }
2709        }
2710
2711        let dispatcher_ctx = BlockContext {
2712            content,
2713            has_blank_before: false,        // filled in later
2714            has_blank_before_strict: false, // filled in later
2715            at_document_start: false,       // filled in later
2716            in_fenced_div: self.in_fenced_div(),
2717            blockquote_depth: current_bq_depth,
2718            config: self.config,
2719            content_indent,
2720            indent_to_emit,
2721            list_indent_info,
2722            in_list: lists::in_list(&self.containers),
2723            in_marker_only_list_item: matches!(
2724                self.containers.last(),
2725                Some(Container::ListItem {
2726                    marker_only: true,
2727                    ..
2728                })
2729            ),
2730            paragraph_open: self.is_paragraph_open(),
2731            next_line,
2732        };
2733
2734        // We'll update these two fields shortly (after they are computed), but we can still
2735        // use this ctx shape to avoid rebuilding repeated context objects.
2736        let mut dispatcher_ctx = dispatcher_ctx;
2737
2738        // Setext heading folded over a list item's buffered first-line text.
2739        // Must run before block detection so that an HR-shaped underline like
2740        // `---` doesn't get claimed by the thematic-break parser.
2741        if self.try_fold_list_item_buffer_into_setext(stripped_content) {
2742            return true;
2743        }
2744
2745        // Initial detection (before blank/doc-start are computed). Note: this can
2746        // match reference definitions, but footnotes are handled explicitly later.
2747        let dispatcher_match =
2748            self.block_registry
2749                .detect_prepared(&dispatcher_ctx, &self.lines, self.pos);
2750
2751        // Check for heading (needs blank line before, or at start of container)
2752        // Note: for fenced div nesting, the line immediately after a div opening fence
2753        // should be treated like the start of a container (Pandoc allows nested fences
2754        // without an intervening blank line). Similarly, the first line after a metadata
2755        // block (YAML/Pandoc title/MMD title) is treated as having a blank before it.
2756        let after_metadata_block = std::mem::replace(&mut self.after_metadata_block, false);
2757        let has_blank_before = if self.pos == 0 || after_metadata_block {
2758            true
2759        } else {
2760            let prev_line = self.lines[self.pos - 1];
2761            let (prev_bq_depth, prev_inner) = count_blockquote_markers(prev_line);
2762            let (prev_inner_no_nl, _) = strip_newline(prev_inner);
2763            let prev_is_fenced_div_open = self.config.extensions.fenced_divs
2764                && fenced_divs::try_parse_div_fence_open(
2765                    strip_n_blockquote_markers(prev_inner_no_nl, prev_bq_depth).trim_start(),
2766                )
2767                .is_some();
2768
2769            let prev_line_blank = is_blank_line(prev_line);
2770            prev_line_blank
2771                || prev_is_fenced_div_open
2772                || matches!(self.containers.last(), Some(Container::BlockQuote { .. }))
2773                || !self.previous_block_requires_blank_before_heading()
2774        };
2775
2776        // For indented code blocks, we need a stricter condition - only actual blank lines count
2777        // Being at document start (pos == 0) is OK only if we're not inside a blockquote
2778        let at_document_start = self.pos == 0 && current_bq_depth == 0;
2779
2780        let prev_line_blank = if self.pos > 0 {
2781            let prev_line = self.lines[self.pos - 1];
2782            let (prev_bq_depth, prev_inner) = count_blockquote_markers(prev_line);
2783            is_blank_line(prev_line) || (prev_bq_depth > 0 && is_blank_line(prev_inner))
2784        } else {
2785            false
2786        };
2787        let has_blank_before_strict = at_document_start || prev_line_blank;
2788
2789        dispatcher_ctx.has_blank_before = has_blank_before;
2790        dispatcher_ctx.has_blank_before_strict = has_blank_before_strict;
2791        dispatcher_ctx.at_document_start = at_document_start;
2792
2793        let dispatcher_match =
2794            if dispatcher_ctx.has_blank_before || dispatcher_ctx.at_document_start {
2795                // Recompute now that blank/doc-start conditions are known.
2796                self.block_registry
2797                    .detect_prepared(&dispatcher_ctx, &self.lines, self.pos)
2798            } else {
2799                dispatcher_match
2800            };
2801
2802        if has_blank_before {
2803            if let Some(env_name) = extract_environment_name(content)
2804                && is_inline_math_environment(env_name)
2805            {
2806                if !self.is_paragraph_open() {
2807                    paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
2808                }
2809                paragraphs::append_paragraph_line(
2810                    &mut self.containers,
2811                    &mut self.builder,
2812                    line_to_append.unwrap_or(self.lines[self.pos]),
2813                    self.config,
2814                );
2815                self.pos += 1;
2816                return true;
2817            }
2818
2819            if let Some(block_match) = dispatcher_match.as_ref() {
2820                let detection = block_match.detection;
2821
2822                match detection {
2823                    BlockDetectionResult::YesCanInterrupt => {
2824                        self.emit_list_item_buffer_if_needed();
2825                        if self.is_paragraph_open() {
2826                            self.close_containers_to(self.containers.depth() - 1);
2827                        }
2828                    }
2829                    BlockDetectionResult::Yes => {
2830                        self.prepare_for_block_element();
2831                    }
2832                    BlockDetectionResult::No => unreachable!(),
2833                }
2834
2835                if matches!(block_match.effect, BlockEffect::CloseFencedDiv) {
2836                    self.close_containers_to_fenced_div();
2837                }
2838
2839                if matches!(block_match.effect, BlockEffect::OpenFootnoteDefinition) {
2840                    self.close_open_footnote_definition();
2841                }
2842
2843                let lines_consumed = self.block_registry.parse_prepared(
2844                    block_match,
2845                    &dispatcher_ctx,
2846                    &mut self.builder,
2847                    &self.lines,
2848                    self.pos,
2849                );
2850
2851                if matches!(
2852                    self.block_registry.parser_name(block_match),
2853                    "yaml_metadata" | "pandoc_title_block" | "mmd_title_block"
2854                ) {
2855                    self.after_metadata_block = true;
2856                }
2857
2858                match block_match.effect {
2859                    BlockEffect::None => {}
2860                    BlockEffect::OpenFencedDiv => {
2861                        self.containers.push(Container::FencedDiv {});
2862                    }
2863                    BlockEffect::CloseFencedDiv => {
2864                        self.close_fenced_div();
2865                    }
2866                    BlockEffect::OpenFootnoteDefinition => {
2867                        self.handle_footnote_open_effect(block_match, content);
2868                    }
2869                    BlockEffect::OpenList => {
2870                        self.handle_list_open_effect(block_match, content, indent_to_emit);
2871                    }
2872                    BlockEffect::OpenDefinitionList => {
2873                        self.handle_definition_list_effect(block_match, content, indent_to_emit);
2874                    }
2875                    BlockEffect::OpenBlockQuote => {
2876                        // Detection only for now; keep core blockquote handling intact.
2877                    }
2878                }
2879
2880                if lines_consumed == 0 {
2881                    log::warn!(
2882                        "block parser made no progress at line {} (parser={})",
2883                        self.pos + 1,
2884                        self.block_registry.parser_name(block_match)
2885                    );
2886                    return false;
2887                }
2888
2889                self.pos += lines_consumed;
2890                return true;
2891            }
2892        } else if let Some(block_match) = dispatcher_match.as_ref() {
2893            // Without blank-before, only allow interrupting blocks OR blocks that are
2894            // explicitly allowed without blank lines (e.g. reference definitions).
2895            let parser_name = self.block_registry.parser_name(block_match);
2896            match block_match.detection {
2897                BlockDetectionResult::YesCanInterrupt => {
2898                    if matches!(block_match.effect, BlockEffect::OpenFencedDiv)
2899                        && self.is_paragraph_open()
2900                    {
2901                        // Fenced divs must not interrupt paragraphs without a blank line.
2902                        if !self.is_paragraph_open() {
2903                            paragraphs::start_paragraph_if_needed(
2904                                &mut self.containers,
2905                                &mut self.builder,
2906                            );
2907                        }
2908                        paragraphs::append_paragraph_line(
2909                            &mut self.containers,
2910                            &mut self.builder,
2911                            line_to_append.unwrap_or(self.lines[self.pos]),
2912                            self.config,
2913                        );
2914                        self.pos += 1;
2915                        return true;
2916                    }
2917
2918                    if matches!(block_match.effect, BlockEffect::OpenList)
2919                        && self.is_paragraph_open()
2920                        && !lists::in_list(&self.containers)
2921                        && self.content_container_indent_to_strip() == 0
2922                    {
2923                        // CommonMark §5.2: bullet lists and ordered lists with
2924                        // start = 1 may interrupt a paragraph; ordered lists
2925                        // with any other start cannot. Pandoc-markdown forbids
2926                        // *any* list from interrupting a paragraph without a
2927                        // blank line.
2928                        let allow_interrupt =
2929                            self.config.dialect == crate::options::Dialect::CommonMark && {
2930                                use super::block_dispatcher::ListPrepared;
2931                                use super::blocks::lists::OrderedMarker;
2932                                let prepared = block_match
2933                                    .payload
2934                                    .as_ref()
2935                                    .and_then(|p| p.downcast_ref::<ListPrepared>());
2936                                match prepared.map(|p| &p.marker) {
2937                                    Some(ListMarker::Bullet(_)) => true,
2938                                    Some(ListMarker::Ordered(OrderedMarker::Decimal {
2939                                        number,
2940                                        ..
2941                                    })) => number == "1",
2942                                    _ => false,
2943                                }
2944                            };
2945                        if !allow_interrupt {
2946                            paragraphs::append_paragraph_line(
2947                                &mut self.containers,
2948                                &mut self.builder,
2949                                line_to_append.unwrap_or(self.lines[self.pos]),
2950                                self.config,
2951                            );
2952                            self.pos += 1;
2953                            return true;
2954                        }
2955                    }
2956
2957                    // CommonMark spec example #312: a "list marker" at indent
2958                    // ≥ 4 isn't actually a marker when it can't reach the
2959                    // deepest item's content column AND no list level matches
2960                    // at that indent. Treat as lazy paragraph continuation of
2961                    // the deepest open list item or paragraph rather than
2962                    // flushing the buffer and opening a new sibling list.
2963                    if matches!(block_match.effect, BlockEffect::OpenList)
2964                        && self.try_lazy_list_continuation(block_match, content)
2965                    {
2966                        self.pos += 1;
2967                        return true;
2968                    }
2969
2970                    self.emit_list_item_buffer_if_needed();
2971                    if self.is_paragraph_open() {
2972                        if self.html_block_demotes_paragraph_to_plain(block_match) {
2973                            self.close_paragraph_as_plain_if_open();
2974                        } else {
2975                            self.close_containers_to(self.containers.depth() - 1);
2976                        }
2977                    }
2978
2979                    // CommonMark §5.2: a thematic break / ATX heading /
2980                    // fenced code at column 0 cannot continue an open list
2981                    // item whose content column is greater than the line's
2982                    // indent — close the surrounding list before emitting.
2983                    // OpenList is excluded so that a same-level marker still
2984                    // continues the list rather than closing it.
2985                    if self.config.dialect == crate::options::Dialect::CommonMark
2986                        && !matches!(block_match.effect, BlockEffect::OpenList)
2987                    {
2988                        let (indent_cols, _) = leading_indent(content);
2989                        self.close_lists_above_indent(indent_cols);
2990                    }
2991                }
2992                BlockDetectionResult::Yes => {
2993                    // CommonMark multi-line setext: when an open paragraph is
2994                    // followed by a setext underline, the entire paragraph
2995                    // becomes the heading content. The dispatcher reports
2996                    // setext at the line *before* the underline (the last text
2997                    // line); fold the buffered paragraph + this line into a
2998                    // single HEADING. Pandoc-markdown disagrees (it never
2999                    // forms a multi-line setext), so this branch is dialect-
3000                    // gated; under Pandoc, a setext detection while a
3001                    // paragraph is open never reaches this point because
3002                    // `blank_before_header` is on by default and gates out the
3003                    // detection earlier in `SetextHeadingParser::detect_prepared`.
3004                    if parser_name == "setext_heading"
3005                        && self.is_paragraph_open()
3006                        && self.config.dialect == crate::options::Dialect::CommonMark
3007                    {
3008                        let text_line = self.lines[self.pos];
3009                        let underline_line = self.lines[self.pos + 1];
3010                        let underline_char = underline_line.trim().chars().next().unwrap_or('=');
3011                        let level = if underline_char == '=' { 1 } else { 2 };
3012                        self.emit_setext_heading_folding_paragraph(
3013                            text_line,
3014                            underline_line,
3015                            level,
3016                        );
3017                        self.pos += 2;
3018                        return true;
3019                    }
3020
3021                    // Keep ambiguous fenced-div openers from interrupting an
3022                    // active paragraph without a blank line.
3023                    if parser_name == "fenced_div_open" && self.is_paragraph_open() {
3024                        if !self.is_paragraph_open() {
3025                            paragraphs::start_paragraph_if_needed(
3026                                &mut self.containers,
3027                                &mut self.builder,
3028                            );
3029                        }
3030                        paragraphs::append_paragraph_line(
3031                            &mut self.containers,
3032                            &mut self.builder,
3033                            line_to_append.unwrap_or(self.lines[self.pos]),
3034                            self.config,
3035                        );
3036                        self.pos += 1;
3037                        return true;
3038                    }
3039
3040                    // Reference definitions cannot interrupt a paragraph
3041                    // (CommonMark §4.7 / Pandoc-markdown agree).
3042                    if parser_name == "reference_definition" && self.is_paragraph_open() {
3043                        paragraphs::append_paragraph_line(
3044                            &mut self.containers,
3045                            &mut self.builder,
3046                            line_to_append.unwrap_or(self.lines[self.pos]),
3047                            self.config,
3048                        );
3049                        self.pos += 1;
3050                        return true;
3051                    }
3052                }
3053                BlockDetectionResult::No => unreachable!(),
3054            }
3055
3056            if !matches!(block_match.detection, BlockDetectionResult::No) {
3057                if matches!(block_match.effect, BlockEffect::CloseFencedDiv) {
3058                    self.close_containers_to_fenced_div();
3059                }
3060
3061                if matches!(block_match.effect, BlockEffect::OpenFootnoteDefinition) {
3062                    self.close_open_footnote_definition();
3063                }
3064
3065                let lines_consumed = self.block_registry.parse_prepared(
3066                    block_match,
3067                    &dispatcher_ctx,
3068                    &mut self.builder,
3069                    &self.lines,
3070                    self.pos,
3071                );
3072
3073                match block_match.effect {
3074                    BlockEffect::None => {}
3075                    BlockEffect::OpenFencedDiv => {
3076                        self.containers.push(Container::FencedDiv {});
3077                    }
3078                    BlockEffect::CloseFencedDiv => {
3079                        self.close_fenced_div();
3080                    }
3081                    BlockEffect::OpenFootnoteDefinition => {
3082                        self.handle_footnote_open_effect(block_match, content);
3083                    }
3084                    BlockEffect::OpenList => {
3085                        self.handle_list_open_effect(block_match, content, indent_to_emit);
3086                    }
3087                    BlockEffect::OpenDefinitionList => {
3088                        self.handle_definition_list_effect(block_match, content, indent_to_emit);
3089                    }
3090                    BlockEffect::OpenBlockQuote => {
3091                        // Detection only for now; keep core blockquote handling intact.
3092                    }
3093                }
3094
3095                if lines_consumed == 0 {
3096                    log::warn!(
3097                        "block parser made no progress at line {} (parser={})",
3098                        self.pos + 1,
3099                        self.block_registry.parser_name(block_match)
3100                    );
3101                    return false;
3102                }
3103
3104                self.pos += lines_consumed;
3105                return true;
3106            }
3107        }
3108
3109        // Check for line block (if line_blocks extension is enabled)
3110        if self.config.extensions.line_blocks
3111            && (has_blank_before || self.pos == 0)
3112            && try_parse_line_block_start(content).is_some()
3113            // Guard against context-stripped content (e.g. inside blockquotes) that
3114            // looks like a line block while the raw source line does not. Calling
3115            // parse_line_block on raw lines in that state would consume 0 lines.
3116            && try_parse_line_block_start(self.lines[self.pos]).is_some()
3117        {
3118            log::trace!("Parsed line block at line {}", self.pos);
3119            // Close paragraph before opening line block
3120            self.close_paragraph_if_open();
3121
3122            let new_pos = parse_line_block(&self.lines, self.pos, &mut self.builder, self.config);
3123            if new_pos > self.pos {
3124                self.pos = new_pos;
3125                return true;
3126            }
3127        }
3128
3129        // Paragraph or list item continuation
3130        // Check if we're inside a ListItem - if so, buffer the content instead of emitting
3131        if matches!(self.containers.last(), Some(Container::ListItem { .. })) {
3132            log::trace!(
3133                "Inside ListItem - buffering content: {:?}",
3134                line_to_append.unwrap_or(self.lines[self.pos]).trim_end()
3135            );
3136            // Inside list item - buffer content for later parsing
3137            let line = line_to_append.unwrap_or(self.lines[self.pos]);
3138
3139            // Add line to buffer in the ListItem container
3140            if let Some(Container::ListItem {
3141                buffer,
3142                marker_only,
3143                ..
3144            }) = self.containers.stack.last_mut()
3145            {
3146                buffer.push_text(line);
3147                if !is_blank_line(line) {
3148                    *marker_only = false;
3149                }
3150            }
3151
3152            self.pos += 1;
3153            return true;
3154        }
3155
3156        log::trace!(
3157            "Not in ListItem - creating paragraph for: {:?}",
3158            line_to_append.unwrap_or(self.lines[self.pos]).trim_end()
3159        );
3160        // Not in list item - create paragraph as usual
3161        paragraphs::start_paragraph_if_needed(&mut self.containers, &mut self.builder);
3162        // For lossless parsing: use line_to_append if provided (e.g., for blockquotes
3163        // where markers have been stripped), otherwise use the original line
3164        let line = line_to_append.unwrap_or(self.lines[self.pos]);
3165        paragraphs::append_paragraph_line(
3166            &mut self.containers,
3167            &mut self.builder,
3168            line,
3169            self.config,
3170        );
3171        self.pos += 1;
3172        true
3173    }
3174
3175    fn fenced_div_container_index(&self) -> Option<usize> {
3176        self.containers
3177            .stack
3178            .iter()
3179            .rposition(|c| matches!(c, Container::FencedDiv { .. }))
3180    }
3181
3182    fn close_containers_to_fenced_div(&mut self) {
3183        if let Some(index) = self.fenced_div_container_index() {
3184            self.close_containers_to(index + 1);
3185        }
3186    }
3187
3188    fn close_fenced_div(&mut self) {
3189        if let Some(index) = self.fenced_div_container_index() {
3190            self.close_containers_to(index);
3191        }
3192    }
3193
3194    fn in_fenced_div(&self) -> bool {
3195        self.containers
3196            .stack
3197            .iter()
3198            .any(|c| matches!(c, Container::FencedDiv { .. }))
3199    }
3200}
3201
3202/// Emit buffered Definition content as either Heading-then-Plain (when the
3203/// first line is an ATX heading) or as a single Plain block.
3204///
3205/// Pandoc parses `Term\n: # Heading\n  Some text` as DefinitionList where the
3206/// definition contains [Header, Plain]; the `# Heading` line is a real Header
3207/// inside the definition, not text that happens to start with `#`.
3208fn emit_definition_plain_or_heading(
3209    builder: &mut GreenNodeBuilder<'static>,
3210    text: &str,
3211    config: &ParserOptions,
3212) {
3213    let line_without_newline = text
3214        .strip_suffix("\r\n")
3215        .or_else(|| text.strip_suffix('\n'));
3216    if let Some(line) = line_without_newline
3217        && !line.contains('\n')
3218        && !line.contains('\r')
3219        && let Some(level) = try_parse_atx_heading(line)
3220    {
3221        emit_atx_heading(builder, text, level, config);
3222        return;
3223    }
3224
3225    // Multi-line: first line is heading, rest is plain continuation.
3226    if let Some(first_nl) = text.find('\n') {
3227        let first_line = &text[..first_nl];
3228        let after_first = &text[first_nl + 1..];
3229        if !after_first.is_empty()
3230            && let Some(level) = try_parse_atx_heading(first_line)
3231        {
3232            let heading_bytes = &text[..first_nl + 1];
3233            emit_atx_heading(builder, heading_bytes, level, config);
3234            builder.start_node(SyntaxKind::PLAIN.into());
3235            inline_emission::emit_inlines(builder, after_first, config);
3236            builder.finish_node();
3237            return;
3238        }
3239    }
3240
3241    builder.start_node(SyntaxKind::PLAIN.into());
3242    inline_emission::emit_inlines(builder, text, config);
3243    builder.finish_node();
3244}
3245
3246/// Look ahead from `pos+1` past blank lines for a definition marker line at
3247/// `content_col` indent. Returns the blank-line count consumed before the
3248/// marker, or `None` if no marker is found at the next non-blank line.
3249///
3250/// Used by `handle_footnote_open_effect` to decide whether the first content
3251/// line of a footnote body should open a definition-list term: pandoc treats
3252/// `[^1]: Term\n\n    :   Definition\n` as a `Note [DefinitionList ...]`,
3253/// not as a paragraph followed by a separate def list with no term.
3254fn footnote_first_line_term_lookahead(
3255    lines: &[&str],
3256    pos: usize,
3257    content_col: usize,
3258    table_captions_enabled: bool,
3259) -> Option<usize> {
3260    let mut check_pos = pos + 1;
3261    let mut blank_count = 0;
3262    while check_pos < lines.len() {
3263        let line = lines[check_pos];
3264        let (trimmed, _) = strip_newline(line);
3265        if trimmed.trim().is_empty() {
3266            blank_count += 1;
3267            check_pos += 1;
3268            continue;
3269        }
3270        let (line_indent_cols, _) = leading_indent(trimmed);
3271        if line_indent_cols < content_col {
3272            return None;
3273        }
3274        let strip_bytes = byte_index_at_column(trimmed, content_col);
3275        if strip_bytes > trimmed.len() {
3276            return None;
3277        }
3278        let stripped = &trimmed[strip_bytes..];
3279        if let Some((marker, ..)) = definition_lists::try_parse_definition_marker(stripped) {
3280            // A `:` line that is actually a table caption shouldn't open a
3281            // definition list. Mirror the gate from
3282            // `next_line_is_definition_marker`.
3283            if marker == ':'
3284                && table_captions_enabled
3285                && super::blocks::tables::is_caption_followed_by_table(lines, check_pos)
3286            {
3287                return None;
3288            }
3289            return Some(blank_count);
3290        }
3291        return None;
3292    }
3293    None
3294}