rumdl_lib/
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//! - `<!-- markdownlint-disable-file -->` - Disable all rules for entire file
13//! - `<!-- markdownlint-enable-file -->` - Re-enable all rules for entire file
14//! - `<!-- markdownlint-disable-file MD001 MD002 -->` - Disable specific rules for entire file
15//! - `<!-- markdownlint-enable-file MD001 MD002 -->` - Re-enable specific rules for entire file
16//! - `<!-- markdownlint-configure-file { "MD013": { "line_length": 120 } } -->` - Configure rules for entire file
17//!
18//! Also supports rumdl-specific syntax with same semantics.
19
20use crate::utils::code_block_utils::CodeBlockUtils;
21use serde_json::Value as JsonValue;
22use std::collections::{HashMap, HashSet};
23
24#[derive(Debug, Clone)]
25pub struct InlineConfig {
26    /// Rules that are disabled at each line (1-indexed line -> set of disabled rules)
27    disabled_at_line: HashMap<usize, HashSet<String>>,
28    /// Rules that are explicitly enabled when all rules are disabled (1-indexed line -> set of enabled rules)
29    /// Only used when "*" is in disabled_at_line
30    enabled_at_line: HashMap<usize, HashSet<String>>,
31    /// Rules disabled for specific lines via disable-line (1-indexed)
32    line_disabled_rules: HashMap<usize, HashSet<String>>,
33    /// Rules disabled for the entire file
34    file_disabled_rules: HashSet<String>,
35    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
36    file_enabled_rules: HashSet<String>,
37    /// Configuration overrides for specific rules from configure-file comments
38    /// Maps rule name to configuration JSON value
39    file_rule_config: HashMap<String, JsonValue>,
40}
41
42impl Default for InlineConfig {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl InlineConfig {
49    pub fn new() -> Self {
50        Self {
51            disabled_at_line: HashMap::new(),
52            enabled_at_line: HashMap::new(),
53            line_disabled_rules: HashMap::new(),
54            file_disabled_rules: HashSet::new(),
55            file_enabled_rules: HashSet::new(),
56            file_rule_config: HashMap::new(),
57        }
58    }
59
60    /// Process all inline comments in the content and return the configuration state
61    pub fn from_content(content: &str) -> Self {
62        let mut config = Self::new();
63        let lines: Vec<&str> = content.lines().collect();
64
65        // Detect code blocks to skip comments within them
66        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
67
68        // Pre-compute line positions for checking if a line is in a code block
69        let mut line_positions = Vec::with_capacity(lines.len());
70        let mut pos = 0;
71        for line in &lines {
72            line_positions.push(pos);
73            pos += line.len() + 1; // +1 for newline
74        }
75
76        // Track current state of disabled rules
77        let mut currently_disabled = HashSet::new();
78        let mut currently_enabled = HashSet::new(); // For when all rules are disabled
79        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
80
81        for (idx, line) in lines.iter().enumerate() {
82            let line_num = idx + 1; // 1-indexed
83
84            // Store the current state for this line BEFORE processing comments
85            // This way, comments on a line don't affect that same line
86            config.disabled_at_line.insert(line_num, currently_disabled.clone());
87            config.enabled_at_line.insert(line_num, currently_enabled.clone());
88
89            // Skip processing if this line is inside a code block
90            let line_start = line_positions[idx];
91            let line_end = line_start + line.len();
92            let in_code_block = code_blocks
93                .iter()
94                .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
95
96            if in_code_block {
97                continue;
98            }
99
100            // Process file-wide comments first as they affect the entire file
101            // Check for disable-file
102            if let Some(rules) = parse_disable_file_comment(line) {
103                if rules.is_empty() {
104                    // Disable all rules for entire file
105                    config.file_disabled_rules.clear();
106                    config.file_disabled_rules.insert("*".to_string());
107                } else {
108                    // Disable specific rules for entire file
109                    if config.file_disabled_rules.contains("*") {
110                        // All rules are disabled, so remove from enabled list
111                        for rule in rules {
112                            config.file_enabled_rules.remove(rule);
113                        }
114                    } else {
115                        // Normal case: add to disabled list
116                        for rule in rules {
117                            config.file_disabled_rules.insert(rule.to_string());
118                        }
119                    }
120                }
121            }
122
123            // Check for enable-file
124            if let Some(rules) = parse_enable_file_comment(line) {
125                if rules.is_empty() {
126                    // Enable all rules for entire file
127                    config.file_disabled_rules.clear();
128                    config.file_enabled_rules.clear();
129                } else {
130                    // Enable specific rules for entire file
131                    if config.file_disabled_rules.contains("*") {
132                        // All rules are disabled, so add to enabled list
133                        for rule in rules {
134                            config.file_enabled_rules.insert(rule.to_string());
135                        }
136                    } else {
137                        // Normal case: remove from disabled list
138                        for rule in rules {
139                            config.file_disabled_rules.remove(rule);
140                        }
141                    }
142                }
143            }
144
145            // Check for configure-file
146            if let Some(json_config) = parse_configure_file_comment(line) {
147                // Process the JSON configuration
148                if let Some(obj) = json_config.as_object() {
149                    for (rule_name, rule_config) in obj {
150                        config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
151                    }
152                }
153            }
154
155            // Process comments - handle multiple comment types on same line
156            // Process line-specific comments first (they don't affect state)
157
158            // Check for disable-next-line
159            if let Some(rules) = parse_disable_next_line_comment(line) {
160                let next_line = line_num + 1;
161                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
162                if rules.is_empty() {
163                    // Disable all rules for next line
164                    line_rules.insert("*".to_string());
165                } else {
166                    for rule in rules {
167                        line_rules.insert(rule.to_string());
168                    }
169                }
170            }
171
172            // Check for disable-line
173            if let Some(rules) = parse_disable_line_comment(line) {
174                let line_rules = config.line_disabled_rules.entry(line_num).or_default();
175                if rules.is_empty() {
176                    // Disable all rules for current line
177                    line_rules.insert("*".to_string());
178                } else {
179                    for rule in rules {
180                        line_rules.insert(rule.to_string());
181                    }
182                }
183            }
184
185            // Process state-changing comments in the order they appear
186            // This handles multiple comments on the same line correctly
187            let mut processed_capture = false;
188            let mut processed_restore = false;
189
190            // Find all comments on this line and process them in order
191            let mut comment_positions = Vec::new();
192
193            if let Some(pos) = line.find("<!-- markdownlint-disable")
194                && !line[pos..].contains("<!-- markdownlint-disable-line")
195                && !line[pos..].contains("<!-- markdownlint-disable-next-line")
196            {
197                comment_positions.push((pos, "disable"));
198            }
199            if let Some(pos) = line.find("<!-- rumdl-disable")
200                && !line[pos..].contains("<!-- rumdl-disable-line")
201                && !line[pos..].contains("<!-- rumdl-disable-next-line")
202            {
203                comment_positions.push((pos, "disable"));
204            }
205
206            if let Some(pos) = line.find("<!-- markdownlint-enable") {
207                comment_positions.push((pos, "enable"));
208            }
209            if let Some(pos) = line.find("<!-- rumdl-enable") {
210                comment_positions.push((pos, "enable"));
211            }
212
213            if let Some(pos) = line.find("<!-- markdownlint-capture") {
214                comment_positions.push((pos, "capture"));
215            }
216            if let Some(pos) = line.find("<!-- rumdl-capture") {
217                comment_positions.push((pos, "capture"));
218            }
219
220            if let Some(pos) = line.find("<!-- markdownlint-restore") {
221                comment_positions.push((pos, "restore"));
222            }
223            if let Some(pos) = line.find("<!-- rumdl-restore") {
224                comment_positions.push((pos, "restore"));
225            }
226
227            // Sort by position to process in order
228            comment_positions.sort_by_key(|&(pos, _)| pos);
229
230            // Process each comment in order
231            for (_, comment_type) in comment_positions {
232                match comment_type {
233                    "disable" => {
234                        if let Some(rules) = parse_disable_comment(line) {
235                            if rules.is_empty() {
236                                // Disable all rules
237                                currently_disabled.clear();
238                                currently_disabled.insert("*".to_string());
239                                currently_enabled.clear(); // Reset enabled list
240                            } else {
241                                // Disable specific rules
242                                if currently_disabled.contains("*") {
243                                    // All rules are disabled, so remove from enabled list
244                                    for rule in rules {
245                                        currently_enabled.remove(rule);
246                                    }
247                                } else {
248                                    // Normal case: add to disabled list
249                                    for rule in rules {
250                                        currently_disabled.insert(rule.to_string());
251                                    }
252                                }
253                            }
254                        }
255                    }
256                    "enable" => {
257                        if let Some(rules) = parse_enable_comment(line) {
258                            if rules.is_empty() {
259                                // Enable all rules
260                                currently_disabled.clear();
261                                currently_enabled.clear();
262                            } else {
263                                // Enable specific rules
264                                if currently_disabled.contains("*") {
265                                    // All rules are disabled, so add to enabled list
266                                    for rule in rules {
267                                        currently_enabled.insert(rule.to_string());
268                                    }
269                                } else {
270                                    // Normal case: remove from disabled list
271                                    for rule in rules {
272                                        currently_disabled.remove(rule);
273                                    }
274                                }
275                            }
276                        }
277                    }
278                    "capture" => {
279                        if !processed_capture && is_capture_comment(line) {
280                            capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
281                            processed_capture = true;
282                        }
283                    }
284                    "restore" => {
285                        if !processed_restore && is_restore_comment(line) {
286                            if let Some((disabled, enabled)) = capture_stack.pop() {
287                                currently_disabled = disabled;
288                                currently_enabled = enabled;
289                            }
290                            processed_restore = true;
291                        }
292                    }
293                    _ => {}
294                }
295            }
296        }
297
298        config
299    }
300
301    /// Check if a rule is disabled at a specific line
302    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
303        // Check file-wide disables first (highest priority)
304        if self.file_disabled_rules.contains("*") {
305            // All rules are disabled for the file, check if this rule is explicitly enabled
306            return !self.file_enabled_rules.contains(rule_name);
307        } else if self.file_disabled_rules.contains(rule_name) {
308            return true;
309        }
310
311        // Check line-specific disables (disable-line, disable-next-line)
312        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
313            && (line_rules.contains("*") || line_rules.contains(rule_name))
314        {
315            return true;
316        }
317
318        // Check persistent disables at this line
319        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
320            if disabled_set.contains("*") {
321                // All rules are disabled, check if this rule is explicitly enabled
322                if let Some(enabled_set) = self.enabled_at_line.get(&line_number) {
323                    return !enabled_set.contains(rule_name);
324                }
325                return true; // All disabled and not explicitly enabled
326            } else {
327                return disabled_set.contains(rule_name);
328            }
329        }
330
331        false
332    }
333
334    /// Get all disabled rules at a specific line
335    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
336        let mut disabled = HashSet::new();
337
338        // Add persistent disables
339        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
340            if disabled_set.contains("*") {
341                // All rules are disabled except those explicitly enabled
342                disabled.insert("*".to_string());
343                // We could subtract enabled rules here, but that would require knowing all rules
344                // For now, we'll just return "*" to indicate all rules are disabled
345            } else {
346                for rule in disabled_set {
347                    disabled.insert(rule.clone());
348                }
349            }
350        }
351
352        // Add line-specific disables
353        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
354            for rule in line_rules {
355                disabled.insert(rule.clone());
356            }
357        }
358
359        disabled
360    }
361
362    /// Get configuration overrides for a specific rule from configure-file comments
363    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
364        self.file_rule_config.get(rule_name)
365    }
366
367    /// Get all configuration overrides from configure-file comments
368    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
369        &self.file_rule_config
370    }
371}
372
373/// Parse a disable comment and return the list of rules (empty vec means all rules)
374pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
375    // Check for both rumdl-disable and markdownlint-disable
376    for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
377        if let Some(start) = line.find(prefix) {
378            let after_prefix = &line[start + prefix.len()..];
379
380            // Global disable: <!-- markdownlint-disable -->
381            if after_prefix.trim_start().starts_with("-->") {
382                return Some(Vec::new()); // Empty vec means all rules
383            }
384
385            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
386            if let Some(end) = after_prefix.find("-->") {
387                let rules_str = after_prefix[..end].trim();
388                if !rules_str.is_empty() {
389                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
390                    return Some(rules);
391                }
392            }
393        }
394    }
395
396    None
397}
398
399/// Parse an enable comment and return the list of rules (empty vec means all rules)
400pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
401    // Check for both rumdl-enable and markdownlint-enable
402    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
403        if let Some(start) = line.find(prefix) {
404            let after_prefix = &line[start + prefix.len()..];
405
406            // Global enable: <!-- markdownlint-enable -->
407            if after_prefix.trim_start().starts_with("-->") {
408                return Some(Vec::new()); // Empty vec means all rules
409            }
410
411            // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
412            if let Some(end) = after_prefix.find("-->") {
413                let rules_str = after_prefix[..end].trim();
414                if !rules_str.is_empty() {
415                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
416                    return Some(rules);
417                }
418            }
419        }
420    }
421
422    None
423}
424
425/// Parse a disable-line comment
426pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
427    // Check for both rumdl and markdownlint variants
428    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
429        if let Some(start) = line.find(prefix) {
430            let after_prefix = &line[start + prefix.len()..];
431
432            // Global disable-line: <!-- markdownlint-disable-line -->
433            if after_prefix.trim_start().starts_with("-->") {
434                return Some(Vec::new()); // Empty vec means all rules
435            }
436
437            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
438            if let Some(end) = after_prefix.find("-->") {
439                let rules_str = after_prefix[..end].trim();
440                if !rules_str.is_empty() {
441                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
442                    return Some(rules);
443                }
444            }
445        }
446    }
447
448    None
449}
450
451/// Parse a disable-next-line comment
452pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
453    // Check for both rumdl and markdownlint variants
454    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
455        if let Some(start) = line.find(prefix) {
456            let after_prefix = &line[start + prefix.len()..];
457
458            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
459            if after_prefix.trim_start().starts_with("-->") {
460                return Some(Vec::new()); // Empty vec means all rules
461            }
462
463            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
464            if let Some(end) = after_prefix.find("-->") {
465                let rules_str = after_prefix[..end].trim();
466                if !rules_str.is_empty() {
467                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
468                    return Some(rules);
469                }
470            }
471        }
472    }
473
474    None
475}
476
477/// Check if line contains a capture comment
478pub fn is_capture_comment(line: &str) -> bool {
479    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
480}
481
482/// Check if line contains a restore comment
483pub fn is_restore_comment(line: &str) -> bool {
484    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
485}
486
487/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
488pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
489    // Check for both rumdl and markdownlint variants
490    for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
491        if let Some(start) = line.find(prefix) {
492            let after_prefix = &line[start + prefix.len()..];
493
494            // Global disable-file: <!-- markdownlint-disable-file -->
495            if after_prefix.trim_start().starts_with("-->") {
496                return Some(Vec::new()); // Empty vec means all rules
497            }
498
499            // Rule-specific disable-file: <!-- markdownlint-disable-file MD001 MD002 -->
500            if let Some(end) = after_prefix.find("-->") {
501                let rules_str = after_prefix[..end].trim();
502                if !rules_str.is_empty() {
503                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
504                    return Some(rules);
505                }
506            }
507        }
508    }
509
510    None
511}
512
513/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
514pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
515    // Check for both rumdl and markdownlint variants
516    for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
517        if let Some(start) = line.find(prefix) {
518            let after_prefix = &line[start + prefix.len()..];
519
520            // Global enable-file: <!-- markdownlint-enable-file -->
521            if after_prefix.trim_start().starts_with("-->") {
522                return Some(Vec::new()); // Empty vec means all rules
523            }
524
525            // Rule-specific enable-file: <!-- markdownlint-enable-file MD001 MD002 -->
526            if let Some(end) = after_prefix.find("-->") {
527                let rules_str = after_prefix[..end].trim();
528                if !rules_str.is_empty() {
529                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
530                    return Some(rules);
531                }
532            }
533        }
534    }
535
536    None
537}
538
539/// Parse a configure-file comment and return the JSON configuration
540pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
541    // Check for both rumdl and markdownlint variants
542    for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
543        if let Some(start) = line.find(prefix) {
544            let after_prefix = &line[start + prefix.len()..];
545
546            // Find the JSON content between the prefix and -->
547            if let Some(end) = after_prefix.find("-->") {
548                let json_str = after_prefix[..end].trim();
549                if !json_str.is_empty() {
550                    // Try to parse as JSON
551                    if let Ok(value) = serde_json::from_str(json_str) {
552                        return Some(value);
553                    }
554                }
555            }
556        }
557    }
558
559    None
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_parse_disable_comment() {
568        // Global disable
569        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
570        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
571
572        // Specific rules
573        assert_eq!(
574            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
575            Some(vec!["MD001", "MD002"])
576        );
577
578        // No comment
579        assert_eq!(parse_disable_comment("Some regular text"), None);
580    }
581
582    #[test]
583    fn test_parse_disable_line_comment() {
584        // Global disable-line
585        assert_eq!(
586            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
587            Some(vec![])
588        );
589
590        // Specific rules
591        assert_eq!(
592            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
593            Some(vec!["MD013"])
594        );
595
596        // No comment
597        assert_eq!(parse_disable_line_comment("Some regular text"), None);
598    }
599
600    #[test]
601    fn test_inline_config_from_content() {
602        let content = r#"# Test Document
603
604<!-- markdownlint-disable MD013 -->
605This is a very long line that would normally trigger MD013 but it's disabled
606
607<!-- markdownlint-enable MD013 -->
608This line will be checked again
609
610<!-- markdownlint-disable-next-line MD001 -->
611# This heading will not be checked for MD001
612## But this one will
613
614Some text <!-- markdownlint-disable-line MD013 -->
615
616<!-- markdownlint-capture -->
617<!-- markdownlint-disable MD001 MD002 -->
618# Heading with MD001 disabled
619<!-- markdownlint-restore -->
620# Heading with MD001 enabled again
621"#;
622
623        let config = InlineConfig::from_content(content);
624
625        // Line 4 should have MD013 disabled (line after disable comment on line 3)
626        assert!(config.is_rule_disabled("MD013", 4));
627
628        // Line 7 should have MD013 enabled (line after enable comment on line 6)
629        assert!(!config.is_rule_disabled("MD013", 7));
630
631        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
632        assert!(config.is_rule_disabled("MD001", 10));
633
634        // Line 11 should not have MD001 disabled
635        assert!(!config.is_rule_disabled("MD001", 11));
636
637        // Line 13 should have MD013 disabled (from disable-line)
638        assert!(config.is_rule_disabled("MD013", 13));
639
640        // After restore (line 18), MD001 should be enabled again on line 19
641        assert!(!config.is_rule_disabled("MD001", 19));
642    }
643
644    #[test]
645    fn test_capture_restore() {
646        let content = r#"<!-- markdownlint-disable MD001 -->
647<!-- markdownlint-capture -->
648<!-- markdownlint-disable MD002 MD003 -->
649<!-- markdownlint-restore -->
650Some content after restore
651"#;
652
653        let config = InlineConfig::from_content(content);
654
655        // After restore (line 4), line 5 should only have MD001 disabled
656        assert!(config.is_rule_disabled("MD001", 5));
657        assert!(!config.is_rule_disabled("MD002", 5));
658        assert!(!config.is_rule_disabled("MD003", 5));
659    }
660}