Skip to main content

panache_parser/parser/blocks/
admonitions.rs

1//! Admonition opener detection.
2//!
3//! Recognizes python-markdown admonitions (`!!! type "title"`) and the
4//! pymdownx.details collapsible variants (`???` / `???+`). Both open a
5//! container whose 4-space-indented body is parsed recursively (see the
6//! `Admonition` container, which closes on dedent like a footnote
7//! definition). Only the opener line is parsed here; the body is handled
8//! by the container machinery.
9//!
10//! Syntax (mirrors python-markdown / pymdownx):
11//!
12//! ```text
13//! !!! type "Optional title"
14//! ??? type "Optional title"     (collapsed)
15//! ???+ type "Optional title"    (expanded)
16//! ```
17//!
18//! `type` is one or more space-separated class words (`[\w\-]+`); the
19//! first is the admonition type, the rest extra classes. The quoted title
20//! is optional. For `!!!` the type is required; for `???`/`???+` it is
21//! optional. Anything other than class words plus an optional trailing
22//! quoted title disqualifies the line (so ordinary paragraphs starting
23//! with `!!!` are left alone).
24
25use crate::options::Extensions;
26use crate::parser::utils::helpers::strip_newline;
27
28/// Which marker opened the admonition.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum AdmonitionMarker {
31    /// `!!!` — python-markdown admonition.
32    Admonition,
33    /// `???` — collapsed pymdownx details.
34    DetailsCollapsed,
35    /// `???+` — expanded pymdownx details.
36    DetailsExpanded,
37}
38
39/// A detected admonition opener. All ranges are byte offsets into the
40/// original `content` passed to [`try_parse_admonition_open`], so the
41/// dispatcher can emit losslessly.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) struct AdmonitionOpen {
44    pub marker: AdmonitionMarker,
45    /// Bytes of leading whitespace before the marker (0..=3).
46    pub indent_len: usize,
47    /// Length of the marker in bytes (`!!!`/`???` = 3, `???+` = 4).
48    pub marker_len: usize,
49    /// Range of the type/class words, if present.
50    pub type_range: Option<(usize, usize)>,
51    /// Range of the quoted title (including the surrounding quotes), if
52    /// present.
53    pub title_range: Option<(usize, usize)>,
54}
55
56fn is_class_char(c: char) -> bool {
57    c.is_alphanumeric() || c == '_' || c == '-'
58}
59
60/// Try to detect an admonition opener from a block's first line.
61///
62/// Returns `None` when neither extension is enabled, when the marker does
63/// not match, or when the post-marker content isn't a valid type/title.
64pub(crate) fn try_parse_admonition_open(content: &str, ext: &Extensions) -> Option<AdmonitionOpen> {
65    if !ext.python_markdown_admonitions && !ext.pymdownx_details {
66        return None;
67    }
68
69    let (line, _newline) = strip_newline(content);
70
71    // Up to 3 leading spaces, per CommonMark indentation convention.
72    let indent_len = line.bytes().take_while(|&b| b == b' ').count();
73    if indent_len > 3 {
74        return None;
75    }
76    let rest = &line[indent_len..];
77    let rest_bytes = rest.as_bytes();
78
79    let (marker, marker_len) = if rest.starts_with("!!!") {
80        if !ext.python_markdown_admonitions || rest_bytes.get(3) == Some(&b'!') {
81            return None;
82        }
83        (AdmonitionMarker::Admonition, 3)
84    } else if rest.starts_with("???") {
85        if !ext.pymdownx_details || rest_bytes.get(3) == Some(&b'?') {
86            return None;
87        }
88        if rest_bytes.get(3) == Some(&b'+') {
89            (AdmonitionMarker::DetailsExpanded, 4)
90        } else {
91            (AdmonitionMarker::DetailsCollapsed, 3)
92        }
93    } else {
94        return None;
95    };
96
97    // Offset of the first byte after the marker, in `content`.
98    let after_marker_abs = indent_len + marker_len;
99    let after_marker = &line[after_marker_abs..];
100
101    // Skip spaces between the marker and the type/title.
102    let lead = after_marker.bytes().take_while(|&b| b == b' ').count();
103    let body_abs = after_marker_abs + lead;
104    let body = after_marker[lead..].trim_end_matches(' ');
105
106    // A trailing quoted title: `... "title"`.
107    let (type_str, type_abs, title_range) = if body.ends_with('"') && body.matches('"').count() >= 2
108    {
109        let first_q = body.find('"').unwrap();
110        // python-markdown requires a space before the opening quote
111        // (unless the title is the entire body, i.e. no type).
112        if first_q > 0 && body.as_bytes()[first_q - 1] != b' ' {
113            return None;
114        }
115        let type_str = body[..first_q].trim_end();
116        let title_abs = body_abs + first_q;
117        (
118            type_str,
119            body_abs,
120            Some((title_abs, title_abs + body[first_q..].len())),
121        )
122    } else {
123        (body, body_abs, None)
124    };
125
126    // The type may only be class words (and the spaces between them).
127    if !type_str.chars().all(|c| is_class_char(c) || c == ' ') {
128        return None;
129    }
130
131    let type_range = if type_str.is_empty() {
132        None
133    } else {
134        Some((type_abs, type_abs + type_str.len()))
135    };
136
137    // python-markdown admonitions require a type; details do not.
138    if marker == AdmonitionMarker::Admonition && type_range.is_none() {
139        return None;
140    }
141
142    Some(AdmonitionOpen {
143        marker,
144        indent_len,
145        marker_len,
146        type_range,
147        title_range,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn both() -> Extensions {
156        // `Extensions::default()` (Pandoc) leaves both admonition flags off.
157        Extensions {
158            python_markdown_admonitions: true,
159            pymdownx_details: true,
160            ..Extensions::default()
161        }
162    }
163
164    fn slice(content: &str, range: (usize, usize)) -> &str {
165        &content[range.0..range.1]
166    }
167
168    #[test]
169    fn basic_admonition() {
170        let c = "!!! note\n";
171        let a = try_parse_admonition_open(c, &both()).unwrap();
172        assert_eq!(a.marker, AdmonitionMarker::Admonition);
173        assert_eq!(a.indent_len, 0);
174        assert_eq!(a.marker_len, 3);
175        assert_eq!(slice(c, a.type_range.unwrap()), "note");
176        assert!(a.title_range.is_none());
177    }
178
179    #[test]
180    fn admonition_with_title() {
181        let c = "!!! note \"Heads up\"\n";
182        let a = try_parse_admonition_open(c, &both()).unwrap();
183        assert_eq!(slice(c, a.type_range.unwrap()), "note");
184        assert_eq!(slice(c, a.title_range.unwrap()), "\"Heads up\"");
185    }
186
187    #[test]
188    fn admonition_with_extra_classes() {
189        let c = "!!! danger highlight \"Don't\"\n";
190        let a = try_parse_admonition_open(c, &both()).unwrap();
191        assert_eq!(slice(c, a.type_range.unwrap()), "danger highlight");
192        assert_eq!(slice(c, a.title_range.unwrap()), "\"Don't\"");
193    }
194
195    #[test]
196    fn admonition_empty_title() {
197        let c = "!!! note \"\"\n";
198        let a = try_parse_admonition_open(c, &both()).unwrap();
199        assert_eq!(slice(c, a.type_range.unwrap()), "note");
200        assert_eq!(slice(c, a.title_range.unwrap()), "\"\"");
201    }
202
203    #[test]
204    fn details_collapsed_and_expanded() {
205        let collapsed = try_parse_admonition_open("??? note\n", &both()).unwrap();
206        assert_eq!(collapsed.marker, AdmonitionMarker::DetailsCollapsed);
207        assert_eq!(collapsed.marker_len, 3);
208
209        let c = "???+ note\n";
210        let expanded = try_parse_admonition_open(c, &both()).unwrap();
211        assert_eq!(expanded.marker, AdmonitionMarker::DetailsExpanded);
212        assert_eq!(expanded.marker_len, 4);
213        assert_eq!(slice(c, expanded.type_range.unwrap()), "note");
214    }
215
216    #[test]
217    fn details_allow_empty_type() {
218        let a = try_parse_admonition_open("???\n", &both()).unwrap();
219        assert_eq!(a.marker, AdmonitionMarker::DetailsCollapsed);
220        assert!(a.type_range.is_none());
221    }
222
223    #[test]
224    fn admonition_requires_type() {
225        assert!(try_parse_admonition_open("!!!\n", &both()).is_none());
226        assert!(try_parse_admonition_open("!!! \"only title\"\n", &both()).is_none());
227    }
228
229    #[test]
230    fn leading_indent_allowed_up_to_three() {
231        let c = "   !!! note\n";
232        let a = try_parse_admonition_open(c, &both()).unwrap();
233        assert_eq!(a.indent_len, 3);
234        assert_eq!(slice(c, a.type_range.unwrap()), "note");
235
236        // Four spaces is indented code, not an admonition.
237        assert!(try_parse_admonition_open("    !!! note\n", &both()).is_none());
238    }
239
240    #[test]
241    fn rejects_non_class_content() {
242        // Trailing prose after the type is not an admonition.
243        assert!(try_parse_admonition_open("!!! warning, this is bad.\n", &both()).is_none());
244        assert!(try_parse_admonition_open("!!! note.\n", &both()).is_none());
245        // Multi-word all-class is fine (python-markdown treats words as classes).
246        assert!(try_parse_admonition_open("!!! note two three\n", &both()).is_some());
247    }
248
249    #[test]
250    fn four_bangs_is_not_a_marker() {
251        assert!(try_parse_admonition_open("!!!! note\n", &both()).is_none());
252        assert!(try_parse_admonition_open("???? note\n", &both()).is_none());
253    }
254
255    #[test]
256    fn gated_on_extension() {
257        let only_adm = Extensions {
258            python_markdown_admonitions: true,
259            ..Extensions::default()
260        };
261        assert!(try_parse_admonition_open("!!! note\n", &only_adm).is_some());
262        assert!(try_parse_admonition_open("??? note\n", &only_adm).is_none());
263
264        let only_det = Extensions {
265            pymdownx_details: true,
266            ..Extensions::default()
267        };
268        assert!(try_parse_admonition_open("??? note\n", &only_det).is_some());
269        assert!(try_parse_admonition_open("!!! note\n", &only_det).is_none());
270
271        let off = Extensions::default();
272        assert!(try_parse_admonition_open("!!! note\n", &off).is_none());
273        assert!(try_parse_admonition_open("??? note\n", &off).is_none());
274    }
275
276    #[test]
277    fn no_space_before_type_is_allowed() {
278        let c = "!!!note\n";
279        let a = try_parse_admonition_open(c, &both()).unwrap();
280        assert_eq!(slice(c, a.type_range.unwrap()), "note");
281    }
282}