Skip to main content

marco_core/parser/blocks/
cm_blockquote_parser.rs

1//! Blockquote parser - converts grammar output to AST nodes
2//!
3//! Handles conversion of blockquotes (> prefixed lines) from grammar layer to parser AST,
4//! including recursive block parsing and lazy continuation line handling.
5
6use super::shared::{opt_span, GrammarSpan};
7use crate::parser::ast::{Document, Node, NodeKind};
8
9/// Parse a blockquote into an AST node with recursive block parsing.
10///
11/// # Arguments
12/// * `content` - The blockquote content from grammar layer (includes > markers)
13/// * `depth` - Current recursion depth for safety
14/// * `parse_blocks_fn` - Function to recursively parse nested blocks
15///
16/// # Returns
17/// A Node with NodeKind::Blockquote containing parsed block children
18///
19/// # Processing
20/// The function:
21/// 1. Extracts content by removing > markers from each line
22/// 2. Handles lazy continuation lines (lines without > markers)
23/// 3. Prevents setext heading underlines in lazy continuation (CommonMark spec)
24/// 4. Recursively parses the cleaned content as block elements
25///
26/// # Example
27/// ```
28/// use marco_core::parser::blocks::cm_blockquote_parser::parse_blockquote;
29/// use marco_core::parser::shared::GrammarSpan;
30/// use marco_core::{Document, NodeKind};
31///
32/// let content = GrammarSpan::new("> Line 1\n> Line 2");
33/// let node = parse_blockquote(content, 0, |_input: &str, _depth: usize| {
34///     Ok(Document::new())
35/// }).unwrap();
36/// assert!(matches!(node.kind, NodeKind::Blockquote));
37/// ```
38pub fn parse_blockquote<F>(
39    content: GrammarSpan,
40    depth: usize,
41    parse_blocks_fn: F,
42) -> Result<Node, Box<dyn std::error::Error>>
43where
44    F: FnOnce(&str, usize) -> Result<Document, Box<dyn std::error::Error>>,
45{
46    let span = opt_span(content);
47
48    // Extract the block quote content (remove leading > markers)
49    // CRITICAL: Per CommonMark spec, "The setext heading underline cannot be a lazy continuation line"
50    // So we need to track which lines had > markers and prevent setext matching on lazy lines
51    let content_str = content.fragment();
52    let mut cleaned_content = String::with_capacity(content_str.len());
53
54    for line in content_str.split_inclusive('\n') {
55        let line_trimmed_start = line.trim_start();
56        let has_marker = line_trimmed_start.starts_with('>');
57
58        if has_marker {
59            // Line has > marker - remove it and optional space
60            let after_marker = line_trimmed_start.strip_prefix('>').unwrap();
61            let cleaned = after_marker.strip_prefix(' ').unwrap_or(after_marker);
62            cleaned_content.push_str(cleaned);
63        } else {
64            // Lazy continuation line - no > marker
65            // Check if this looks like a setext underline (all === or all ---)
66            let line_content = line_trimmed_start.trim_end();
67            let line_sans_spaces = line_content.replace([' ', '\t'], "");
68
69            let is_underline = !line_sans_spaces.is_empty()
70                && (line_sans_spaces.chars().all(|c| c == '=')
71                    || line_sans_spaces.chars().all(|c| c == '-'));
72
73            if is_underline {
74                // This lazy continuation looks like setext underline
75                // Per CommonMark: "underline cannot be lazy continuation"
76                // Escape the first character to prevent setext parsing
77                if let Some(first_char) = line_content.chars().next() {
78                    if first_char == '=' || first_char == '-' {
79                        // Add backslash escape before first underline character
80                        cleaned_content.push('\\');
81                    }
82                }
83            }
84
85            // Add the line as-is (or with escape prepended)
86            cleaned_content.push_str(line);
87        }
88    }
89
90    // Recursively parse the block quote content
91    let inner_doc = parse_blocks_fn(&cleaned_content, depth + 1)?;
92
93    Ok(Node {
94        kind: NodeKind::Blockquote,
95        span,
96        children: inner_doc.children, // Use parsed children
97    })
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::parser::ast::NodeKind;
104
105    // Mock parse function for testing
106    fn mock_parse_blocks(
107        input: &str,
108        _depth: usize,
109    ) -> Result<Document, Box<dyn std::error::Error>> {
110        let mut doc = Document::new();
111        if !input.is_empty() {
112            doc.children.push(Node {
113                kind: NodeKind::Text(input.to_string()),
114                span: None,
115                children: Vec::new(),
116            });
117        }
118        Ok(doc)
119    }
120
121    #[test]
122    fn smoke_test_parse_blockquote_basic() {
123        let content = GrammarSpan::new("> Line 1\n> Line 2");
124        let node = parse_blockquote(content, 0, mock_parse_blocks).unwrap();
125
126        assert!(matches!(node.kind, NodeKind::Blockquote));
127        assert!(!node.children.is_empty());
128    }
129
130    #[test]
131    fn smoke_test_blockquote_lazy_continuation() {
132        let content = GrammarSpan::new("> Line 1\nLine 2 (lazy)");
133        let node = parse_blockquote(content, 0, mock_parse_blocks).unwrap();
134
135        assert!(matches!(node.kind, NodeKind::Blockquote));
136    }
137
138    #[test]
139    fn smoke_test_blockquote_span() {
140        let content = GrammarSpan::new("> Test");
141        let node = parse_blockquote(content, 0, mock_parse_blocks).unwrap();
142
143        assert!(node.span.is_some());
144    }
145
146    #[test]
147    fn smoke_test_blockquote_empty() {
148        let content = GrammarSpan::new(">");
149        let node = parse_blockquote(content, 0, mock_parse_blocks).unwrap();
150
151        assert!(matches!(node.kind, NodeKind::Blockquote));
152    }
153
154    #[test]
155    fn smoke_test_blockquote_nested_content() {
156        let content = GrammarSpan::new("> # Heading\n> Paragraph");
157        let node = parse_blockquote(content, 0, mock_parse_blocks).unwrap();
158
159        assert!(matches!(node.kind, NodeKind::Blockquote));
160        assert!(!node.children.is_empty());
161    }
162}