panache_parser/parser/blocks/
admonitions.rs1use crate::options::Extensions;
26use crate::parser::utils::helpers::strip_newline;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum AdmonitionMarker {
31 Admonition,
33 DetailsCollapsed,
35 DetailsExpanded,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) struct AdmonitionOpen {
44 pub marker: AdmonitionMarker,
45 pub indent_len: usize,
47 pub marker_len: usize,
49 pub type_range: Option<(usize, usize)>,
51 pub title_range: Option<(usize, usize)>,
54}
55
56fn is_class_char(c: char) -> bool {
57 c.is_alphanumeric() || c == '_' || c == '-'
58}
59
60pub(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 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 let after_marker_abs = indent_len + marker_len;
99 let after_marker = &line[after_marker_abs..];
100
101 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 let (type_str, type_abs, title_range) = if body.ends_with('"') && body.matches('"').count() >= 2
108 {
109 let first_q = body.find('"').unwrap();
110 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 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 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 {
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 assert!(try_parse_admonition_open(" !!! note\n", &both()).is_none());
238 }
239
240 #[test]
241 fn rejects_non_class_content() {
242 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 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}