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