rumdl_lib/utils/
mkdocs_admonitions.rs1use regex::Regex;
2use std::sync::LazyLock;
15
16static ADMONITION_START: LazyLock<Regex> = LazyLock::new(|| {
21 Regex::new(r#"^(\s*)(?:!!!|\?\?\?\+?)\s+([a-zA-Z][a-zA-Z0-9_-]*)(?:\s+(?:inline(?:\s+end)?))?.*$"#).unwrap()
22});
23
24static ADMONITION_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(?:!!!|\?\?\?\+?)\s+").unwrap());
26
27static VALID_TYPE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]*$").unwrap());
29
30pub fn is_admonition_start(line: &str) -> bool {
36 if !ADMONITION_MARKER.is_match(line) {
38 return false;
39 }
40
41 let trimmed = line.trim_start();
43 let after_marker = if let Some(stripped) = trimmed.strip_prefix("!!!") {
44 stripped
45 } else if let Some(stripped) = trimmed.strip_prefix("???+") {
46 stripped
47 } else if let Some(stripped) = trimmed.strip_prefix("???") {
48 stripped
49 } else {
50 return false;
51 };
52
53 let after_marker = after_marker.trim_start();
54 if after_marker.is_empty() {
55 return false;
56 }
57
58 let type_part = after_marker.split_whitespace().next().unwrap_or("");
60
61 if !VALID_TYPE.is_match(type_part) {
63 return false;
64 }
65
66 ADMONITION_START.is_match(line)
68}
69
70pub fn is_admonition_marker(line: &str) -> bool {
72 ADMONITION_MARKER.is_match(line)
73}
74
75pub fn get_admonition_indent(line: &str) -> Option<usize> {
77 if ADMONITION_START.is_match(line) {
78 return Some(super::mkdocs_common::get_line_indent(line));
80 }
81 None
82}
83
84pub fn is_admonition_content(line: &str, base_indent: usize) -> bool {
86 let line_indent = super::mkdocs_common::get_line_indent(line);
88
89 if line.trim().is_empty() {
91 return true;
92 }
93
94 line_indent >= base_indent + 4
96}
97
98pub fn is_within_admonition(content: &str, position: usize) -> bool {
101 let lines: Vec<&str> = content.lines().collect();
102 let mut byte_pos = 0;
103 let mut admonition_stack: Vec<usize> = Vec::new();
105
106 for line in lines {
107 let line_end = byte_pos + line.len();
108 let line_indent = super::mkdocs_common::get_line_indent(line);
109
110 if is_admonition_start(line) {
112 let admon_indent = get_admonition_indent(line).unwrap_or(0);
113
114 while let Some(&parent_indent) = admonition_stack.last() {
118 if admon_indent >= parent_indent + 4 {
119 break;
121 }
122 admonition_stack.pop();
124 }
125
126 admonition_stack.push(admon_indent);
128 } else if !admonition_stack.is_empty() && !line.trim().is_empty() {
129 while let Some(&admon_indent) = admonition_stack.last() {
132 if line_indent >= admon_indent + 4 {
133 break;
135 }
136 admonition_stack.pop();
138 }
139 }
140
141 if byte_pos <= position && position <= line_end && !admonition_stack.is_empty() {
143 return true;
144 }
145
146 byte_pos = line_end + 1;
148 }
149
150 false
151}
152
153pub fn get_admonition_range(lines: &[&str], start_line_idx: usize) -> Option<(usize, usize)> {
155 if start_line_idx >= lines.len() {
156 return None;
157 }
158
159 let start_line = lines[start_line_idx];
160 if !is_admonition_start(start_line) {
161 return None;
162 }
163
164 let base_indent = get_admonition_indent(start_line).unwrap_or(0);
165 let mut end_line_idx = start_line_idx;
166
167 for (idx, line) in lines.iter().enumerate().skip(start_line_idx + 1) {
169 if !line.trim().is_empty() && !is_admonition_content(line, base_indent) {
170 break;
171 }
172 end_line_idx = idx;
173 }
174
175 Some((start_line_idx, end_line_idx))
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_admonition_start_detection() {
184 assert!(is_admonition_start("!!! note"));
186 assert!(is_admonition_start("!!! warning \"Custom Title\""));
187 assert!(is_admonition_start("??? tip"));
188 assert!(is_admonition_start("???+ danger \"Expanded\""));
189 assert!(is_admonition_start(" !!! note")); assert!(is_admonition_start("!!! note inline"));
191 assert!(is_admonition_start("!!! note inline end"));
192
193 assert!(!is_admonition_start("!! note")); assert!(!is_admonition_start("!!!")); assert!(!is_admonition_start("Regular text"));
197 assert!(!is_admonition_start("# Heading"));
198 }
199
200 #[test]
201 fn test_admonition_indent() {
202 assert_eq!(get_admonition_indent("!!! note"), Some(0));
203 assert_eq!(get_admonition_indent(" !!! note"), Some(2));
204 assert_eq!(get_admonition_indent(" !!! warning \"Title\""), Some(4));
205 assert_eq!(get_admonition_indent("Regular text"), None);
206 }
207
208 #[test]
209 fn test_admonition_content() {
210 assert!(is_admonition_content(" Content", 0));
212 assert!(is_admonition_content(" More indented", 0));
213 assert!(is_admonition_content("", 0)); assert!(!is_admonition_content("Not indented", 0));
215 assert!(!is_admonition_content(" Only 2 spaces", 0));
216
217 assert!(is_admonition_content(" Content", 4));
219 assert!(!is_admonition_content(" Not enough", 4));
220 }
221
222 #[test]
223 fn test_within_admonition() {
224 let content = r#"# Document
225
226!!! note "Test Note"
227 This is content inside the admonition.
228 More content here.
229
230Regular text outside.
231
232??? warning
233 Collapsible content.
234
235 Still inside.
236
237Not inside anymore."#;
238
239 let inside_pos = content.find("inside the admonition").unwrap();
241 let outside_pos = content.find("Regular text").unwrap();
242 let collapsible_pos = content.find("Collapsible").unwrap();
243 let still_inside_pos = content.find("Still inside").unwrap();
244 let not_inside_pos = content.find("Not inside anymore").unwrap();
245
246 assert!(is_within_admonition(content, inside_pos));
247 assert!(!is_within_admonition(content, outside_pos));
248 assert!(is_within_admonition(content, collapsible_pos));
249 assert!(is_within_admonition(content, still_inside_pos));
250 assert!(!is_within_admonition(content, not_inside_pos));
251 }
252
253 #[test]
254 fn test_nested_admonitions() {
255 let content = r#"!!! note "Outer"
256 Content of outer.
257
258 !!! warning "Inner"
259 Content of inner.
260 More inner content.
261
262 Back to outer.
263
264Outside."#;
265
266 let outer_pos = content.find("Content of outer").unwrap();
267 let inner_pos = content.find("Content of inner").unwrap();
268 let back_outer_pos = content.find("Back to outer").unwrap();
269 let outside_pos = content.find("Outside").unwrap();
270
271 assert!(is_within_admonition(content, outer_pos));
272 assert!(is_within_admonition(content, inner_pos));
273 assert!(is_within_admonition(content, back_outer_pos));
275 assert!(!is_within_admonition(content, outside_pos));
276 }
277
278 #[test]
279 fn test_deeply_nested_admonitions() {
280 let content = r#"!!! note "Level 1"
281 Level 1 content.
282
283 !!! warning "Level 2"
284 Level 2 content.
285
286 !!! tip "Level 3"
287 Level 3 content.
288
289 Back to level 2.
290
291 Back to level 1.
292
293Outside all."#;
294
295 let level1_pos = content.find("Level 1 content").unwrap();
296 let level2_pos = content.find("Level 2 content").unwrap();
297 let level3_pos = content.find("Level 3 content").unwrap();
298 let back_level2_pos = content.find("Back to level 2").unwrap();
299 let back_level1_pos = content.find("Back to level 1").unwrap();
300 let outside_pos = content.find("Outside all").unwrap();
301
302 assert!(
303 is_within_admonition(content, level1_pos),
304 "Level 1 content should be in admonition"
305 );
306 assert!(
307 is_within_admonition(content, level2_pos),
308 "Level 2 content should be in admonition"
309 );
310 assert!(
311 is_within_admonition(content, level3_pos),
312 "Level 3 content should be in admonition"
313 );
314 assert!(
315 is_within_admonition(content, back_level2_pos),
316 "Back to level 2 should be in admonition"
317 );
318 assert!(
319 is_within_admonition(content, back_level1_pos),
320 "Back to level 1 should be in admonition"
321 );
322 assert!(
323 !is_within_admonition(content, outside_pos),
324 "Outside should not be in admonition"
325 );
326 }
327
328 #[test]
329 fn test_sibling_admonitions() {
330 let content = r#"!!! note "First"
331 First content.
332
333!!! warning "Second"
334 Second content.
335
336Outside."#;
337
338 let first_pos = content.find("First content").unwrap();
339 let second_pos = content.find("Second content").unwrap();
340 let outside_pos = content.find("Outside").unwrap();
341
342 assert!(is_within_admonition(content, first_pos));
343 assert!(is_within_admonition(content, second_pos));
344 assert!(!is_within_admonition(content, outside_pos));
345 }
346}