Skip to main content

panache_parser/parser/
core.rs

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