rumdl/
inline_config.rs

1//! Inline configuration comment handling for markdownlint compatibility
2//!
3//! Supports:
4//! - `<!-- markdownlint-disable -->` - Disable all rules from this point
5//! - `<!-- markdownlint-enable -->` - Re-enable all rules from this point
6//! - `<!-- markdownlint-disable MD001 MD002 -->` - Disable specific rules
7//! - `<!-- markdownlint-enable MD001 MD002 -->` - Re-enable specific rules
8//! - `<!-- markdownlint-disable-line MD001 -->` - Disable rules for current line
9//! - `<!-- markdownlint-disable-next-line MD001 -->` - Disable rules for next line
10//! - `<!-- markdownlint-capture -->` - Capture current configuration state
11//! - `<!-- markdownlint-restore -->` - Restore captured configuration state
12//!
13//! Also supports rumdl-specific syntax with same semantics.
14
15use std::collections::{HashMap, HashSet};
16
17#[derive(Debug, Clone)]
18pub struct InlineConfig {
19    /// Rules that are disabled at each line (1-indexed line -> set of disabled rules)
20    disabled_at_line: HashMap<usize, HashSet<String>>,
21    /// Rules disabled for specific lines via disable-line (1-indexed)
22    line_disabled_rules: HashMap<usize, HashSet<String>>,
23}
24
25impl Default for InlineConfig {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl InlineConfig {
32    pub fn new() -> Self {
33        Self {
34            disabled_at_line: HashMap::new(),
35            line_disabled_rules: HashMap::new(),
36        }
37    }
38
39    /// Process all inline comments in the content and return the configuration state
40    pub fn from_content(content: &str) -> Self {
41        let mut config = Self::new();
42        let lines: Vec<&str> = content.lines().collect();
43
44        // Track current state of disabled rules
45        let mut currently_disabled = HashSet::new();
46        let mut capture_stack: Vec<HashSet<String>> = Vec::new();
47
48        for (idx, line) in lines.iter().enumerate() {
49            let line_num = idx + 1; // 1-indexed
50
51            // Store the current state for this line BEFORE processing comments
52            // This way, comments on a line don't affect that same line
53            config.disabled_at_line.insert(line_num, currently_disabled.clone());
54
55            // Process comments in order of specificity to avoid conflicts
56
57            // Check for disable-next-line first (more specific than disable)
58            if let Some(rules) = parse_disable_next_line_comment(line) {
59                let next_line = line_num + 1;
60                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
61                if rules.is_empty() {
62                    // Disable all rules for next line
63                    line_rules.insert("*".to_string());
64                } else {
65                    for rule in rules {
66                        line_rules.insert(rule.to_string());
67                    }
68                }
69            }
70            // Check for disable-line (more specific than disable)
71            else if let Some(rules) = parse_disable_line_comment(line) {
72                let line_rules = config.line_disabled_rules.entry(line_num).or_default();
73                if rules.is_empty() {
74                    // Disable all rules for current line
75                    line_rules.insert("*".to_string());
76                } else {
77                    for rule in rules {
78                        line_rules.insert(rule.to_string());
79                    }
80                }
81            }
82            // Check for capture
83            else if is_capture_comment(line) {
84                capture_stack.push(currently_disabled.clone());
85            }
86            // Check for restore
87            else if is_restore_comment(line) {
88                if let Some(captured) = capture_stack.pop() {
89                    currently_disabled = captured;
90                }
91            }
92            // Check for disable (persistent)
93            else if let Some(rules) = parse_disable_comment(line) {
94                if rules.is_empty() {
95                    // Disable all rules - we'll use "*" as a marker
96                    currently_disabled.clear();
97                    currently_disabled.insert("*".to_string());
98                } else {
99                    for rule in rules {
100                        currently_disabled.insert(rule.to_string());
101                    }
102                }
103            }
104            // Check for enable (persistent)
105            else if let Some(rules) = parse_enable_comment(line) {
106                if rules.is_empty() {
107                    // Enable all rules
108                    currently_disabled.clear();
109                } else {
110                    // Enable specific rules
111                    for rule in rules {
112                        currently_disabled.remove(rule);
113                    }
114                }
115            }
116        }
117
118        config
119    }
120
121    /// Check if a rule is disabled at a specific line
122    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
123        // Check line-specific disables first (disable-line, disable-next-line)
124        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
125            if line_rules.contains("*") || line_rules.contains(rule_name) {
126                return true;
127            }
128        }
129
130        // Check persistent disables at this line
131        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
132            disabled_set.contains("*") || disabled_set.contains(rule_name)
133        } else {
134            false
135        }
136    }
137
138    /// Get all disabled rules at a specific line
139    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
140        let mut disabled = HashSet::new();
141
142        // Add persistent disables
143        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
144            for rule in disabled_set {
145                disabled.insert(rule.clone());
146            }
147        }
148
149        // Add line-specific disables
150        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
151            for rule in line_rules {
152                disabled.insert(rule.clone());
153            }
154        }
155
156        disabled
157    }
158}
159
160/// Parse a disable comment and return the list of rules (empty vec means all rules)
161pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
162    // Check for both rumdl-disable and markdownlint-disable
163    for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
164        if let Some(start) = line.find(prefix) {
165            let after_prefix = &line[start + prefix.len()..];
166
167            // Global disable: <!-- markdownlint-disable -->
168            if after_prefix.trim_start().starts_with("-->") {
169                return Some(Vec::new()); // Empty vec means all rules
170            }
171
172            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
173            if let Some(end) = after_prefix.find("-->") {
174                let rules_str = after_prefix[..end].trim();
175                if !rules_str.is_empty() {
176                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
177                    return Some(rules);
178                }
179            }
180        }
181    }
182
183    None
184}
185
186/// Parse an enable comment and return the list of rules (empty vec means all rules)
187pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
188    // Check for both rumdl-enable and markdownlint-enable
189    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
190        if let Some(start) = line.find(prefix) {
191            let after_prefix = &line[start + prefix.len()..];
192
193            // Global enable: <!-- markdownlint-enable -->
194            if after_prefix.trim_start().starts_with("-->") {
195                return Some(Vec::new()); // Empty vec means all rules
196            }
197
198            // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
199            if let Some(end) = after_prefix.find("-->") {
200                let rules_str = after_prefix[..end].trim();
201                if !rules_str.is_empty() {
202                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
203                    return Some(rules);
204                }
205            }
206        }
207    }
208
209    None
210}
211
212/// Parse a disable-line comment
213pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
214    // Check for both rumdl and markdownlint variants
215    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
216        if let Some(start) = line.find(prefix) {
217            let after_prefix = &line[start + prefix.len()..];
218
219            // Global disable-line: <!-- markdownlint-disable-line -->
220            if after_prefix.trim_start().starts_with("-->") {
221                return Some(Vec::new()); // Empty vec means all rules
222            }
223
224            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
225            if let Some(end) = after_prefix.find("-->") {
226                let rules_str = after_prefix[..end].trim();
227                if !rules_str.is_empty() {
228                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
229                    return Some(rules);
230                }
231            }
232        }
233    }
234
235    None
236}
237
238/// Parse a disable-next-line comment
239pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
240    // Check for both rumdl and markdownlint variants
241    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
242        if let Some(start) = line.find(prefix) {
243            let after_prefix = &line[start + prefix.len()..];
244
245            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
246            if after_prefix.trim_start().starts_with("-->") {
247                return Some(Vec::new()); // Empty vec means all rules
248            }
249
250            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
251            if let Some(end) = after_prefix.find("-->") {
252                let rules_str = after_prefix[..end].trim();
253                if !rules_str.is_empty() {
254                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
255                    return Some(rules);
256                }
257            }
258        }
259    }
260
261    None
262}
263
264/// Check if line contains a capture comment
265pub fn is_capture_comment(line: &str) -> bool {
266    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
267}
268
269/// Check if line contains a restore comment
270pub fn is_restore_comment(line: &str) -> bool {
271    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_parse_disable_comment() {
280        // Global disable
281        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
282        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
283
284        // Specific rules
285        assert_eq!(
286            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
287            Some(vec!["MD001", "MD002"])
288        );
289
290        // No comment
291        assert_eq!(parse_disable_comment("Some regular text"), None);
292    }
293
294    #[test]
295    fn test_parse_disable_line_comment() {
296        // Global disable-line
297        assert_eq!(
298            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
299            Some(vec![])
300        );
301
302        // Specific rules
303        assert_eq!(
304            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
305            Some(vec!["MD013"])
306        );
307
308        // No comment
309        assert_eq!(parse_disable_line_comment("Some regular text"), None);
310    }
311
312    #[test]
313    fn test_inline_config_from_content() {
314        let content = r#"# Test Document
315
316<!-- markdownlint-disable MD013 -->
317This is a very long line that would normally trigger MD013 but it's disabled
318
319<!-- markdownlint-enable MD013 -->
320This line will be checked again
321
322<!-- markdownlint-disable-next-line MD001 -->
323# This heading will not be checked for MD001
324## But this one will
325
326Some text <!-- markdownlint-disable-line MD013 -->
327
328<!-- markdownlint-capture -->
329<!-- markdownlint-disable MD001 MD002 -->
330# Heading with MD001 disabled
331<!-- markdownlint-restore -->
332# Heading with MD001 enabled again
333"#;
334
335        let config = InlineConfig::from_content(content);
336
337        // Line 4 should have MD013 disabled (line after disable comment on line 3)
338        assert!(config.is_rule_disabled("MD013", 4));
339
340        // Line 7 should have MD013 enabled (line after enable comment on line 6)
341        assert!(!config.is_rule_disabled("MD013", 7));
342
343        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
344        assert!(config.is_rule_disabled("MD001", 10));
345
346        // Line 11 should not have MD001 disabled
347        assert!(!config.is_rule_disabled("MD001", 11));
348
349        // Line 13 should have MD013 disabled (from disable-line)
350        assert!(config.is_rule_disabled("MD013", 13));
351
352        // After restore (line 18), MD001 should be enabled again on line 19
353        assert!(!config.is_rule_disabled("MD001", 19));
354    }
355
356    #[test]
357    fn test_capture_restore() {
358        let content = r#"<!-- markdownlint-disable MD001 -->
359<!-- markdownlint-capture -->
360<!-- markdownlint-disable MD002 MD003 -->
361<!-- markdownlint-restore -->
362Some content after restore
363"#;
364
365        let config = InlineConfig::from_content(content);
366
367        // After restore (line 4), line 5 should only have MD001 disabled
368        assert!(config.is_rule_disabled("MD001", 5));
369        assert!(!config.is_rule_disabled("MD002", 5));
370        assert!(!config.is_rule_disabled("MD003", 5));
371    }
372}