Skip to main content

panache_parser/parser/
core.rs

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