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
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_admonition_start_detection() {
159 assert!(is_admonition_start("!!! note"));
161 assert!(is_admonition_start("!!! warning \"Custom Title\""));
162 assert!(is_admonition_start("??? tip"));
163 assert!(is_admonition_start("???+ danger \"Expanded\""));
164 assert!(is_admonition_start(" !!! note")); assert!(is_admonition_start("!!! note inline"));
166 assert!(is_admonition_start("!!! note inline end"));
167
168 assert!(!is_admonition_start("!! note")); assert!(!is_admonition_start("!!!")); assert!(!is_admonition_start("Regular text"));
172 assert!(!is_admonition_start("# Heading"));
173 }
174
175 #[test]
176 fn test_admonition_indent() {
177 assert_eq!(get_admonition_indent("!!! note"), Some(0));
178 assert_eq!(get_admonition_indent(" !!! note"), Some(2));
179 assert_eq!(get_admonition_indent(" !!! warning \"Title\""), Some(4));
180 assert_eq!(get_admonition_indent("Regular text"), None);
181 }
182
183 #[test]
184 fn test_admonition_content() {
185 assert!(is_admonition_content(" Content", 0));
187 assert!(is_admonition_content(" More indented", 0));
188 assert!(is_admonition_content("", 0)); assert!(!is_admonition_content("Not indented", 0));
190 assert!(!is_admonition_content(" Only 2 spaces", 0));
191
192 assert!(is_admonition_content(" Content", 4));
194 assert!(!is_admonition_content(" Not enough", 4));
195 }
196
197 #[test]
198 fn test_within_admonition() {
199 let content = r#"# Document
200
201!!! note "Test Note"
202 This is content inside the admonition.
203 More content here.
204
205Regular text outside.
206
207??? warning
208 Collapsible content.
209
210 Still inside.
211
212Not inside anymore."#;
213
214 let inside_pos = content.find("inside the admonition").unwrap();
216 let outside_pos = content.find("Regular text").unwrap();
217 let collapsible_pos = content.find("Collapsible").unwrap();
218 let still_inside_pos = content.find("Still inside").unwrap();
219 let not_inside_pos = content.find("Not inside anymore").unwrap();
220
221 assert!(is_within_admonition(content, inside_pos));
222 assert!(!is_within_admonition(content, outside_pos));
223 assert!(is_within_admonition(content, collapsible_pos));
224 assert!(is_within_admonition(content, still_inside_pos));
225 assert!(!is_within_admonition(content, not_inside_pos));
226 }
227
228 #[test]
229 fn test_nested_admonitions() {
230 let content = r#"!!! note "Outer"
231 Content of outer.
232
233 !!! warning "Inner"
234 Content of inner.
235 More inner content.
236
237 Back to outer.
238
239Outside."#;
240
241 let outer_pos = content.find("Content of outer").unwrap();
242 let inner_pos = content.find("Content of inner").unwrap();
243 let back_outer_pos = content.find("Back to outer").unwrap();
244 let outside_pos = content.find("Outside").unwrap();
245
246 assert!(is_within_admonition(content, outer_pos));
247 assert!(is_within_admonition(content, inner_pos));
248 assert!(is_within_admonition(content, back_outer_pos));
250 assert!(!is_within_admonition(content, outside_pos));
251 }
252
253 #[test]
254 fn test_deeply_nested_admonitions() {
255 let content = r#"!!! note "Level 1"
256 Level 1 content.
257
258 !!! warning "Level 2"
259 Level 2 content.
260
261 !!! tip "Level 3"
262 Level 3 content.
263
264 Back to level 2.
265
266 Back to level 1.
267
268Outside all."#;
269
270 let level1_pos = content.find("Level 1 content").unwrap();
271 let level2_pos = content.find("Level 2 content").unwrap();
272 let level3_pos = content.find("Level 3 content").unwrap();
273 let back_level2_pos = content.find("Back to level 2").unwrap();
274 let back_level1_pos = content.find("Back to level 1").unwrap();
275 let outside_pos = content.find("Outside all").unwrap();
276
277 assert!(
278 is_within_admonition(content, level1_pos),
279 "Level 1 content should be in admonition"
280 );
281 assert!(
282 is_within_admonition(content, level2_pos),
283 "Level 2 content should be in admonition"
284 );
285 assert!(
286 is_within_admonition(content, level3_pos),
287 "Level 3 content should be in admonition"
288 );
289 assert!(
290 is_within_admonition(content, back_level2_pos),
291 "Back to level 2 should be in admonition"
292 );
293 assert!(
294 is_within_admonition(content, back_level1_pos),
295 "Back to level 1 should be in admonition"
296 );
297 assert!(
298 !is_within_admonition(content, outside_pos),
299 "Outside should not be in admonition"
300 );
301 }
302
303 #[test]
304 fn test_sibling_admonitions() {
305 let content = r#"!!! note "First"
306 First content.
307
308!!! warning "Second"
309 Second content.
310
311Outside."#;
312
313 let first_pos = content.find("First content").unwrap();
314 let second_pos = content.find("Second content").unwrap();
315 let outside_pos = content.find("Outside").unwrap();
316
317 assert!(is_within_admonition(content, first_pos));
318 assert!(is_within_admonition(content, second_pos));
319 assert!(!is_within_admonition(content, outside_pos));
320 }
321}