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