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#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_admonition_start_detection() {
159        // Valid admonition starts
160        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")); // Indented
165        assert!(is_admonition_start("!!! note inline"));
166        assert!(is_admonition_start("!!! note inline end"));
167
168        // Invalid patterns
169        assert!(!is_admonition_start("!! note")); // Wrong number of !
170        assert!(!is_admonition_start("!!!")); // No type
171        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        // Base indent 0, content must be indented 4+
186        assert!(is_admonition_content("    Content", 0));
187        assert!(is_admonition_content("        More indented", 0));
188        assert!(is_admonition_content("", 0)); // Empty lines allowed
189        assert!(!is_admonition_content("Not indented", 0));
190        assert!(!is_admonition_content("  Only 2 spaces", 0));
191
192        // Base indent 4, content must be indented 8+
193        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        // Find positions
215        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        // Stack-based approach properly handles returning to outer admonition
249        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}