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