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            // Skip more specific variants (disable-line, disable-next-line, disable-file)
418            if after_prefix.starts_with('-') {
419                continue;
420            }
421
422            // Global disable: <!-- markdownlint-disable -->
423            if after_prefix.trim_start().starts_with("-->") {
424                return Some(Vec::new()); // Empty vec means all rules
425            }
426
427            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
428            if let Some(end) = after_prefix.find("-->") {
429                let rules_str = after_prefix[..end].trim();
430                if !rules_str.is_empty() {
431                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
432                    return Some(rules);
433                }
434            }
435        }
436    }
437
438    None
439}
440
441/// Parse an enable comment and return the list of rules (empty vec means all rules)
442pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
443    // Check for both rumdl-enable and markdownlint-enable
444    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
445        if let Some(start) = line.find(prefix) {
446            let after_prefix = &line[start + prefix.len()..];
447
448            // Skip more specific variants (enable-file)
449            if after_prefix.starts_with('-') {
450                continue;
451            }
452
453            // Global enable: <!-- markdownlint-enable -->
454            if after_prefix.trim_start().starts_with("-->") {
455                return Some(Vec::new()); // Empty vec means all rules
456            }
457
458            // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
459            if let Some(end) = after_prefix.find("-->") {
460                let rules_str = after_prefix[..end].trim();
461                if !rules_str.is_empty() {
462                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
463                    return Some(rules);
464                }
465            }
466        }
467    }
468
469    None
470}
471
472/// Parse a disable-line comment
473pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
474    // Check for both rumdl and markdownlint variants
475    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
476        if let Some(start) = line.find(prefix) {
477            let after_prefix = &line[start + prefix.len()..];
478
479            // Global disable-line: <!-- markdownlint-disable-line -->
480            if after_prefix.trim_start().starts_with("-->") {
481                return Some(Vec::new()); // Empty vec means all rules
482            }
483
484            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
485            if let Some(end) = after_prefix.find("-->") {
486                let rules_str = after_prefix[..end].trim();
487                if !rules_str.is_empty() {
488                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
489                    return Some(rules);
490                }
491            }
492        }
493    }
494
495    None
496}
497
498/// Parse a disable-next-line comment
499pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
500    // Check for both rumdl and markdownlint variants
501    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
502        if let Some(start) = line.find(prefix) {
503            let after_prefix = &line[start + prefix.len()..];
504
505            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
506            if after_prefix.trim_start().starts_with("-->") {
507                return Some(Vec::new()); // Empty vec means all rules
508            }
509
510            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
511            if let Some(end) = after_prefix.find("-->") {
512                let rules_str = after_prefix[..end].trim();
513                if !rules_str.is_empty() {
514                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
515                    return Some(rules);
516                }
517            }
518        }
519    }
520
521    None
522}
523
524/// Check if line contains a capture comment
525pub fn is_capture_comment(line: &str) -> bool {
526    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
527}
528
529/// Check if line contains a restore comment
530pub fn is_restore_comment(line: &str) -> bool {
531    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
532}
533
534/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
535pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
536    // Check for both rumdl and markdownlint variants
537    for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
538        if let Some(start) = line.find(prefix) {
539            let after_prefix = &line[start + prefix.len()..];
540
541            // Global disable-file: <!-- markdownlint-disable-file -->
542            if after_prefix.trim_start().starts_with("-->") {
543                return Some(Vec::new()); // Empty vec means all rules
544            }
545
546            // Rule-specific disable-file: <!-- markdownlint-disable-file MD001 MD002 -->
547            if let Some(end) = after_prefix.find("-->") {
548                let rules_str = after_prefix[..end].trim();
549                if !rules_str.is_empty() {
550                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
551                    return Some(rules);
552                }
553            }
554        }
555    }
556
557    None
558}
559
560/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
561pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
562    // Check for both rumdl and markdownlint variants
563    for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
564        if let Some(start) = line.find(prefix) {
565            let after_prefix = &line[start + prefix.len()..];
566
567            // Global enable-file: <!-- markdownlint-enable-file -->
568            if after_prefix.trim_start().starts_with("-->") {
569                return Some(Vec::new()); // Empty vec means all rules
570            }
571
572            // Rule-specific enable-file: <!-- markdownlint-enable-file MD001 MD002 -->
573            if let Some(end) = after_prefix.find("-->") {
574                let rules_str = after_prefix[..end].trim();
575                if !rules_str.is_empty() {
576                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
577                    return Some(rules);
578                }
579            }
580        }
581    }
582
583    None
584}
585
586/// Parse a configure-file comment and return the JSON configuration
587pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
588    // Check for both rumdl and markdownlint variants
589    for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
590        if let Some(start) = line.find(prefix) {
591            let after_prefix = &line[start + prefix.len()..];
592
593            // Find the JSON content between the prefix and -->
594            if let Some(end) = after_prefix.find("-->") {
595                let json_str = after_prefix[..end].trim();
596                if !json_str.is_empty() {
597                    // Try to parse as JSON
598                    if let Ok(value) = serde_json::from_str(json_str) {
599                        return Some(value);
600                    }
601                }
602            }
603        }
604    }
605
606    None
607}
608
609/// Warning about unknown rules in inline config comments
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub struct InlineConfigWarning {
612    /// The line number where the warning occurred (1-indexed)
613    pub line_number: usize,
614    /// The rule name that was not recognized
615    pub rule_name: String,
616    /// The type of inline config comment
617    pub comment_type: String,
618    /// Optional suggestion for similar rule names
619    pub suggestion: Option<String>,
620}
621
622impl InlineConfigWarning {
623    /// Format the warning message
624    pub fn format_message(&self) -> String {
625        if let Some(ref suggestion) = self.suggestion {
626            format!(
627                "Unknown rule in inline {} comment: {} (did you mean: {}?)",
628                self.comment_type, self.rule_name, suggestion
629            )
630        } else {
631            format!(
632                "Unknown rule in inline {} comment: {}",
633                self.comment_type, self.rule_name
634            )
635        }
636    }
637
638    /// Print the warning to stderr with file context
639    pub fn print_warning(&self, file_path: &str) {
640        eprintln!(
641            "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
642            file_path,
643            self.line_number,
644            self.format_message()
645        );
646    }
647}
648
649/// Validate all inline config comments in content and return warnings for unknown rules.
650///
651/// This function extracts rule names from all types of inline config comments
652/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
653/// and validates them against the known rule alias map.
654pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
655    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
656
657    let mut warnings = Vec::new();
658    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
659
660    for (idx, line) in content.lines().enumerate() {
661        let line_num = idx + 1;
662
663        // Collect all rule names from various comment types
664        let mut rule_entries: Vec<(&str, &str)> = Vec::new();
665
666        // Check each comment type and collect rules with their type names
667        if let Some(rules) = parse_disable_comment(line) {
668            for rule in rules {
669                rule_entries.push((rule, "disable"));
670            }
671        }
672        if let Some(rules) = parse_enable_comment(line) {
673            for rule in rules {
674                rule_entries.push((rule, "enable"));
675            }
676        }
677        if let Some(rules) = parse_disable_line_comment(line) {
678            for rule in rules {
679                rule_entries.push((rule, "disable-line"));
680            }
681        }
682        if let Some(rules) = parse_disable_next_line_comment(line) {
683            for rule in rules {
684                rule_entries.push((rule, "disable-next-line"));
685            }
686        }
687        if let Some(rules) = parse_disable_file_comment(line) {
688            for rule in rules {
689                rule_entries.push((rule, "disable-file"));
690            }
691        }
692        if let Some(rules) = parse_enable_file_comment(line) {
693            for rule in rules {
694                rule_entries.push((rule, "enable-file"));
695            }
696        }
697
698        // Check configure-file comments - rule names are JSON keys
699        if let Some(json_config) = parse_configure_file_comment(line)
700            && let Some(obj) = json_config.as_object()
701        {
702            for rule_name in obj.keys() {
703                if !is_valid_rule_name(rule_name) {
704                    let suggestion = suggest_similar_key(rule_name, &all_rule_names)
705                        .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
706                    warnings.push(InlineConfigWarning {
707                        line_number: line_num,
708                        rule_name: rule_name.to_string(),
709                        comment_type: "configure-file".to_string(),
710                        suggestion,
711                    });
712                }
713            }
714        }
715
716        // Validate each rule name
717        for (rule_name, comment_type) in rule_entries {
718            if !is_valid_rule_name(rule_name) {
719                let suggestion = suggest_similar_key(rule_name, &all_rule_names)
720                    .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
721                warnings.push(InlineConfigWarning {
722                    line_number: line_num,
723                    rule_name: rule_name.to_string(),
724                    comment_type: comment_type.to_string(),
725                    suggestion,
726                });
727            }
728        }
729    }
730
731    warnings
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_parse_disable_comment() {
740        // Global disable
741        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
742        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
743
744        // Specific rules
745        assert_eq!(
746            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
747            Some(vec!["MD001", "MD002"])
748        );
749
750        // No comment
751        assert_eq!(parse_disable_comment("Some regular text"), None);
752    }
753
754    #[test]
755    fn test_parse_disable_line_comment() {
756        // Global disable-line
757        assert_eq!(
758            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
759            Some(vec![])
760        );
761
762        // Specific rules
763        assert_eq!(
764            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
765            Some(vec!["MD013"])
766        );
767
768        // No comment
769        assert_eq!(parse_disable_line_comment("Some regular text"), None);
770    }
771
772    #[test]
773    fn test_inline_config_from_content() {
774        let content = r#"# Test Document
775
776<!-- markdownlint-disable MD013 -->
777This is a very long line that would normally trigger MD013 but it's disabled
778
779<!-- markdownlint-enable MD013 -->
780This line will be checked again
781
782<!-- markdownlint-disable-next-line MD001 -->
783# This heading will not be checked for MD001
784## But this one will
785
786Some text <!-- markdownlint-disable-line MD013 -->
787
788<!-- markdownlint-capture -->
789<!-- markdownlint-disable MD001 MD002 -->
790# Heading with MD001 disabled
791<!-- markdownlint-restore -->
792# Heading with MD001 enabled again
793"#;
794
795        let config = InlineConfig::from_content(content);
796
797        // Line 4 should have MD013 disabled (line after disable comment on line 3)
798        assert!(config.is_rule_disabled("MD013", 4));
799
800        // Line 7 should have MD013 enabled (line after enable comment on line 6)
801        assert!(!config.is_rule_disabled("MD013", 7));
802
803        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
804        assert!(config.is_rule_disabled("MD001", 10));
805
806        // Line 11 should not have MD001 disabled
807        assert!(!config.is_rule_disabled("MD001", 11));
808
809        // Line 13 should have MD013 disabled (from disable-line)
810        assert!(config.is_rule_disabled("MD013", 13));
811
812        // After restore (line 18), MD001 should be enabled again on line 19
813        assert!(!config.is_rule_disabled("MD001", 19));
814    }
815
816    #[test]
817    fn test_capture_restore() {
818        let content = r#"<!-- markdownlint-disable MD001 -->
819<!-- markdownlint-capture -->
820<!-- markdownlint-disable MD002 MD003 -->
821<!-- markdownlint-restore -->
822Some content after restore
823"#;
824
825        let config = InlineConfig::from_content(content);
826
827        // After restore (line 4), line 5 should only have MD001 disabled
828        assert!(config.is_rule_disabled("MD001", 5));
829        assert!(!config.is_rule_disabled("MD002", 5));
830        assert!(!config.is_rule_disabled("MD003", 5));
831    }
832
833    #[test]
834    fn test_validate_inline_config_rules_unknown_rule() {
835        let content = "<!-- rumdl-disable abc -->\nSome content";
836        let warnings = validate_inline_config_rules(content);
837        assert_eq!(warnings.len(), 1);
838        assert_eq!(warnings[0].line_number, 1);
839        assert_eq!(warnings[0].rule_name, "abc");
840        assert_eq!(warnings[0].comment_type, "disable");
841    }
842
843    #[test]
844    fn test_validate_inline_config_rules_valid_rule() {
845        let content = "<!-- rumdl-disable MD001 -->\nSome content";
846        let warnings = validate_inline_config_rules(content);
847        assert!(
848            warnings.is_empty(),
849            "MD001 is a valid rule, should not produce warnings"
850        );
851    }
852
853    #[test]
854    fn test_validate_inline_config_rules_alias() {
855        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
856        let warnings = validate_inline_config_rules(content);
857        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
858    }
859
860    #[test]
861    fn test_validate_inline_config_rules_multiple_unknown() {
862        let content = r#"<!-- rumdl-disable abc xyz -->
863<!-- rumdl-disable-line foo -->
864<!-- markdownlint-disable-next-line bar -->
865"#;
866        let warnings = validate_inline_config_rules(content);
867        assert_eq!(warnings.len(), 4);
868        assert_eq!(warnings[0].rule_name, "abc");
869        assert_eq!(warnings[1].rule_name, "xyz");
870        assert_eq!(warnings[2].rule_name, "foo");
871        assert_eq!(warnings[3].rule_name, "bar");
872    }
873
874    #[test]
875    fn test_validate_inline_config_rules_suggestion() {
876        // "MD00" should suggest "MD001" (or similar)
877        let content = "<!-- rumdl-disable MD00 -->\n";
878        let warnings = validate_inline_config_rules(content);
879        assert_eq!(warnings.len(), 1);
880        // Should have a suggestion since "MD00" is close to "MD001"
881        assert!(warnings[0].suggestion.is_some());
882    }
883
884    #[test]
885    fn test_validate_inline_config_rules_file_comments() {
886        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
887        let warnings = validate_inline_config_rules(content);
888        assert_eq!(warnings.len(), 2);
889        assert_eq!(warnings[0].comment_type, "disable-file");
890        assert_eq!(warnings[1].comment_type, "enable-file");
891    }
892
893    #[test]
894    fn test_validate_inline_config_rules_global_disable() {
895        // Global disable (no specific rules) should not produce warnings
896        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
897        let warnings = validate_inline_config_rules(content);
898        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
899    }
900
901    #[test]
902    fn test_validate_inline_config_rules_mixed_valid_invalid() {
903        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
904        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
905        let warnings = validate_inline_config_rules(content);
906        assert_eq!(warnings.len(), 2);
907        assert_eq!(warnings[0].rule_name, "abc");
908        assert_eq!(warnings[1].rule_name, "xyz");
909    }
910
911    #[test]
912    fn test_validate_inline_config_rules_configure_file() {
913        // configure-file comments contain rule names as JSON keys
914        let content =
915            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
916        let warnings = validate_inline_config_rules(content);
917        assert_eq!(warnings.len(), 1);
918        assert_eq!(warnings[0].rule_name, "nonexistent");
919        assert_eq!(warnings[0].comment_type, "configure-file");
920    }
921
922    #[test]
923    fn test_validate_inline_config_rules_markdownlint_variants() {
924        // Test markdownlint-* variants (not just rumdl-*)
925        let content = r#"<!-- markdownlint-disable unknown_rule -->
926<!-- markdownlint-enable another_fake -->
927<!-- markdownlint-disable-line bad_rule -->
928<!-- markdownlint-disable-next-line fake_rule -->
929<!-- markdownlint-disable-file missing_rule -->
930<!-- markdownlint-enable-file nonexistent -->
931"#;
932        let warnings = validate_inline_config_rules(content);
933        assert_eq!(warnings.len(), 6);
934        assert_eq!(warnings[0].rule_name, "unknown_rule");
935        assert_eq!(warnings[1].rule_name, "another_fake");
936        assert_eq!(warnings[2].rule_name, "bad_rule");
937        assert_eq!(warnings[3].rule_name, "fake_rule");
938        assert_eq!(warnings[4].rule_name, "missing_rule");
939        assert_eq!(warnings[5].rule_name, "nonexistent");
940    }
941
942    #[test]
943    fn test_validate_inline_config_rules_markdownlint_configure_file() {
944        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
945        let warnings = validate_inline_config_rules(content);
946        assert_eq!(warnings.len(), 1);
947        assert_eq!(warnings[0].rule_name, "fake_rule");
948        assert_eq!(warnings[0].comment_type, "configure-file");
949    }
950
951    #[test]
952    fn test_get_rule_config_from_configure_file() {
953        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
954
955This is a test line."#;
956
957        let inline_config = InlineConfig::from_content(content);
958        let config_override = inline_config.get_rule_config("MD013");
959
960        assert!(config_override.is_some(), "MD013 config should be found");
961        let json = config_override.unwrap();
962        assert!(json.is_object(), "Config should be an object");
963        let obj = json.as_object().unwrap();
964        assert!(obj.contains_key("line_length"), "Should have line_length key");
965        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
966    }
967
968    #[test]
969    fn test_get_rule_config_tables_false() {
970        // Test that tables=false inline config is correctly parsed
971        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
972
973        let inline_config = InlineConfig::from_content(content);
974        let config_override = inline_config.get_rule_config("MD013");
975
976        assert!(config_override.is_some(), "MD013 config should be found");
977        let json = config_override.unwrap();
978        let obj = json.as_object().unwrap();
979        assert!(obj.contains_key("tables"), "Should have tables key");
980        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
981    }
982}