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