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