Skip to main content

rumdl_lib/utils/
mkdocs_common.rs

1//! Common utilities and constants for MkDocs pattern detection
2//!
3//! This module provides shared functionality used across all MkDocs feature
4//! detection modules to reduce code duplication and improve maintainability.
5
6/// Standard indentation size for MkDocs content blocks
7/// Most MkDocs features require content to be indented by 4 spaces
8pub const MKDOCS_CONTENT_INDENT: usize = 4;
9
10/// Utility for tracking byte positions through document lines
11/// Reduces duplication of line-by-line byte position tracking logic
12pub struct BytePositionTracker<'a> {
13    pub content: &'a str,
14    pub lines: Vec<&'a str>,
15}
16
17impl<'a> BytePositionTracker<'a> {
18    /// Create a new byte position tracker for the given content
19    pub fn new(content: &'a str) -> Self {
20        Self {
21            content,
22            lines: content.lines().collect(),
23        }
24    }
25
26    /// Iterate through lines with byte position tracking
27    /// Returns an iterator of (line_index, line_content, byte_start, byte_end)
28    pub fn iter_with_positions(&self) -> impl Iterator<Item = (usize, &'a str, usize, usize)> + '_ {
29        let mut byte_pos = 0;
30        self.lines.iter().enumerate().map(move |(idx, line)| {
31            let start = byte_pos;
32            let end = byte_pos + line.len();
33            byte_pos = end + 1; // Account for newline
34            (idx, *line, start, end)
35        })
36    }
37
38    /// Check if a position falls within any line matching the given predicate
39    pub fn is_position_in_matching_lines<F>(&self, position: usize, predicate: F) -> bool
40    where
41        F: Fn(usize, &str) -> bool,
42    {
43        for (idx, line, start, end) in self.iter_with_positions() {
44            if start <= position && position <= end && predicate(idx, line) {
45                return true;
46            }
47        }
48        false
49    }
50}
51
52/// Extract indentation from a line (counts spaces and tabs)
53pub fn get_line_indent(line: &str) -> usize {
54    line.chars()
55        .take_while(|&c| c == ' ' || c == '\t')
56        .map(|c| if c == '\t' { 4 } else { 1 }) // Treat tabs as 4 spaces
57        .sum()
58}
59
60/// State machine for tracking nested context boundaries
61pub struct ContextStateMachine {
62    in_context: bool,
63    context_indent: usize,
64    context_type: Option<String>,
65}
66
67impl ContextStateMachine {
68    pub fn new() -> Self {
69        Self {
70            in_context: false,
71            context_indent: 0,
72            context_type: None,
73        }
74    }
75
76    /// Enter a new context with the given indentation and type
77    pub fn enter_context(&mut self, indent: usize, context_type: String) {
78        self.in_context = true;
79        self.context_indent = indent;
80        self.context_type = Some(context_type);
81    }
82
83    /// Exit the current context
84    pub fn exit_context(&mut self) {
85        self.in_context = false;
86        self.context_indent = 0;
87        self.context_type = None;
88    }
89
90    /// Check if currently in a context
91    pub fn is_in_context(&self) -> bool {
92        self.in_context
93    }
94
95    /// Get the current context indentation
96    pub fn context_indent(&self) -> usize {
97        self.context_indent
98    }
99
100    /// Get the current context type
101    pub fn context_type(&self) -> Option<&str> {
102        self.context_type.as_deref()
103    }
104}
105
106impl Default for ContextStateMachine {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_get_line_indent() {
118        assert_eq!(get_line_indent("no indent"), 0);
119        assert_eq!(get_line_indent("  two spaces"), 2);
120        assert_eq!(get_line_indent("    four spaces"), 4);
121        assert_eq!(get_line_indent("\tone tab"), 4);
122        assert_eq!(get_line_indent("\t\ttwo tabs"), 8);
123        assert_eq!(get_line_indent("  \tmixed"), 6); // 2 spaces + 1 tab
124    }
125
126    #[test]
127    fn test_byte_position_tracker() {
128        let content = "line1\nline2\nline3";
129        let tracker = BytePositionTracker::new(content);
130
131        let positions: Vec<_> = tracker.iter_with_positions().collect();
132        assert_eq!(positions.len(), 3);
133        assert_eq!(positions[0], (0, "line1", 0, 5));
134        assert_eq!(positions[1], (1, "line2", 6, 11));
135        assert_eq!(positions[2], (2, "line3", 12, 17));
136    }
137
138    #[test]
139    fn test_position_in_matching_lines() {
140        let content = "normal\nspecial\nnormal";
141        let tracker = BytePositionTracker::new(content);
142
143        // Position 8 is in "special"
144        assert!(tracker.is_position_in_matching_lines(8, |_, line| line == "special"));
145        // Position 2 is in "normal"
146        assert!(!tracker.is_position_in_matching_lines(2, |_, line| line == "special"));
147    }
148
149    #[test]
150    fn test_context_state_machine() {
151        let mut sm = ContextStateMachine::new();
152        assert!(!sm.is_in_context());
153
154        sm.enter_context(4, "admonition".to_string());
155        assert!(sm.is_in_context());
156        assert_eq!(sm.context_indent(), 4);
157        assert_eq!(sm.context_type(), Some("admonition"));
158
159        sm.exit_context();
160        assert!(!sm.is_in_context());
161        assert_eq!(sm.context_type(), None);
162    }
163}