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::ParserOptions;
7use crate::parser::blocks::headings::{emit_atx_heading, try_parse_atx_heading};
8use crate::parser::blocks::horizontal_rules::{emit_horizontal_rule, try_parse_horizontal_rule};
9use crate::parser::utils::inline_emission;
10use crate::parser::utils::text_buffer::ParagraphBuffer;
11use crate::syntax::SyntaxKind;
12use rowan::GreenNodeBuilder;
13
14/// A segment in the list item buffer - either text content or a blank line.
15#[derive(Debug, Clone)]
16pub(crate) enum ListItemContent {
17    /// Text content (includes newlines for losslessness)
18    Text(String),
19    /// Structural blockquote marker emitted inside buffered list-item text.
20    BlockquoteMarker {
21        leading_spaces: usize,
22        has_trailing_space: bool,
23    },
24}
25
26/// Buffer for accumulating list item content before emission.
27///
28/// Collects text, blank lines, and structural elements as we parse list item
29/// continuation lines. When the list item closes, we can:
30/// 1. Determine if it's tight (Plain) or loose (PARAGRAPH)
31/// 2. Parse inline elements correctly across continuation lines
32/// 3. Emit the complete structure
33#[derive(Debug, Default, Clone)]
34pub(crate) struct ListItemBuffer {
35    /// Segments of content in order
36    segments: Vec<ListItemContent>,
37}
38
39impl ListItemBuffer {
40    /// Create a new empty list item buffer.
41    pub(crate) fn new() -> Self {
42        Self {
43            segments: Vec::new(),
44        }
45    }
46
47    /// Push text content to the buffer.
48    pub(crate) fn push_text(&mut self, text: impl Into<String>) {
49        let text = text.into();
50        if text.is_empty() {
51            return;
52        }
53        self.segments.push(ListItemContent::Text(text));
54    }
55
56    pub(crate) fn push_blockquote_marker(
57        &mut self,
58        leading_spaces: usize,
59        has_trailing_space: bool,
60    ) {
61        self.segments.push(ListItemContent::BlockquoteMarker {
62            leading_spaces,
63            has_trailing_space,
64        });
65    }
66
67    /// Check if buffer is empty.
68    pub(crate) fn is_empty(&self) -> bool {
69        self.segments.is_empty()
70    }
71
72    /// Get the number of segments in the buffer (for debugging).
73    pub(crate) fn segment_count(&self) -> usize {
74        self.segments.len()
75    }
76
77    /// Return the text of the first segment, if it is a `Text` segment.
78    pub(crate) fn first_text(&self) -> Option<&str> {
79        match self.segments.first()? {
80            ListItemContent::Text(t) => Some(t.as_str()),
81            ListItemContent::BlockquoteMarker { .. } => None,
82        }
83    }
84
85    /// Determine if this list item has blank lines between content.
86    ///
87    /// Used to decide between Plain (tight) and PARAGRAPH (loose).
88    /// Returns true if there's a blank line followed by more content.
89    pub(crate) fn has_blank_lines_between_content(&self) -> bool {
90        log::trace!(
91            "has_blank_lines_between_content: segments={} result=false",
92            self.segments.len()
93        );
94
95        false
96    }
97
98    /// Get concatenated text for inline parsing (excludes blank lines).
99    fn get_text_for_parsing(&self) -> String {
100        let mut result = String::new();
101        for segment in &self.segments {
102            if let ListItemContent::Text(text) = segment {
103                result.push_str(text);
104            }
105        }
106        result
107    }
108
109    fn to_paragraph_buffer(&self) -> ParagraphBuffer {
110        let mut paragraph_buffer = ParagraphBuffer::new();
111        for segment in &self.segments {
112            match segment {
113                ListItemContent::Text(text) => paragraph_buffer.push_text(text),
114                ListItemContent::BlockquoteMarker {
115                    leading_spaces,
116                    has_trailing_space,
117                } => paragraph_buffer.push_marker(*leading_spaces, *has_trailing_space),
118            }
119        }
120        paragraph_buffer
121    }
122
123    /// Emit the buffered content as a Plain or PARAGRAPH block.
124    ///
125    /// If `use_paragraph` is true, wraps in PARAGRAPH (loose list).
126    /// If false, wraps in PLAIN (tight list).
127    pub(crate) fn emit_as_block(
128        &self,
129        builder: &mut GreenNodeBuilder<'static>,
130        use_paragraph: bool,
131        config: &ParserOptions,
132    ) {
133        if self.is_empty() {
134            return;
135        }
136
137        // Get text and parse inline elements
138        let text = self.get_text_for_parsing();
139
140        if !text.is_empty() {
141            let line_without_newline = text
142                .strip_suffix("\r\n")
143                .or_else(|| text.strip_suffix('\n'));
144            if let Some(line) = line_without_newline
145                && !line.contains('\n')
146                && !line.contains('\r')
147            {
148                if let Some(level) = try_parse_atx_heading(line) {
149                    emit_atx_heading(builder, &text, level, config);
150                    return;
151                }
152                if try_parse_horizontal_rule(line).is_some() {
153                    emit_horizontal_rule(builder, &text);
154                    return;
155                }
156            }
157
158            // Multi-line case: first line is an ATX heading, rest is plain
159            // continuation. Pandoc treats `- # Heading\n  Some text` as a
160            // list item containing Header + Plain, not a single Plain spanning
161            // both lines.
162            if self
163                .segments
164                .iter()
165                .all(|s| matches!(s, ListItemContent::Text(_)))
166                && let Some(first_nl) = text.find('\n')
167            {
168                let first_line = &text[..first_nl];
169                let after_first = &text[first_nl + 1..];
170                if !after_first.is_empty()
171                    && let Some(level) = try_parse_atx_heading(first_line)
172                {
173                    let heading_bytes = &text[..first_nl + 1];
174                    emit_atx_heading(builder, heading_bytes, level, config);
175
176                    let block_kind = if use_paragraph {
177                        SyntaxKind::PARAGRAPH
178                    } else {
179                        SyntaxKind::PLAIN
180                    };
181                    builder.start_node(block_kind.into());
182                    inline_emission::emit_inlines(builder, after_first, config);
183                    builder.finish_node();
184                    return;
185                }
186            }
187        }
188
189        let block_kind = if use_paragraph {
190            SyntaxKind::PARAGRAPH
191        } else {
192            SyntaxKind::PLAIN
193        };
194
195        builder.start_node(block_kind.into());
196
197        let paragraph_buffer = self.to_paragraph_buffer();
198        if !paragraph_buffer.is_empty() {
199            paragraph_buffer.emit_with_inlines(builder, config);
200        } else if !text.is_empty() {
201            inline_emission::emit_inlines(builder, &text, config);
202        }
203
204        builder.finish_node(); // Close PLAIN or PARAGRAPH
205    }
206
207    /// Clear the buffer for reuse.
208    pub(crate) fn clear(&mut self) {
209        self.segments.clear();
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_new_buffer_is_empty() {
219        let buffer = ListItemBuffer::new();
220        assert!(buffer.is_empty());
221        assert!(!buffer.has_blank_lines_between_content());
222    }
223
224    #[test]
225    fn test_push_single_text() {
226        let mut buffer = ListItemBuffer::new();
227        buffer.push_text("Hello, world!");
228        assert!(!buffer.is_empty());
229        assert!(!buffer.has_blank_lines_between_content());
230        assert_eq!(buffer.get_text_for_parsing(), "Hello, world!");
231    }
232
233    #[test]
234    fn test_push_multiple_text_segments() {
235        let mut buffer = ListItemBuffer::new();
236        buffer.push_text("Line 1\n");
237        buffer.push_text("Line 2\n");
238        buffer.push_text("Line 3");
239        assert_eq!(buffer.get_text_for_parsing(), "Line 1\nLine 2\nLine 3");
240    }
241
242    #[test]
243    fn test_clear_buffer() {
244        let mut buffer = ListItemBuffer::new();
245        buffer.push_text("Some text");
246        assert!(!buffer.is_empty());
247
248        buffer.clear();
249        assert!(buffer.is_empty());
250        assert_eq!(buffer.get_text_for_parsing(), "");
251    }
252
253    #[test]
254    fn test_empty_text_ignored() {
255        let mut buffer = ListItemBuffer::new();
256        buffer.push_text("");
257        assert!(buffer.is_empty());
258    }
259}