Skip to main content

rumdl_lib/utils/
mkdocs_admonitions.rs

1use regex::Regex;
2/// MkDocs Admonitions detection utilities
3///
4/// The Admonitions extension provides specially-styled content blocks for
5/// notes, warnings, tips, and other callouts using `!!!` and `???` markers.
6///
7/// Common patterns:
8/// - `!!! note "Title"` - Standard admonition
9/// - `??? warning "Title"` - Collapsible admonition (closed by default)
10/// - `???+ tip "Title"` - Collapsible admonition (open by default)
11/// - `!!! note` - Admonition without title (uses type as title)
12/// - `!!! type inline` - Inline admonition (left-aligned)
13/// - `!!! type inline end` - Inline admonition (right-aligned)
14use std::sync::LazyLock;
15
16/// Pattern to match admonition start markers
17/// Matches: !!! type, ??? type, ???+ type, with optional "title" and modifiers
18/// Type must be alphanumeric with optional dashes/underscores (no special chars)
19/// Lenient: accepts unclosed quotes for real-world markdown handling
20static 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
24/// Pattern to match just the admonition marker without capturing groups
25static ADMONITION_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(?:!!!|\?\?\?\+?)\s+").unwrap());
26
27/// Pattern to validate admonition type characters
28static VALID_TYPE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]*$").unwrap());
29
30// Common admonition types recognized by MkDocs
31// Note: Any word is valid as a custom type, so this list is informational
32// Types: note, abstract, info, tip, success, question, warning, failure, danger, bug, example, quote
33
34/// Check if a line is an admonition start marker
35pub fn is_admonition_start(line: &str) -> bool {
36    // First check with the basic marker
37    if !ADMONITION_MARKER.is_match(line) {
38        return false;
39    }
40
41    // Extract and validate the type
42    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    // Extract the type (first word)
59    let type_part = after_marker.split_whitespace().next().unwrap_or("");
60
61    // Validate the type contains only allowed characters
62    if !VALID_TYPE.is_match(type_part) {
63        return false;
64    }
65
66    // Final check with the full regex
67    ADMONITION_START.is_match(line)
68}
69
70/// Check if a line contains any admonition marker
71pub fn is_admonition_marker(line: &str) -> bool {
72    ADMONITION_MARKER.is_match(line)
73}
74
75/// Extract the indentation level of an admonition (for tracking nested content)
76pub fn get_admonition_indent(line: &str) -> Option<usize> {
77    if ADMONITION_START.is_match(line) {
78        // Use consistent indentation calculation (tabs = 4 spaces)
79        return Some(super::mkdocs_common::get_line_indent(line));
80    }
81    None
82}
83
84/// Check if a line is part of admonition content (based on indentation)
85pub fn is_admonition_content(line: &str, base_indent: usize) -> bool {
86    // Admonition content must be indented at least 4 spaces more than the marker
87    let line_indent = super::mkdocs_common::get_line_indent(line);
88
89    // Empty lines within admonitions are allowed
90    if line.trim().is_empty() {
91        return true;
92    }
93
94    // Content must be indented at least 4 spaces from the admonition marker
95    line_indent >= base_indent + 4
96}
97
98/// Check if content at a byte position is within an admonition block
99/// Uses a stack-based approach to properly handle nested admonitions.
100pub fn is_within_admonition(content: &str, position: usize) -> bool {
101    let lines: Vec<&str> = content.lines().collect();
102    let mut byte_pos = 0;
103    // Stack of admonition indent levels (supports nesting)
104    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        // Check if we're starting a new admonition
111        if is_admonition_start(line) {
112            let admon_indent = get_admonition_indent(line).unwrap_or(0);
113
114            // Pop any outer admonitions that this one is not nested within.
115            // An admonition is nested within a parent if its marker appears in
116            // the parent's content area (indented >= parent_indent + 4)
117            while let Some(&parent_indent) = admonition_stack.last() {
118                if admon_indent >= parent_indent + 4 {
119                    // This admonition is nested inside the parent's content
120                    break;
121                }
122                // Not nested within this parent, pop it
123                admonition_stack.pop();
124            }
125
126            // Push this admonition onto the stack
127            admonition_stack.push(admon_indent);
128        } else if !admonition_stack.is_empty() && !line.trim().is_empty() {
129            // Non-empty line - check if we're still within any admonition
130            // Pop admonitions whose content indent requirement is not met
131            while let Some(&admon_indent) = admonition_stack.last() {
132                if line_indent >= admon_indent + 4 {
133                    // Content is properly indented for this admonition
134                    break;
135                }
136                // Content not indented enough, exit this admonition
137                admonition_stack.pop();
138            }
139        }
140
141        // Check if the position is within this line and we're in any admonition
142        if byte_pos <= position && position <= line_end && !admonition_stack.is_empty() {
143            return true;
144        }
145
146        // Account for newline character
147        byte_pos = line_end + 1;
148    }
149
150    false
151}
152
153/// Get the range of an admonition block starting at the given line index
154pub 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    // Find where the admonition ends
168    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        // Valid admonition starts
185        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")); // Indented
190        assert!(is_admonition_start("!!! note inline"));
191        assert!(is_admonition_start("!!! note inline end"));
192
193        // Invalid patterns
194        assert!(!is_admonition_start("!! note")); // Wrong number of !
195        assert!(!is_admonition_start("!!!")); // No type
196        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        // Base indent 0, content must be indented 4+
211        assert!(is_admonition_content("    Content", 0));
212        assert!(is_admonition_content("        More indented", 0));
213        assert!(is_admonition_content("", 0)); // Empty lines allowed
214        assert!(!is_admonition_content("Not indented", 0));
215        assert!(!is_admonition_content("  Only 2 spaces", 0));
216
217        // Base indent 4, content must be indented 8+
218        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        // Find positions
240        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        // Stack-based approach properly handles returning to outer admonition
274        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}