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