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