Skip to main content

panache_parser/parser/utils/
list_item_buffer.rs

1//! Buffer for accumulating list item content before emission.
2//!
3//! This module provides infrastructure for buffering list item content during parsing,
4//! allowing us to determine tight vs loose lists and parse inline elements correctly.
5
6use crate::options::{Dialect, ParserOptions};
7use crate::parser::blocks::container_prefix::{
8    ContainerPrefixLine, ContainerPrefixState, emit_container_prefix_tokens,
9};
10use crate::parser::blocks::headings::{emit_atx_heading, try_parse_atx_heading};
11use crate::parser::blocks::horizontal_rules::{emit_horizontal_rule, try_parse_horizontal_rule};
12use crate::parser::blocks::html_blocks::{
13    HtmlBlockType, count_tag_balance, is_pandoc_matched_pair_tag, try_parse_html_block_start,
14};
15use crate::parser::blocks::paragraphs::update_display_math_state;
16use crate::parser::utils::container_stack::OpenDisplayMath;
17use crate::parser::utils::helpers::trim_end_newlines;
18use crate::parser::utils::inline_emission;
19use crate::parser::utils::text_buffer::ParagraphBuffer;
20use crate::syntax::{SyntaxKind, SyntaxNode};
21use rowan::{GreenNodeBuilder, TextSize};
22
23/// A segment in the list item buffer - either text content or a blank line.
24#[derive(Debug, Clone)]
25pub(crate) enum ListItemContent {
26    /// Text content (includes newlines for losslessness)
27    Text(String),
28    /// Structural blockquote marker emitted inside buffered list-item text.
29    BlockquoteMarker {
30        leading_spaces: usize,
31        has_trailing_space: bool,
32    },
33}
34
35/// Buffer for accumulating list item content before emission.
36///
37/// Collects text, blank lines, and structural elements as we parse list item
38/// continuation lines. When the list item closes, we can:
39/// 1. Determine if it's tight (Plain) or loose (PARAGRAPH)
40/// 2. Parse inline elements correctly across continuation lines
41/// 3. Emit the complete structure
42#[derive(Debug, Default, Clone)]
43pub(crate) struct ListItemBuffer {
44    /// Segments of content in order
45    segments: Vec<ListItemContent>,
46    /// Display-math region (`$$ ... $$`, `\[ ... \]`, `\\[ ... \\]`) left
47    /// open by the buffered lines. Consulted by the parser's list-item hold
48    /// so block detection cannot split an open region into a `TEX_BLOCK`
49    /// (same failure mode the paragraph tracker fixes at top level).
50    open_display_math: Option<OpenDisplayMath>,
51}
52
53impl ListItemBuffer {
54    /// Create a new empty list item buffer.
55    pub(crate) fn new() -> Self {
56        Self {
57            segments: Vec::new(),
58            open_display_math: None,
59        }
60    }
61
62    /// Push text content to the buffer, tracking display-math delimiters
63    /// per line (the marker-line seed can carry multiple lines).
64    pub(crate) fn push_text(&mut self, text: impl Into<String>, config: &ParserOptions) {
65        let text = text.into();
66        if text.is_empty() {
67            return;
68        }
69        for line in text.split_inclusive('\n') {
70            update_display_math_state(trim_end_newlines(line), &mut self.open_display_math, config);
71        }
72        self.segments.push(ListItemContent::Text(text));
73    }
74
75    /// Whether the buffered lines left a display-math region open.
76    pub(crate) fn has_open_display_math(&self) -> bool {
77        self.open_display_math.is_some()
78    }
79
80    pub(crate) fn push_blockquote_marker(
81        &mut self,
82        leading_spaces: usize,
83        has_trailing_space: bool,
84    ) {
85        self.segments.push(ListItemContent::BlockquoteMarker {
86            leading_spaces,
87            has_trailing_space,
88        });
89    }
90
91    /// Check if buffer is empty.
92    pub(crate) fn is_empty(&self) -> bool {
93        self.segments.is_empty()
94    }
95
96    /// Get the number of segments in the buffer (for debugging).
97    pub(crate) fn segment_count(&self) -> usize {
98        self.segments.len()
99    }
100
101    /// Return the text of the first segment, if it is a `Text` segment.
102    pub(crate) fn first_text(&self) -> Option<&str> {
103        match self.segments.first()? {
104            ListItemContent::Text(t) => Some(t.as_str()),
105            ListItemContent::BlockquoteMarker { .. } => None,
106        }
107    }
108
109    /// If the buffered text begins with a Pandoc matched-pair HTML open
110    /// tag (e.g. `<div ...>`, `<section>`, `<pre>`, `<video>`) whose
111    /// opens outnumber its closes in the buffered text, return the tag
112    /// name. Used by the block dispatcher to suppress the close-form
113    /// dispatch that would otherwise interrupt the LIST_ITEM buffer at
114    /// `</div>` / `</pre>` / etc. — letting the buffer accumulate the
115    /// full matched-pair text so the emit-time structural lift sees both
116    /// open and close.
117    ///
118    /// Only fires under Pandoc dialect. Under CommonMark, list items
119    /// keep their existing behavior (inline HTML inside Plain).
120    pub(crate) fn unclosed_pandoc_matched_pair_tag(
121        &self,
122        config: &ParserOptions,
123    ) -> Option<String> {
124        if config.dialect != Dialect::Pandoc {
125            return None;
126        }
127        let first = self.first_text()?;
128        let first_line_with_nl = first.split_inclusive('\n').next()?;
129        let first_line_no_nl = first_line_with_nl
130            .strip_suffix("\r\n")
131            .or_else(|| first_line_with_nl.strip_suffix('\n'))
132            .unwrap_or(first_line_with_nl);
133        let HtmlBlockType::BlockTag {
134            tag_name,
135            is_closing: false,
136            ..
137        } = try_parse_html_block_start(first_line_no_nl, false)?
138        else {
139            return None;
140        };
141        if !is_pandoc_matched_pair_tag(&tag_name) {
142            return None;
143        }
144        let mut opens = 0usize;
145        let mut closes = 0usize;
146        for segment in &self.segments {
147            if let ListItemContent::Text(t) = segment {
148                let (o, c) = count_tag_balance(t, &tag_name);
149                opens += o;
150                closes += c;
151            }
152        }
153        if opens > closes { Some(tag_name) } else { None }
154    }
155
156    /// Determine if this list item has blank lines between content.
157    ///
158    /// Used to decide between Plain (tight) and PARAGRAPH (loose).
159    /// Returns true if there's a blank line followed by more content.
160    pub(crate) fn has_blank_lines_between_content(&self) -> bool {
161        log::trace!(
162            "has_blank_lines_between_content: segments={} result=false",
163            self.segments.len()
164        );
165
166        false
167    }
168
169    /// Get concatenated text for inline parsing (excludes blank lines).
170    fn get_text_for_parsing(&self) -> String {
171        let mut result = String::new();
172        for segment in &self.segments {
173            if let ListItemContent::Text(text) = segment {
174                result.push_str(text);
175            }
176        }
177        result
178    }
179
180    fn to_paragraph_buffer(&self) -> ParagraphBuffer {
181        let mut paragraph_buffer = ParagraphBuffer::new();
182        for segment in &self.segments {
183            match segment {
184                ListItemContent::Text(text) => paragraph_buffer.push_text(text),
185                ListItemContent::BlockquoteMarker {
186                    leading_spaces,
187                    has_trailing_space,
188                } => paragraph_buffer.push_marker(*leading_spaces, *has_trailing_space),
189            }
190        }
191        paragraph_buffer
192    }
193
194    /// Emit the buffered content as a Plain or PARAGRAPH block.
195    ///
196    /// If `use_paragraph` is true, wraps in PARAGRAPH (loose list).
197    /// If false, wraps in PLAIN (tight list).
198    ///
199    /// `content_col` is the enclosing list-item's content column (or 0
200    /// outside a list-item). The HTML-block first-line structural lift
201    /// uses it to strip the list-item leading indent from continuation
202    /// lines before reparsing the body, so `<div>` body parses as
203    /// pandoc's `Para` (matched-pair under stripped indent) instead of
204    /// `Plain` (the indented-close demotion), and so verbatim-tag
205    /// content (`<pre>`, `<style>`, etc.) projects without the leading
206    /// indent baked into the RawBlock text. The stripped bytes are
207    /// re-emitted as `WHITESPACE` tokens at line starts during graft
208    /// so the CST stays byte-equal to source.
209    pub(crate) fn emit_as_block(
210        &self,
211        builder: &mut GreenNodeBuilder<'static>,
212        use_paragraph: bool,
213        config: &ParserOptions,
214        content_col: usize,
215        suppress_footnote_refs: bool,
216    ) {
217        if self.is_empty() {
218            return;
219        }
220
221        // Get text and parse inline elements
222        let text = self.get_text_for_parsing();
223
224        if !text.is_empty() {
225            let line_without_newline = text
226                .strip_suffix("\r\n")
227                .or_else(|| text.strip_suffix('\n'));
228            if let Some(line) = line_without_newline
229                && !line.contains('\n')
230                && !line.contains('\r')
231            {
232                // Detect against the line with the item's leading indent
233                // stripped (a continuation chunk carries it on its first
234                // line): pandoc measures rule/heading indentation from the
235                // item's content column, so a depth-2 rule (4 leading
236                // columns) must not trip the CommonMark 4-space guard.
237                // Emission keeps the original bytes (lossless).
238                let detect_line = &line[item_indent_prefix_len(line, content_col)..];
239                if let Some(level) = try_parse_atx_heading(detect_line) {
240                    emit_atx_heading(builder, &text, level, config);
241                    return;
242                }
243                if try_parse_horizontal_rule(detect_line).is_some() {
244                    emit_horizontal_rule(builder, &text);
245                    return;
246                }
247            }
248
249            // Multi-line case: first line is an ATX heading, rest is plain
250            // continuation. Pandoc treats `- # Heading\n  Some text` as a
251            // list item containing Header + Plain, not a single Plain spanning
252            // both lines.
253            if self
254                .segments
255                .iter()
256                .all(|s| matches!(s, ListItemContent::Text(_)))
257                && let Some(first_nl) = text.find('\n')
258            {
259                let first_line = &text[..first_nl];
260                let after_first = &text[first_nl + 1..];
261                let detect_first = &first_line[item_indent_prefix_len(first_line, content_col)..];
262                if !after_first.is_empty()
263                    && let Some(level) = try_parse_atx_heading(detect_first)
264                {
265                    let heading_bytes = &text[..first_nl + 1];
266                    emit_atx_heading(builder, heading_bytes, level, config);
267
268                    let block_kind = if use_paragraph {
269                        SyntaxKind::PARAGRAPH
270                    } else {
271                        SyntaxKind::PLAIN
272                    };
273                    builder.start_node(block_kind.into());
274                    inline_emission::emit_inlines(
275                        builder,
276                        after_first,
277                        config,
278                        suppress_footnote_refs,
279                    );
280                    builder.finish_node();
281                    return;
282                }
283            }
284
285            // Pandoc HTML-block-first-line structural lift: when the buffered
286            // text begins with a matched HTML block (same-line `<div>...</div>`,
287            // single-line comment, `<pre>foo</pre>`, etc.) and the entire
288            // buffer is consumed by that block, reparse and graft the inner
289            // block as a direct LIST_ITEM child. Without this lift, the
290            // dispatcher's inline-HTML path takes over and emits
291            // `Plain[RawInline <tag>, body, RawInline </tag>]` instead of
292            // `Div [...]` or `RawBlock <tag>`.
293            //
294            // Multi-line cases where the close tag lives in a sibling
295            // HTML_BLOCK (because the dispatcher recognizes Pandoc strict-
296            // block close forms as block starts and breaks the buffer) are
297            // not handled here — the gate rejects HTML_BLOCK_DIV with only
298            // one HTML_BLOCK_TAG child. That sub-target stays open.
299            if config.dialect == Dialect::Pandoc
300                && self
301                    .segments
302                    .iter()
303                    .all(|s| matches!(s, ListItemContent::Text(_)))
304                && try_emit_html_block_lift(builder, &text, config, content_col, use_paragraph, "")
305            {
306                return;
307            }
308
309            // Structural block lift for marker-line tables and fenced divs.
310            // Pandoc recognizes `- | a | b |\n  | - | - |` and `- ::: note\n
311            // ...\n  :::` as nested Table / Div; without lifting, the buffer
312            // would emit them as PLAIN with raw `|` / `:` text. Mirrors the
313            // HTML lift above: strip list-item indent from continuation
314            // lines, reparse via the block dispatcher, accept a single root
315            // node whose kind is in the allowlist and that consumes the
316            // whole buffer.
317            if self
318                .segments
319                .iter()
320                .all(|s| matches!(s, ListItemContent::Text(_)))
321                && try_emit_table_or_div_lift(builder, &text, config, content_col)
322            {
323                return;
324            }
325        }
326
327        let block_kind = if use_paragraph {
328            SyntaxKind::PARAGRAPH
329        } else {
330            SyntaxKind::PLAIN
331        };
332
333        builder.start_node(block_kind.into());
334
335        let paragraph_buffer = self.to_paragraph_buffer();
336        if !paragraph_buffer.is_empty() {
337            paragraph_buffer.emit_with_inlines(builder, config, suppress_footnote_refs);
338        } else if !text.is_empty() {
339            inline_emission::emit_inlines(builder, &text, config, suppress_footnote_refs);
340        }
341
342        builder.finish_node(); // Close PLAIN or PARAGRAPH
343    }
344
345    /// Clear the buffer for reuse. Also drops any open display-math state:
346    /// every clear site starts a fresh paragraph-like chunk (blank-line
347    /// flush, first-line conversion, setext fold), and a blank line ends
348    /// the math region just like it ends a paragraph.
349    pub(crate) fn clear(&mut self) {
350        self.segments.clear();
351        self.open_display_math = None;
352    }
353}
354
355/// Attempt the Pandoc HTML-block-first-line structural lift on the
356/// buffered list-item text. Returns `true` if `text` was emitted as
357/// one or more HTML block CST nodes (no surrounding PLAIN/PARAGRAPH
358/// wrapper). Returns `false` if the lift gate rejected the case;
359/// the caller falls through to its default Plain/Paragraph emission.
360///
361/// The gate is strict: the inner reparse must produce exactly one
362/// top-level HTML_BLOCK or HTML_BLOCK_DIV that consumes every byte
363/// of `text` (modulo list-item indent stripping — see `content_col`).
364/// For HTML_BLOCK_DIV, a matched open+close is required (>= 2
365/// `HTML_BLOCK_TAG` children). This avoids lifting unclosed shapes
366/// (where the close tag would live in a separate sibling HTML_BLOCK),
367/// which would produce a structurally incomplete CST.
368///
369/// When `content_col > 0`, continuation lines have up to `content_col`
370/// leading spaces stripped before the inner reparse, mirroring
371/// pandoc's list-item indent normalization. The stripped bytes are
372/// re-injected as `WHITESPACE` tokens at the start of each continuation
373/// line during graft so the result is byte-equal to the original
374/// buffer text.
375///
376/// `line0_prefix` is re-injected at the very start of the grafted block
377/// (before the open tag's first token, so it lands *inside* the block).
378/// List-item and marker-line callers pass `""` — their line-0 indent was
379/// already emitted upstream as the list marker / definition marker. The
380/// later-line content-container caller passes the stripped content indent
381/// so the block's first line carries it too (the formatter dumps HTML
382/// blocks verbatim, so the indent must live inside the block).
383pub(crate) fn try_emit_html_block_lift(
384    builder: &mut GreenNodeBuilder<'static>,
385    text: &str,
386    config: &ParserOptions,
387    content_col: usize,
388    use_paragraph: bool,
389    line0_prefix: &str,
390) -> bool {
391    let first_line = text.split_inclusive('\n').next().unwrap_or(text);
392    let first_line_no_nl = first_line
393        .strip_suffix("\r\n")
394        .or_else(|| first_line.strip_suffix('\n'))
395        .unwrap_or(first_line);
396    if try_parse_html_block_start(first_line_no_nl, false).is_none() {
397        return false;
398    }
399
400    let (parse_text, mut prefixes) = if content_col > 0 {
401        strip_list_item_indent(text, content_col)
402    } else {
403        (text.to_string(), Vec::new())
404    };
405    if !line0_prefix.is_empty() {
406        if prefixes.is_empty() {
407            prefixes.push(line0_prefix.to_string());
408        } else {
409            prefixes[0] = line0_prefix.to_string();
410        }
411    }
412
413    let refdefs = config.refdef_labels.clone().unwrap_or_default();
414    let inner_root = crate::parser::parse_with_refdefs(&parse_text, Some(config.clone()), refdefs);
415
416    let children: Vec<SyntaxNode> = inner_root.children().collect();
417    if children.is_empty() {
418        return false;
419    }
420    let first = &children[0];
421    if !matches!(
422        first.kind(),
423        SyntaxKind::HTML_BLOCK | SyntaxKind::HTML_BLOCK_RAW | SyntaxKind::HTML_BLOCK_DIV
424    ) {
425        return false;
426    }
427    let total_end = children.last().unwrap().text_range().end();
428    if total_end != TextSize::of(parse_text.as_str()) {
429        return false;
430    }
431
432    // Single-child path: existing same-line / fully-contained lift.
433    // Multi-child path: trailing-text split — the inner dispatcher
434    // produced sibling block(s) after the HTML_BLOCK / HTML_BLOCK_DIV.
435    // Sources:
436    //   - `try_parse_comment_pi_with_trailing_split` for `<!--…--> trail`
437    //     and `<?…?> trail` (HTML_BLOCK + PARAGRAPH).
438    //   - Same-line div / non-div strict-block lift's trailing branch
439    //     for `<div>foo</div>bar` (HTML_BLOCK_DIV + PARAGRAPH) and
440    //     `<form>foo</form>bar` (also HTML_BLOCK + PARAGRAPH after the
441    //     existing strict-block matched-pair lift fires).
442    // The trailing PARAGRAPH is retagged to PLAIN for tight list items
443    // so the item shape matches pandoc (`[RawBlock, Plain[trailing]]`
444    // for tight, `[RawBlock, Para[...]]` for loose). N>2 children would
445    // require Para→Plain SoftBreak fusion across HTML-block boundaries
446    // (0390 blocked); leave those shapes to the inline path until that
447    // gap closes.
448    let multi_child_trailing = if children.len() == 1 {
449        false
450    } else if children.len() == 2
451        && matches!(
452            first.kind(),
453            SyntaxKind::HTML_BLOCK | SyntaxKind::HTML_BLOCK_RAW | SyntaxKind::HTML_BLOCK_DIV
454        )
455        && children[1].kind() == SyntaxKind::PARAGRAPH
456    {
457        true
458    } else {
459        return false;
460    };
461
462    if first.kind() == SyntaxKind::HTML_BLOCK_DIV {
463        let html_block_tag_count = first
464            .children()
465            .filter(|c| c.kind() == SyntaxKind::HTML_BLOCK_TAG)
466            .count();
467        if html_block_tag_count < 2 {
468            return false;
469        }
470    }
471
472    let prefix_lines: Vec<ContainerPrefixLine> = prefixes
473        .into_iter()
474        .map(ContainerPrefixLine::list_only)
475        .collect();
476    let mut prefix_state = ContainerPrefixState::new(prefix_lines);
477    if multi_child_trailing {
478        graft_node(builder, first, &mut prefix_state);
479        let trailing_kind = if use_paragraph {
480            SyntaxKind::PARAGRAPH
481        } else {
482            SyntaxKind::PLAIN
483        };
484        graft_node_retag_root(builder, &children[1], &mut prefix_state, trailing_kind);
485    } else {
486        graft_node(builder, first, &mut prefix_state);
487    }
488    true
489}
490
491/// Structural lift for pipe tables, grid tables, and fenced divs whose
492/// opener sits on the list-item marker line (or on the first non-blank
493/// continuation line of a buffered list item). Returns `true` when the
494/// buffered text was emitted as a single LIST_ITEM-child block. The
495/// strict single-root + total-end-coverage gate makes "lift failed"
496/// indistinguishable from "buffer is not actually a table/div" — the
497/// caller falls through to its PLAIN/PARAGRAPH wrapper.
498fn try_emit_table_or_div_lift(
499    builder: &mut GreenNodeBuilder<'static>,
500    text: &str,
501    config: &ParserOptions,
502    content_col: usize,
503) -> bool {
504    let first_line = text.split_inclusive('\n').next().unwrap_or(text);
505    let first_line_no_nl = first_line
506        .strip_suffix("\r\n")
507        .or_else(|| first_line.strip_suffix('\n'))
508        .unwrap_or(first_line);
509    let trimmed = first_line_no_nl.trim_start();
510    let first_byte = trimmed.as_bytes().first().copied();
511    if !matches!(first_byte, Some(b'|') | Some(b'+') | Some(b':')) {
512        return false;
513    }
514
515    let (parse_text, prefixes) = if content_col > 0 {
516        strip_list_item_indent(text, content_col)
517    } else {
518        (text.to_string(), Vec::new())
519    };
520
521    let refdefs = config.refdef_labels.clone().unwrap_or_default();
522    let inner_root = crate::parser::parse_with_refdefs(&parse_text, Some(config.clone()), refdefs);
523
524    let children: Vec<SyntaxNode> = inner_root.children().collect();
525    if children.len() != 1 {
526        return false;
527    }
528    let first = &children[0];
529    if !matches!(
530        first.kind(),
531        SyntaxKind::PIPE_TABLE | SyntaxKind::GRID_TABLE | SyntaxKind::FENCED_DIV
532    ) {
533        return false;
534    }
535    if first.text_range().end() != TextSize::of(parse_text.as_str()) {
536        return false;
537    }
538
539    let prefix_lines: Vec<ContainerPrefixLine> = prefixes
540        .into_iter()
541        .map(ContainerPrefixLine::list_only)
542        .collect();
543    let mut prefix_state = ContainerPrefixState::new(prefix_lines);
544    graft_node(builder, first, &mut prefix_state);
545    true
546}
547
548fn graft_node_retag_root(
549    builder: &mut GreenNodeBuilder<'static>,
550    node: &SyntaxNode,
551    prefix: &mut Option<ContainerPrefixState>,
552    new_kind: SyntaxKind,
553) {
554    builder.start_node(new_kind.into());
555    for child in node.children_with_tokens() {
556        match child {
557            rowan::NodeOrToken::Node(n) => graft_node(builder, &n, prefix),
558            rowan::NodeOrToken::Token(t) => {
559                emit_grafted_token(builder, t.kind(), t.text(), prefix);
560            }
561        }
562    }
563    builder.finish_node();
564}
565
566/// Strip up to `content_col` leading-space bytes from each continuation
567/// line of `text` (lines after the first). The first line is left
568/// untouched — its leading columns are owned by the list marker and
569/// its post-marker spaces. Returns the stripped text plus a per-line
570/// prefix vector for losslessness re-injection during graft.
571/// Number of leading bytes covering up to `content_col` columns of
572/// list-item indentation (spaces, or tabs on 4-column stops).
573fn item_indent_prefix_len(line: &str, content_col: usize) -> usize {
574    let mut consumed = 0usize;
575    let mut col = 0usize;
576    for &b in line.as_bytes() {
577        if col >= content_col {
578            break;
579        }
580        match b {
581            b' ' => {
582                col += 1;
583                consumed += 1;
584            }
585            b'\t' => {
586                let next = (col / 4 + 1) * 4;
587                if next > content_col {
588                    break;
589                }
590                col = next;
591                consumed += 1;
592            }
593            _ => break,
594        }
595    }
596    consumed
597}
598
599fn strip_list_item_indent(text: &str, content_col: usize) -> (String, Vec<String>) {
600    let mut stripped = String::with_capacity(text.len());
601    let mut prefixes: Vec<String> = Vec::new();
602    for (i, line) in text.split_inclusive('\n').enumerate() {
603        if i == 0 {
604            prefixes.push(String::new());
605            stripped.push_str(line);
606            continue;
607        }
608        let consumed = item_indent_prefix_len(line, content_col);
609        prefixes.push(line[..consumed].to_string());
610        stripped.push_str(&line[consumed..]);
611    }
612    (stripped, prefixes)
613}
614
615fn graft_node(
616    builder: &mut GreenNodeBuilder<'static>,
617    node: &SyntaxNode,
618    prefix: &mut Option<ContainerPrefixState>,
619) {
620    builder.start_node(node.kind().into());
621    for child in node.children_with_tokens() {
622        match child {
623            rowan::NodeOrToken::Node(n) => graft_node(builder, &n, prefix),
624            rowan::NodeOrToken::Token(t) => {
625                emit_grafted_token(builder, t.kind(), t.text(), prefix);
626            }
627        }
628    }
629    builder.finish_node();
630}
631
632fn emit_grafted_token(
633    builder: &mut GreenNodeBuilder<'static>,
634    kind: SyntaxKind,
635    text: &str,
636    prefix: &mut Option<ContainerPrefixState>,
637) {
638    if let Some(state) = prefix.as_mut() {
639        if state.at_line_start {
640            if let Some(line_prefix) = state.prefixes.get(state.line_idx) {
641                emit_container_prefix_tokens(builder, line_prefix);
642            }
643            state.at_line_start = false;
644        }
645        builder.token(kind.into(), text);
646        if kind == SyntaxKind::NEWLINE || kind == SyntaxKind::BLANK_LINE {
647            state.line_idx += 1;
648            state.at_line_start = true;
649        }
650    } else {
651        builder.token(kind.into(), text);
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_new_buffer_is_empty() {
661        let buffer = ListItemBuffer::new();
662        assert!(buffer.is_empty());
663        assert!(!buffer.has_blank_lines_between_content());
664    }
665
666    #[test]
667    fn test_push_single_text() {
668        let mut buffer = ListItemBuffer::new();
669        buffer.push_text("Hello, world!", &ParserOptions::default());
670        assert!(!buffer.is_empty());
671        assert!(!buffer.has_blank_lines_between_content());
672        assert_eq!(buffer.get_text_for_parsing(), "Hello, world!");
673    }
674
675    #[test]
676    fn test_push_multiple_text_segments() {
677        let mut buffer = ListItemBuffer::new();
678        let config = ParserOptions::default();
679        buffer.push_text("Line 1\n", &config);
680        buffer.push_text("Line 2\n", &config);
681        buffer.push_text("Line 3", &config);
682        assert_eq!(buffer.get_text_for_parsing(), "Line 1\nLine 2\nLine 3");
683    }
684
685    #[test]
686    fn test_clear_buffer() {
687        let mut buffer = ListItemBuffer::new();
688        buffer.push_text("Some text", &ParserOptions::default());
689        assert!(!buffer.is_empty());
690
691        buffer.clear();
692        assert!(buffer.is_empty());
693        assert_eq!(buffer.get_text_for_parsing(), "");
694    }
695
696    #[test]
697    fn test_empty_text_ignored() {
698        let mut buffer = ListItemBuffer::new();
699        buffer.push_text("", &ParserOptions::default());
700        assert!(buffer.is_empty());
701    }
702
703    #[test]
704    fn test_display_math_state_tracks_and_resets_on_clear() {
705        let mut config = ParserOptions::default();
706        config.extensions.tex_math_single_backslash = true;
707
708        let mut buffer = ListItemBuffer::new();
709        buffer.push_text("\\[\n", &config);
710        assert!(buffer.has_open_display_math());
711        buffer.push_text("x = 1 \\]\n", &config);
712        assert!(!buffer.has_open_display_math());
713
714        buffer.push_text("$$\n", &config);
715        assert!(buffer.has_open_display_math());
716        buffer.clear();
717        assert!(!buffer.has_open_display_math());
718    }
719}