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
159        let block_kind = if use_paragraph {
160            SyntaxKind::PARAGRAPH
161        } else {
162            SyntaxKind::PLAIN
163        };
164
165        builder.start_node(block_kind.into());
166
167        let paragraph_buffer = self.to_paragraph_buffer();
168        if !paragraph_buffer.is_empty() {
169            paragraph_buffer.emit_with_inlines(builder, config);
170        } else if !text.is_empty() {
171            inline_emission::emit_inlines(builder, &text, config);
172        }
173
174        builder.finish_node(); // Close PLAIN or PARAGRAPH
175    }
176
177    /// Clear the buffer for reuse.
178    pub(crate) fn clear(&mut self) {
179        self.segments.clear();
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_new_buffer_is_empty() {
189        let buffer = ListItemBuffer::new();
190        assert!(buffer.is_empty());
191        assert!(!buffer.has_blank_lines_between_content());
192    }
193
194    #[test]
195    fn test_push_single_text() {
196        let mut buffer = ListItemBuffer::new();
197        buffer.push_text("Hello, world!");
198        assert!(!buffer.is_empty());
199        assert!(!buffer.has_blank_lines_between_content());
200        assert_eq!(buffer.get_text_for_parsing(), "Hello, world!");
201    }
202
203    #[test]
204    fn test_push_multiple_text_segments() {
205        let mut buffer = ListItemBuffer::new();
206        buffer.push_text("Line 1\n");
207        buffer.push_text("Line 2\n");
208        buffer.push_text("Line 3");
209        assert_eq!(buffer.get_text_for_parsing(), "Line 1\nLine 2\nLine 3");
210    }
211
212    #[test]
213    fn test_clear_buffer() {
214        let mut buffer = ListItemBuffer::new();
215        buffer.push_text("Some text");
216        assert!(!buffer.is_empty());
217
218        buffer.clear();
219        assert!(buffer.is_empty());
220        assert_eq!(buffer.get_text_for_parsing(), "");
221    }
222
223    #[test]
224    fn test_empty_text_ignored() {
225        let mut buffer = ListItemBuffer::new();
226        buffer.push_text("");
227        assert!(buffer.is_empty());
228    }
229}