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