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    /// Export the disabled rules data for storage in FileIndex
390    ///
391    /// Returns (file_disabled_rules, line_disabled_rules) for use in cross-file checks.
392    /// Merges both persistent disables and line-specific disables into a single map.
393    pub fn export_for_file_index(&self) -> (HashSet<String>, HashMap<usize, HashSet<String>>) {
394        let file_disabled = self.file_disabled_rules.clone();
395
396        // Merge disabled_at_line and line_disabled_rules into a single map
397        let mut line_disabled: HashMap<usize, HashSet<String>> = HashMap::new();
398
399        for (line, rules) in &self.disabled_at_line {
400            line_disabled.entry(*line).or_default().extend(rules.clone());
401        }
402        for (line, rules) in &self.line_disabled_rules {
403            line_disabled.entry(*line).or_default().extend(rules.clone());
404        }
405
406        (file_disabled, line_disabled)
407    }
408}
409
410/// Parse a disable comment and return the list of rules (empty vec means all rules)
411pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
412    // Check for both rumdl-disable and markdownlint-disable
413    for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
414        if let Some(start) = line.find(prefix) {
415            let after_prefix = &line[start + prefix.len()..];
416
417            // Global disable: <!-- markdownlint-disable -->
418            if after_prefix.trim_start().starts_with("-->") {
419                return Some(Vec::new()); // Empty vec means all rules
420            }
421
422            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
423            if let Some(end) = after_prefix.find("-->") {
424                let rules_str = after_prefix[..end].trim();
425                if !rules_str.is_empty() {
426                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
427                    return Some(rules);
428                }
429            }
430        }
431    }
432
433    None
434}
435
436/// Parse an enable comment and return the list of rules (empty vec means all rules)
437pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
438    // Check for both rumdl-enable and markdownlint-enable
439    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
440        if let Some(start) = line.find(prefix) {
441            let after_prefix = &line[start + prefix.len()..];
442
443            // Global enable: <!-- markdownlint-enable -->
444            if after_prefix.trim_start().starts_with("-->") {
445                return Some(Vec::new()); // Empty vec means all rules
446            }
447
448            // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
449            if let Some(end) = after_prefix.find("-->") {
450                let rules_str = after_prefix[..end].trim();
451                if !rules_str.is_empty() {
452                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
453                    return Some(rules);
454                }
455            }
456        }
457    }
458
459    None
460}
461
462/// Parse a disable-line comment
463pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
464    // Check for both rumdl and markdownlint variants
465    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
466        if let Some(start) = line.find(prefix) {
467            let after_prefix = &line[start + prefix.len()..];
468
469            // Global disable-line: <!-- markdownlint-disable-line -->
470            if after_prefix.trim_start().starts_with("-->") {
471                return Some(Vec::new()); // Empty vec means all rules
472            }
473
474            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
475            if let Some(end) = after_prefix.find("-->") {
476                let rules_str = after_prefix[..end].trim();
477                if !rules_str.is_empty() {
478                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
479                    return Some(rules);
480                }
481            }
482        }
483    }
484
485    None
486}
487
488/// Parse a disable-next-line comment
489pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
490    // Check for both rumdl and markdownlint variants
491    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
492        if let Some(start) = line.find(prefix) {
493            let after_prefix = &line[start + prefix.len()..];
494
495            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
496            if after_prefix.trim_start().starts_with("-->") {
497                return Some(Vec::new()); // Empty vec means all rules
498            }
499
500            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
501            if let Some(end) = after_prefix.find("-->") {
502                let rules_str = after_prefix[..end].trim();
503                if !rules_str.is_empty() {
504                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
505                    return Some(rules);
506                }
507            }
508        }
509    }
510
511    None
512}
513
514/// Check if line contains a capture comment
515pub fn is_capture_comment(line: &str) -> bool {
516    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
517}
518
519/// Check if line contains a restore comment
520pub fn is_restore_comment(line: &str) -> bool {
521    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
522}
523
524/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
525pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
526    // Check for both rumdl and markdownlint variants
527    for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
528        if let Some(start) = line.find(prefix) {
529            let after_prefix = &line[start + prefix.len()..];
530
531            // Global disable-file: <!-- markdownlint-disable-file -->
532            if after_prefix.trim_start().starts_with("-->") {
533                return Some(Vec::new()); // Empty vec means all rules
534            }
535
536            // Rule-specific disable-file: <!-- markdownlint-disable-file MD001 MD002 -->
537            if let Some(end) = after_prefix.find("-->") {
538                let rules_str = after_prefix[..end].trim();
539                if !rules_str.is_empty() {
540                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
541                    return Some(rules);
542                }
543            }
544        }
545    }
546
547    None
548}
549
550/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
551pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
552    // Check for both rumdl and markdownlint variants
553    for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
554        if let Some(start) = line.find(prefix) {
555            let after_prefix = &line[start + prefix.len()..];
556
557            // Global enable-file: <!-- markdownlint-enable-file -->
558            if after_prefix.trim_start().starts_with("-->") {
559                return Some(Vec::new()); // Empty vec means all rules
560            }
561
562            // Rule-specific enable-file: <!-- markdownlint-enable-file MD001 MD002 -->
563            if let Some(end) = after_prefix.find("-->") {
564                let rules_str = after_prefix[..end].trim();
565                if !rules_str.is_empty() {
566                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
567                    return Some(rules);
568                }
569            }
570        }
571    }
572
573    None
574}
575
576/// Parse a configure-file comment and return the JSON configuration
577pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
578    // Check for both rumdl and markdownlint variants
579    for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
580        if let Some(start) = line.find(prefix) {
581            let after_prefix = &line[start + prefix.len()..];
582
583            // Find the JSON content between the prefix and -->
584            if let Some(end) = after_prefix.find("-->") {
585                let json_str = after_prefix[..end].trim();
586                if !json_str.is_empty() {
587                    // Try to parse as JSON
588                    if let Ok(value) = serde_json::from_str(json_str) {
589                        return Some(value);
590                    }
591                }
592            }
593        }
594    }
595
596    None
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_parse_disable_comment() {
605        // Global disable
606        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
607        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
608
609        // Specific rules
610        assert_eq!(
611            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
612            Some(vec!["MD001", "MD002"])
613        );
614
615        // No comment
616        assert_eq!(parse_disable_comment("Some regular text"), None);
617    }
618
619    #[test]
620    fn test_parse_disable_line_comment() {
621        // Global disable-line
622        assert_eq!(
623            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
624            Some(vec![])
625        );
626
627        // Specific rules
628        assert_eq!(
629            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
630            Some(vec!["MD013"])
631        );
632
633        // No comment
634        assert_eq!(parse_disable_line_comment("Some regular text"), None);
635    }
636
637    #[test]
638    fn test_inline_config_from_content() {
639        let content = r#"# Test Document
640
641<!-- markdownlint-disable MD013 -->
642This is a very long line that would normally trigger MD013 but it's disabled
643
644<!-- markdownlint-enable MD013 -->
645This line will be checked again
646
647<!-- markdownlint-disable-next-line MD001 -->
648# This heading will not be checked for MD001
649## But this one will
650
651Some text <!-- markdownlint-disable-line MD013 -->
652
653<!-- markdownlint-capture -->
654<!-- markdownlint-disable MD001 MD002 -->
655# Heading with MD001 disabled
656<!-- markdownlint-restore -->
657# Heading with MD001 enabled again
658"#;
659
660        let config = InlineConfig::from_content(content);
661
662        // Line 4 should have MD013 disabled (line after disable comment on line 3)
663        assert!(config.is_rule_disabled("MD013", 4));
664
665        // Line 7 should have MD013 enabled (line after enable comment on line 6)
666        assert!(!config.is_rule_disabled("MD013", 7));
667
668        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
669        assert!(config.is_rule_disabled("MD001", 10));
670
671        // Line 11 should not have MD001 disabled
672        assert!(!config.is_rule_disabled("MD001", 11));
673
674        // Line 13 should have MD013 disabled (from disable-line)
675        assert!(config.is_rule_disabled("MD013", 13));
676
677        // After restore (line 18), MD001 should be enabled again on line 19
678        assert!(!config.is_rule_disabled("MD001", 19));
679    }
680
681    #[test]
682    fn test_capture_restore() {
683        let content = r#"<!-- markdownlint-disable MD001 -->
684<!-- markdownlint-capture -->
685<!-- markdownlint-disable MD002 MD003 -->
686<!-- markdownlint-restore -->
687Some content after restore
688"#;
689
690        let config = InlineConfig::from_content(content);
691
692        // After restore (line 4), line 5 should only have MD001 disabled
693        assert!(config.is_rule_disabled("MD001", 5));
694        assert!(!config.is_rule_disabled("MD002", 5));
695        assert!(!config.is_rule_disabled("MD003", 5));
696    }
697}