Skip to main content

panache_parser/parser/blocks/
fenced_divs.rs

1//! Fenced div parsing utilities.
2
3use crate::parser::utils::helpers::strip_leading_spaces;
4
5/// Information about a detected div fence opening.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct DivFenceInfo {
8    pub attributes: String,
9    pub fence_count: usize,
10}
11
12/// Try to detect a fenced div opening from content.
13/// Returns div fence info if this is a valid opening fence.
14///
15/// Opening fences MUST have attributes (or the fences are treated as closing).
16/// Format: `::: {.class #id}` or `::: classname` or `::::: {#id} :::::`
17pub(crate) fn try_parse_div_fence_open(content: &str) -> Option<DivFenceInfo> {
18    let trimmed = strip_leading_spaces(content);
19
20    // Check for fence opening (:::)
21    if !trimmed.starts_with(':') {
22        return None;
23    }
24
25    let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
26
27    if colon_count < 3 {
28        return None;
29    }
30
31    // Get the part after the colons
32    let after_colons = trimmed[colon_count..].trim_start();
33
34    // Check if there are attributes
35    // Attributes can be:
36    // 1. Curly braces: {.class #id key="value"}
37    // 2. Single word (treated as class): classname
38    // 3. Attributes followed by more colons (optional): {.class} :::
39
40    let attributes = if after_colons.starts_with('{') {
41        // Find the closing brace
42        if let Some(close_idx) = after_colons.find('}') {
43            after_colons[..=close_idx].to_string()
44        } else {
45            // Unclosed brace, not valid
46            return None;
47        }
48    } else if after_colons.is_empty() {
49        // No attributes - this is a closing fence.
50        return None;
51    } else {
52        // Single word (treated as class), optionally followed by trailing colons.
53        let word_end = after_colons
54            .find(|c: char| c.is_whitespace() || c == ':')
55            .unwrap_or(after_colons.len());
56        let (first, rest) = after_colons.split_at(word_end);
57        if first.is_empty() {
58            return None;
59        }
60
61        let trailing = rest.trim();
62        if !trailing.is_empty() {
63            if trailing.chars().any(|c| c != ':') {
64                return None;
65            }
66            // Require at least 3 trailing colons when extra content follows the class.
67            if trailing.len() < 3 {
68                return None;
69            }
70        } else {
71            // No whitespace, but trailing colons may be present (e.g. "Warning:::::").
72            let trailing_colons = after_colons[first.len()..].trim();
73            if !trailing_colons.is_empty() {
74                if trailing_colons.chars().any(|c| c != ':') {
75                    return None;
76                }
77                if trailing_colons.len() < 3 {
78                    return None;
79                }
80            }
81        }
82
83        first.to_string()
84    };
85
86    Some(DivFenceInfo {
87        attributes,
88        fence_count: colon_count,
89    })
90}
91
92/// Check if a line is a valid closing fence for a div.
93/// Closing fences have NO attributes and at least 3 colons.
94pub(crate) fn is_div_closing_fence(content: &str) -> bool {
95    let trimmed = strip_leading_spaces(content);
96
97    if !trimmed.starts_with(':') {
98        return false;
99    }
100
101    let colon_count = trimmed.chars().take_while(|&c| c == ':').count();
102
103    if colon_count < 3 {
104        return false;
105    }
106
107    // Rest of line must be empty (only colons are allowed)
108    trimmed[colon_count..].trim().is_empty()
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_parse_div_fence_open_with_curly_braces() {
117        let line = "::: {.callout-note}";
118        let fence = try_parse_div_fence_open(line).unwrap();
119        assert_eq!(fence.attributes, "{.callout-note}");
120    }
121
122    #[test]
123    fn test_parse_div_fence_open_with_class_name() {
124        let line = "::: Warning";
125        let fence = try_parse_div_fence_open(line).unwrap();
126        assert_eq!(fence.attributes, "Warning");
127    }
128
129    #[test]
130    fn test_parse_div_fence_open_with_trailing_colons() {
131        let line = "::::: {#special .sidebar} :::::";
132        let fence = try_parse_div_fence_open(line).unwrap();
133        assert_eq!(fence.attributes, "{#special .sidebar}");
134    }
135
136    #[test]
137    fn test_parse_div_fence_open_with_class_name_and_trailing_colons() {
138        let line = "::: Warning :::";
139        let fence = try_parse_div_fence_open(line).unwrap();
140        assert_eq!(fence.attributes, "Warning");
141    }
142
143    #[test]
144    fn test_opening_fence_empty_attributes() {
145        let line = ":::";
146        assert!(try_parse_div_fence_open(line).is_none());
147        assert!(is_div_closing_fence(line));
148    }
149
150    #[test]
151    fn test_opening_fence_many_colons_empty_attributes() {
152        let line = "::::::::::::::";
153        assert!(try_parse_div_fence_open(line).is_none());
154        assert!(is_div_closing_fence(line));
155    }
156
157    #[test]
158    fn test_not_a_fence_too_few_colons() {
159        let line = ":: something";
160        assert!(try_parse_div_fence_open(line).is_none());
161        assert!(!is_div_closing_fence(line));
162    }
163}