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