Skip to main content

rumdl_lib/utils/
mkdocs_tabs.rs

1use super::mkdocs_common::{BytePositionTracker, ContextStateMachine, MKDOCS_CONTENT_INDENT, get_line_indent};
2use regex::Regex;
3/// MkDocs Content Tabs detection utilities
4///
5/// The Tabbed extension provides support for grouped content tabs
6/// using `===` markers for tab labels and content.
7///
8/// Common patterns:
9/// - `=== "Tab 1"` - Tab with label
10/// - `=== Tab` - Tab without quotes
11/// - Content indented with 4 spaces under each tab
12use std::sync::LazyLock;
13
14/// Pattern to match tab markers
15/// Matches: === "Label" or === Label
16/// Lenient: accepts unclosed quotes, escaped quotes within quotes
17static TAB_MARKER: LazyLock<Regex> = LazyLock::new(|| {
18    Regex::new(
19        r"^(\s*)===\s+.*$", // Just need content after ===
20    )
21    .unwrap()
22});
23
24/// Simple pattern to check for any tab marker
25static TAB_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)===\s+").unwrap());
26
27/// Check if a line is a tab marker
28pub fn is_tab_marker(line: &str) -> bool {
29    // First check if it starts like a tab marker
30    let trimmed_start = line.trim_start();
31    if !trimmed_start.starts_with("===") {
32        return false;
33    }
34
35    // Reject double === (like "=== ===")
36    // Check what comes after the first ===
37    let after_marker = &trimmed_start[3..];
38    if after_marker.trim_start().starts_with("===") {
39        return false; // Double === is invalid
40    }
41
42    let trimmed = line.trim();
43
44    // Must have content after ===
45    if trimmed.len() <= 3 || !trimmed.chars().nth(3).is_some_and(char::is_whitespace) {
46        return false;
47    }
48
49    // Be lenient with quote matching to handle real-world markdown
50    // A future rule can warn about unclosed quotes
51    // For now, just ensure there's some content after ===
52
53    // Use the original regex as a final check
54    TAB_MARKER.is_match(line)
55}
56
57/// Check if a line starts a tab section
58pub fn is_tab_start(line: &str) -> bool {
59    TAB_START.is_match(line)
60}
61
62/// Get the indentation level of a tab marker
63pub fn get_tab_indent(line: &str) -> Option<usize> {
64    if TAB_MARKER.is_match(line) {
65        // Use consistent indentation calculation (tabs = 4 spaces)
66        return Some(get_line_indent(line));
67    }
68    None
69}
70
71/// Check if a line is part of tab content (based on indentation)
72pub fn is_tab_content(line: &str, base_indent: usize) -> bool {
73    // Empty lines are not considered content on their own
74    // They're handled separately in context
75    if line.trim().is_empty() {
76        return false;
77    }
78
79    // Content must be indented at least MKDOCS_CONTENT_INDENT spaces from the tab marker
80    get_line_indent(line) >= base_indent + MKDOCS_CONTENT_INDENT
81}
82
83/// Check if content at a byte position is within a tab content area
84pub fn is_within_tab_content(content: &str, position: usize) -> bool {
85    let tracker = BytePositionTracker::new(content);
86    let mut state = ContextStateMachine::new();
87    let mut in_tab_group = false;
88
89    for (_idx, line, start, end) in tracker.iter_with_positions() {
90        // Check if we're starting a new tab
91        if is_tab_marker(line) {
92            // If this is the first tab, we're starting a tab group
93            if !in_tab_group {
94                in_tab_group = true;
95            }
96            let indent = get_tab_indent(line).unwrap_or(0);
97            state.enter_context(indent, "tab".to_string());
98        } else if state.is_in_context() {
99            // Check if we're still in tab content
100            if !line.trim().is_empty() && !is_tab_content(line, state.context_indent()) {
101                // Check if this is another tab at the same level (continues the group)
102                if is_tab_marker(line) && get_tab_indent(line).unwrap_or(0) == state.context_indent() {
103                    // Continue with new tab
104                    let indent = get_tab_indent(line).unwrap_or(0);
105                    state.enter_context(indent, "tab".to_string());
106                } else {
107                    // Non-tab content that's not properly indented ends the tab group
108                    state.exit_context();
109                    in_tab_group = false;
110                }
111            }
112        }
113
114        // Check if the position is within this line and we're in a tab
115        if start <= position && position <= end && state.is_in_context() {
116            return true;
117        }
118    }
119
120    false
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_tab_marker_detection() {
129        assert!(is_tab_marker("=== \"Tab 1\""));
130        assert!(is_tab_marker("=== \"Complex Tab Label\""));
131        assert!(is_tab_marker("=== SimpleTab"));
132        assert!(is_tab_marker("  === \"Indented Tab\""));
133        assert!(!is_tab_marker("== \"Not a tab\""));
134        assert!(!is_tab_marker("==== \"Too many equals\""));
135        assert!(!is_tab_marker("Regular text"));
136    }
137
138    #[test]
139    fn test_tab_indent() {
140        assert_eq!(get_tab_indent("=== \"Tab\""), Some(0));
141        assert_eq!(get_tab_indent("  === \"Tab\""), Some(2));
142        assert_eq!(get_tab_indent("    === \"Tab\""), Some(4));
143        assert_eq!(get_tab_indent("Not a tab"), None);
144    }
145
146    #[test]
147    fn test_tab_content() {
148        // Base indent 0, content must be indented 4+
149        assert!(is_tab_content("    Content", 0));
150        assert!(is_tab_content("        More indented", 0));
151        assert!(!is_tab_content("", 0)); // Empty lines not considered content on their own
152        assert!(!is_tab_content("Not indented", 0));
153        assert!(!is_tab_content("  Only 2 spaces", 0));
154    }
155}