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
34fn has_inline_config_markers(content: &str) -> bool {
35    if !content.contains("<!--") {
36        return false;
37    }
38    content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
39}
40
41#[derive(Debug, Clone)]
42pub struct InlineConfig {
43    /// Rules that are disabled at each line (1-indexed line -> set of disabled rules)
44    disabled_at_line: HashMap<usize, HashSet<String>>,
45    /// Rules that are explicitly enabled when all rules are disabled (1-indexed line -> set of enabled rules)
46    /// Only used when "*" is in disabled_at_line
47    enabled_at_line: HashMap<usize, HashSet<String>>,
48    /// Rules disabled for specific lines via disable-line (1-indexed)
49    line_disabled_rules: HashMap<usize, HashSet<String>>,
50    /// Rules disabled for the entire file
51    file_disabled_rules: HashSet<String>,
52    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
53    file_enabled_rules: HashSet<String>,
54    /// Configuration overrides for specific rules from configure-file comments
55    /// Maps rule name to configuration JSON value
56    file_rule_config: HashMap<String, JsonValue>,
57}
58
59impl Default for InlineConfig {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl InlineConfig {
66    pub fn new() -> Self {
67        Self {
68            disabled_at_line: HashMap::new(),
69            enabled_at_line: HashMap::new(),
70            line_disabled_rules: HashMap::new(),
71            file_disabled_rules: HashSet::new(),
72            file_enabled_rules: HashSet::new(),
73            file_rule_config: HashMap::new(),
74        }
75    }
76
77    /// Process all inline comments in the content and return the configuration state
78    pub fn from_content(content: &str) -> Self {
79        if !has_inline_config_markers(content) {
80            return Self::new();
81        }
82
83        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
84        Self::from_content_with_code_blocks_internal(content, &code_blocks)
85    }
86
87    /// Process all inline comments in the content with precomputed code blocks.
88    pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
89        if !has_inline_config_markers(content) {
90            return Self::new();
91        }
92
93        Self::from_content_with_code_blocks_internal(content, code_blocks)
94    }
95
96    fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
97        let mut config = Self::new();
98        let lines: Vec<&str> = content.lines().collect();
99
100        // Pre-compute line positions for checking if a line is in a code block
101        let mut line_positions = Vec::with_capacity(lines.len());
102        let mut pos = 0;
103        for line in &lines {
104            line_positions.push(pos);
105            pos += line.len() + 1; // +1 for newline
106        }
107
108        // Track current state of disabled rules
109        let mut currently_disabled = HashSet::new();
110        let mut currently_enabled = HashSet::new(); // For when all rules are disabled
111        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
112
113        for (idx, line) in lines.iter().enumerate() {
114            let line_num = idx + 1; // 1-indexed
115
116            // Store the current state for this line BEFORE processing comments
117            // This way, comments on a line don't affect that same line
118            config.disabled_at_line.insert(line_num, currently_disabled.clone());
119            config.enabled_at_line.insert(line_num, currently_enabled.clone());
120
121            // Skip processing if this line is inside a code block
122            let line_start = line_positions[idx];
123            let line_end = line_start + line.len();
124            let in_code_block = code_blocks
125                .iter()
126                .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
127
128            if in_code_block {
129                continue;
130            }
131
132            // Process file-wide comments first as they affect the entire file
133            // Check for disable-file
134            if let Some(rules) = parse_disable_file_comment(line) {
135                if rules.is_empty() {
136                    // Disable all rules for entire file
137                    config.file_disabled_rules.clear();
138                    config.file_disabled_rules.insert("*".to_string());
139                } else {
140                    // Disable specific rules for entire file
141                    if config.file_disabled_rules.contains("*") {
142                        // All rules are disabled, so remove from enabled list
143                        for rule in rules {
144                            config.file_enabled_rules.remove(&normalize_rule_name(rule));
145                        }
146                    } else {
147                        // Normal case: add to disabled list
148                        for rule in rules {
149                            config.file_disabled_rules.insert(normalize_rule_name(rule));
150                        }
151                    }
152                }
153            }
154
155            // Check for enable-file
156            if let Some(rules) = parse_enable_file_comment(line) {
157                if rules.is_empty() {
158                    // Enable all rules for entire file
159                    config.file_disabled_rules.clear();
160                    config.file_enabled_rules.clear();
161                } else {
162                    // Enable specific rules for entire file
163                    if config.file_disabled_rules.contains("*") {
164                        // All rules are disabled, so add to enabled list
165                        for rule in rules {
166                            config.file_enabled_rules.insert(normalize_rule_name(rule));
167                        }
168                    } else {
169                        // Normal case: remove from disabled list
170                        for rule in rules {
171                            config.file_disabled_rules.remove(&normalize_rule_name(rule));
172                        }
173                    }
174                }
175            }
176
177            // Check for configure-file
178            if let Some(json_config) = parse_configure_file_comment(line) {
179                // Process the JSON configuration
180                if let Some(obj) = json_config.as_object() {
181                    for (rule_name, rule_config) in obj {
182                        config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
183                    }
184                }
185            }
186
187            // Process comments - handle multiple comment types on same line
188            // Process line-specific comments first (they don't affect state)
189
190            // Check for disable-next-line
191            if let Some(rules) = parse_disable_next_line_comment(line) {
192                let next_line = line_num + 1;
193                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
194                if rules.is_empty() {
195                    // Disable all rules for next line
196                    line_rules.insert("*".to_string());
197                } else {
198                    for rule in rules {
199                        line_rules.insert(normalize_rule_name(rule));
200                    }
201                }
202            }
203
204            // Check for prettier-ignore (disables all rules for next line)
205            if line.contains("<!-- prettier-ignore -->") {
206                let next_line = line_num + 1;
207                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
208                line_rules.insert("*".to_string());
209            }
210
211            // Check for disable-line
212            if let Some(rules) = parse_disable_line_comment(line) {
213                let line_rules = config.line_disabled_rules.entry(line_num).or_default();
214                if rules.is_empty() {
215                    // Disable all rules for current line
216                    line_rules.insert("*".to_string());
217                } else {
218                    for rule in rules {
219                        line_rules.insert(normalize_rule_name(rule));
220                    }
221                }
222            }
223
224            // Process state-changing comments in the order they appear
225            // This handles multiple comments on the same line correctly
226            let mut processed_capture = false;
227            let mut processed_restore = false;
228
229            // Find all comments on this line and process them in order
230            let mut comment_positions = Vec::new();
231
232            if let Some(pos) = line.find("<!-- markdownlint-disable")
233                && !line[pos..].contains("<!-- markdownlint-disable-line")
234                && !line[pos..].contains("<!-- markdownlint-disable-next-line")
235            {
236                comment_positions.push((pos, "disable"));
237            }
238            if let Some(pos) = line.find("<!-- rumdl-disable")
239                && !line[pos..].contains("<!-- rumdl-disable-line")
240                && !line[pos..].contains("<!-- rumdl-disable-next-line")
241            {
242                comment_positions.push((pos, "disable"));
243            }
244
245            if let Some(pos) = line.find("<!-- markdownlint-enable") {
246                comment_positions.push((pos, "enable"));
247            }
248            if let Some(pos) = line.find("<!-- rumdl-enable") {
249                comment_positions.push((pos, "enable"));
250            }
251
252            if let Some(pos) = line.find("<!-- markdownlint-capture") {
253                comment_positions.push((pos, "capture"));
254            }
255            if let Some(pos) = line.find("<!-- rumdl-capture") {
256                comment_positions.push((pos, "capture"));
257            }
258
259            if let Some(pos) = line.find("<!-- markdownlint-restore") {
260                comment_positions.push((pos, "restore"));
261            }
262            if let Some(pos) = line.find("<!-- rumdl-restore") {
263                comment_positions.push((pos, "restore"));
264            }
265
266            // Sort by position to process in order
267            comment_positions.sort_by_key(|&(pos, _)| pos);
268
269            // Process each comment in order
270            for (_, comment_type) in comment_positions {
271                match comment_type {
272                    "disable" => {
273                        if let Some(rules) = parse_disable_comment(line) {
274                            if rules.is_empty() {
275                                // Disable all rules
276                                currently_disabled.clear();
277                                currently_disabled.insert("*".to_string());
278                                currently_enabled.clear(); // Reset enabled list
279                            } else {
280                                // Disable specific rules
281                                if currently_disabled.contains("*") {
282                                    // All rules are disabled, so remove from enabled list
283                                    for rule in rules {
284                                        currently_enabled.remove(&normalize_rule_name(rule));
285                                    }
286                                } else {
287                                    // Normal case: add to disabled list
288                                    for rule in rules {
289                                        currently_disabled.insert(normalize_rule_name(rule));
290                                    }
291                                }
292                            }
293                        }
294                    }
295                    "enable" => {
296                        if let Some(rules) = parse_enable_comment(line) {
297                            if rules.is_empty() {
298                                // Enable all rules
299                                currently_disabled.clear();
300                                currently_enabled.clear();
301                            } else {
302                                // Enable specific rules
303                                if currently_disabled.contains("*") {
304                                    // All rules are disabled, so add to enabled list
305                                    for rule in rules {
306                                        currently_enabled.insert(normalize_rule_name(rule));
307                                    }
308                                } else {
309                                    // Normal case: remove from disabled list
310                                    for rule in rules {
311                                        currently_disabled.remove(&normalize_rule_name(rule));
312                                    }
313                                }
314                            }
315                        }
316                    }
317                    "capture" => {
318                        if !processed_capture && is_capture_comment(line) {
319                            capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
320                            processed_capture = true;
321                        }
322                    }
323                    "restore" => {
324                        if !processed_restore && is_restore_comment(line) {
325                            if let Some((disabled, enabled)) = capture_stack.pop() {
326                                currently_disabled = disabled;
327                                currently_enabled = enabled;
328                            }
329                            processed_restore = true;
330                        }
331                    }
332                    _ => {}
333                }
334            }
335        }
336
337        config
338    }
339
340    /// Check if a rule is disabled at a specific line
341    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
342        // Check file-wide disables first (highest priority)
343        if self.file_disabled_rules.contains("*") {
344            // All rules are disabled for the file, check if this rule is explicitly enabled
345            return !self.file_enabled_rules.contains(rule_name);
346        } else if self.file_disabled_rules.contains(rule_name) {
347            return true;
348        }
349
350        // Check line-specific disables (disable-line, disable-next-line)
351        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
352            && (line_rules.contains("*") || line_rules.contains(rule_name))
353        {
354            return true;
355        }
356
357        // Check persistent disables at this line
358        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
359            if disabled_set.contains("*") {
360                // All rules are disabled, check if this rule is explicitly enabled
361                if let Some(enabled_set) = self.enabled_at_line.get(&line_number) {
362                    return !enabled_set.contains(rule_name);
363                }
364                return true; // All disabled and not explicitly enabled
365            } else {
366                return disabled_set.contains(rule_name);
367            }
368        }
369
370        false
371    }
372
373    /// Get all disabled rules at a specific line
374    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
375        let mut disabled = HashSet::new();
376
377        // Add persistent disables
378        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
379            if disabled_set.contains("*") {
380                // All rules are disabled except those explicitly enabled
381                disabled.insert("*".to_string());
382                // We could subtract enabled rules here, but that would require knowing all rules
383                // For now, we'll just return "*" to indicate all rules are disabled
384            } else {
385                for rule in disabled_set {
386                    disabled.insert(rule.clone());
387                }
388            }
389        }
390
391        // Add line-specific disables
392        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
393            for rule in line_rules {
394                disabled.insert(rule.clone());
395            }
396        }
397
398        disabled
399    }
400
401    /// Get configuration overrides for a specific rule from configure-file comments
402    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
403        self.file_rule_config.get(rule_name)
404    }
405
406    /// Get all configuration overrides from configure-file comments
407    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
408        &self.file_rule_config
409    }
410
411    /// Export the disabled rules data for storage in FileIndex
412    ///
413    /// Returns (file_disabled_rules, line_disabled_rules) for use in cross-file checks.
414    /// Merges both persistent disables and line-specific disables into a single map.
415    pub fn export_for_file_index(&self) -> (HashSet<String>, HashMap<usize, HashSet<String>>) {
416        let file_disabled = self.file_disabled_rules.clone();
417
418        // Merge disabled_at_line and line_disabled_rules into a single map
419        let mut line_disabled: HashMap<usize, HashSet<String>> = HashMap::new();
420
421        for (line, rules) in &self.disabled_at_line {
422            line_disabled.entry(*line).or_default().extend(rules.clone());
423        }
424        for (line, rules) in &self.line_disabled_rules {
425            line_disabled.entry(*line).or_default().extend(rules.clone());
426        }
427
428        (file_disabled, line_disabled)
429    }
430}
431
432/// Parse a disable comment and return the list of rules (empty vec means all rules)
433pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
434    // Check for both rumdl-disable and markdownlint-disable
435    for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
436        if let Some(start) = line.find(prefix) {
437            let after_prefix = &line[start + prefix.len()..];
438
439            // Skip more specific variants (disable-line, disable-next-line, disable-file)
440            if after_prefix.starts_with('-') {
441                continue;
442            }
443
444            // Global disable: <!-- markdownlint-disable -->
445            if after_prefix.trim_start().starts_with("-->") {
446                return Some(Vec::new()); // Empty vec means all rules
447            }
448
449            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
450            if let Some(end) = after_prefix.find("-->") {
451                let rules_str = after_prefix[..end].trim();
452                if !rules_str.is_empty() {
453                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
454                    return Some(rules);
455                }
456            }
457        }
458    }
459
460    None
461}
462
463/// Parse an enable comment and return the list of rules (empty vec means all rules)
464pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
465    // Check for both rumdl-enable and markdownlint-enable
466    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
467        if let Some(start) = line.find(prefix) {
468            let after_prefix = &line[start + prefix.len()..];
469
470            // Skip more specific variants (enable-file)
471            if after_prefix.starts_with('-') {
472                continue;
473            }
474
475            // Global enable: <!-- markdownlint-enable -->
476            if after_prefix.trim_start().starts_with("-->") {
477                return Some(Vec::new()); // Empty vec means all rules
478            }
479
480            // Rule-specific enable: <!-- markdownlint-enable 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/// Parse a disable-line comment
495pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
496    // Check for both rumdl and markdownlint variants
497    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
498        if let Some(start) = line.find(prefix) {
499            let after_prefix = &line[start + prefix.len()..];
500
501            // Global disable-line: <!-- markdownlint-disable-line -->
502            if after_prefix.trim_start().starts_with("-->") {
503                return Some(Vec::new()); // Empty vec means all rules
504            }
505
506            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
507            if let Some(end) = after_prefix.find("-->") {
508                let rules_str = after_prefix[..end].trim();
509                if !rules_str.is_empty() {
510                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
511                    return Some(rules);
512                }
513            }
514        }
515    }
516
517    None
518}
519
520/// Parse a disable-next-line comment
521pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
522    // Check for both rumdl and markdownlint variants
523    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
524        if let Some(start) = line.find(prefix) {
525            let after_prefix = &line[start + prefix.len()..];
526
527            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
528            if after_prefix.trim_start().starts_with("-->") {
529                return Some(Vec::new()); // Empty vec means all rules
530            }
531
532            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
533            if let Some(end) = after_prefix.find("-->") {
534                let rules_str = after_prefix[..end].trim();
535                if !rules_str.is_empty() {
536                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
537                    return Some(rules);
538                }
539            }
540        }
541    }
542
543    None
544}
545
546/// Check if line contains a capture comment
547pub fn is_capture_comment(line: &str) -> bool {
548    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
549}
550
551/// Check if line contains a restore comment
552pub fn is_restore_comment(line: &str) -> bool {
553    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
554}
555
556/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
557pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
558    // Check for both rumdl and markdownlint variants
559    for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
560        if let Some(start) = line.find(prefix) {
561            let after_prefix = &line[start + prefix.len()..];
562
563            // Global disable-file: <!-- markdownlint-disable-file -->
564            if after_prefix.trim_start().starts_with("-->") {
565                return Some(Vec::new()); // Empty vec means all rules
566            }
567
568            // Rule-specific disable-file: <!-- markdownlint-disable-file MD001 MD002 -->
569            if let Some(end) = after_prefix.find("-->") {
570                let rules_str = after_prefix[..end].trim();
571                if !rules_str.is_empty() {
572                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
573                    return Some(rules);
574                }
575            }
576        }
577    }
578
579    None
580}
581
582/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
583pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
584    // Check for both rumdl and markdownlint variants
585    for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
586        if let Some(start) = line.find(prefix) {
587            let after_prefix = &line[start + prefix.len()..];
588
589            // Global enable-file: <!-- markdownlint-enable-file -->
590            if after_prefix.trim_start().starts_with("-->") {
591                return Some(Vec::new()); // Empty vec means all rules
592            }
593
594            // Rule-specific enable-file: <!-- markdownlint-enable-file MD001 MD002 -->
595            if let Some(end) = after_prefix.find("-->") {
596                let rules_str = after_prefix[..end].trim();
597                if !rules_str.is_empty() {
598                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
599                    return Some(rules);
600                }
601            }
602        }
603    }
604
605    None
606}
607
608/// Parse a configure-file comment and return the JSON configuration
609pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
610    // Check for both rumdl and markdownlint variants
611    for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
612        if let Some(start) = line.find(prefix) {
613            let after_prefix = &line[start + prefix.len()..];
614
615            // Find the JSON content between the prefix and -->
616            if let Some(end) = after_prefix.find("-->") {
617                let json_str = after_prefix[..end].trim();
618                if !json_str.is_empty() {
619                    // Try to parse as JSON
620                    if let Ok(value) = serde_json::from_str(json_str) {
621                        return Some(value);
622                    }
623                }
624            }
625        }
626    }
627
628    None
629}
630
631/// Warning about unknown rules in inline config comments
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct InlineConfigWarning {
634    /// The line number where the warning occurred (1-indexed)
635    pub line_number: usize,
636    /// The rule name that was not recognized
637    pub rule_name: String,
638    /// The type of inline config comment
639    pub comment_type: String,
640    /// Optional suggestion for similar rule names
641    pub suggestion: Option<String>,
642}
643
644impl InlineConfigWarning {
645    /// Format the warning message
646    pub fn format_message(&self) -> String {
647        if let Some(ref suggestion) = self.suggestion {
648            format!(
649                "Unknown rule in inline {} comment: {} (did you mean: {}?)",
650                self.comment_type, self.rule_name, suggestion
651            )
652        } else {
653            format!(
654                "Unknown rule in inline {} comment: {}",
655                self.comment_type, self.rule_name
656            )
657        }
658    }
659
660    /// Print the warning to stderr with file context
661    pub fn print_warning(&self, file_path: &str) {
662        eprintln!(
663            "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
664            file_path,
665            self.line_number,
666            self.format_message()
667        );
668    }
669}
670
671/// Validate all inline config comments in content and return warnings for unknown rules.
672///
673/// This function extracts rule names from all types of inline config comments
674/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
675/// and validates them against the known rule alias map.
676pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
677    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
678
679    let mut warnings = Vec::new();
680    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
681
682    for (idx, line) in content.lines().enumerate() {
683        let line_num = idx + 1;
684
685        // Collect all rule names from various comment types
686        let mut rule_entries: Vec<(&str, &str)> = Vec::new();
687
688        // Check each comment type and collect rules with their type names
689        if let Some(rules) = parse_disable_comment(line) {
690            for rule in rules {
691                rule_entries.push((rule, "disable"));
692            }
693        }
694        if let Some(rules) = parse_enable_comment(line) {
695            for rule in rules {
696                rule_entries.push((rule, "enable"));
697            }
698        }
699        if let Some(rules) = parse_disable_line_comment(line) {
700            for rule in rules {
701                rule_entries.push((rule, "disable-line"));
702            }
703        }
704        if let Some(rules) = parse_disable_next_line_comment(line) {
705            for rule in rules {
706                rule_entries.push((rule, "disable-next-line"));
707            }
708        }
709        if let Some(rules) = parse_disable_file_comment(line) {
710            for rule in rules {
711                rule_entries.push((rule, "disable-file"));
712            }
713        }
714        if let Some(rules) = parse_enable_file_comment(line) {
715            for rule in rules {
716                rule_entries.push((rule, "enable-file"));
717            }
718        }
719
720        // Check configure-file comments - rule names are JSON keys
721        if let Some(json_config) = parse_configure_file_comment(line)
722            && let Some(obj) = json_config.as_object()
723        {
724            for rule_name in obj.keys() {
725                if !is_valid_rule_name(rule_name) {
726                    let suggestion = suggest_similar_key(rule_name, &all_rule_names)
727                        .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
728                    warnings.push(InlineConfigWarning {
729                        line_number: line_num,
730                        rule_name: rule_name.to_string(),
731                        comment_type: "configure-file".to_string(),
732                        suggestion,
733                    });
734                }
735            }
736        }
737
738        // Validate each rule name
739        for (rule_name, comment_type) in rule_entries {
740            if !is_valid_rule_name(rule_name) {
741                let suggestion = suggest_similar_key(rule_name, &all_rule_names)
742                    .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
743                warnings.push(InlineConfigWarning {
744                    line_number: line_num,
745                    rule_name: rule_name.to_string(),
746                    comment_type: comment_type.to_string(),
747                    suggestion,
748                });
749            }
750        }
751    }
752
753    warnings
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn test_parse_disable_comment() {
762        // Global disable
763        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
764        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
765
766        // Specific rules
767        assert_eq!(
768            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
769            Some(vec!["MD001", "MD002"])
770        );
771
772        // No comment
773        assert_eq!(parse_disable_comment("Some regular text"), None);
774    }
775
776    #[test]
777    fn test_parse_disable_line_comment() {
778        // Global disable-line
779        assert_eq!(
780            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
781            Some(vec![])
782        );
783
784        // Specific rules
785        assert_eq!(
786            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
787            Some(vec!["MD013"])
788        );
789
790        // No comment
791        assert_eq!(parse_disable_line_comment("Some regular text"), None);
792    }
793
794    #[test]
795    fn test_inline_config_from_content() {
796        let content = r#"# Test Document
797
798<!-- markdownlint-disable MD013 -->
799This is a very long line that would normally trigger MD013 but it's disabled
800
801<!-- markdownlint-enable MD013 -->
802This line will be checked again
803
804<!-- markdownlint-disable-next-line MD001 -->
805# This heading will not be checked for MD001
806## But this one will
807
808Some text <!-- markdownlint-disable-line MD013 -->
809
810<!-- markdownlint-capture -->
811<!-- markdownlint-disable MD001 MD002 -->
812# Heading with MD001 disabled
813<!-- markdownlint-restore -->
814# Heading with MD001 enabled again
815"#;
816
817        let config = InlineConfig::from_content(content);
818
819        // Line 4 should have MD013 disabled (line after disable comment on line 3)
820        assert!(config.is_rule_disabled("MD013", 4));
821
822        // Line 7 should have MD013 enabled (line after enable comment on line 6)
823        assert!(!config.is_rule_disabled("MD013", 7));
824
825        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
826        assert!(config.is_rule_disabled("MD001", 10));
827
828        // Line 11 should not have MD001 disabled
829        assert!(!config.is_rule_disabled("MD001", 11));
830
831        // Line 13 should have MD013 disabled (from disable-line)
832        assert!(config.is_rule_disabled("MD013", 13));
833
834        // After restore (line 18), MD001 should be enabled again on line 19
835        assert!(!config.is_rule_disabled("MD001", 19));
836    }
837
838    #[test]
839    fn test_capture_restore() {
840        let content = r#"<!-- markdownlint-disable MD001 -->
841<!-- markdownlint-capture -->
842<!-- markdownlint-disable MD002 MD003 -->
843<!-- markdownlint-restore -->
844Some content after restore
845"#;
846
847        let config = InlineConfig::from_content(content);
848
849        // After restore (line 4), line 5 should only have MD001 disabled
850        assert!(config.is_rule_disabled("MD001", 5));
851        assert!(!config.is_rule_disabled("MD002", 5));
852        assert!(!config.is_rule_disabled("MD003", 5));
853    }
854
855    #[test]
856    fn test_validate_inline_config_rules_unknown_rule() {
857        let content = "<!-- rumdl-disable abc -->\nSome content";
858        let warnings = validate_inline_config_rules(content);
859        assert_eq!(warnings.len(), 1);
860        assert_eq!(warnings[0].line_number, 1);
861        assert_eq!(warnings[0].rule_name, "abc");
862        assert_eq!(warnings[0].comment_type, "disable");
863    }
864
865    #[test]
866    fn test_validate_inline_config_rules_valid_rule() {
867        let content = "<!-- rumdl-disable MD001 -->\nSome content";
868        let warnings = validate_inline_config_rules(content);
869        assert!(
870            warnings.is_empty(),
871            "MD001 is a valid rule, should not produce warnings"
872        );
873    }
874
875    #[test]
876    fn test_validate_inline_config_rules_alias() {
877        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
878        let warnings = validate_inline_config_rules(content);
879        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
880    }
881
882    #[test]
883    fn test_validate_inline_config_rules_multiple_unknown() {
884        let content = r#"<!-- rumdl-disable abc xyz -->
885<!-- rumdl-disable-line foo -->
886<!-- markdownlint-disable-next-line bar -->
887"#;
888        let warnings = validate_inline_config_rules(content);
889        assert_eq!(warnings.len(), 4);
890        assert_eq!(warnings[0].rule_name, "abc");
891        assert_eq!(warnings[1].rule_name, "xyz");
892        assert_eq!(warnings[2].rule_name, "foo");
893        assert_eq!(warnings[3].rule_name, "bar");
894    }
895
896    #[test]
897    fn test_validate_inline_config_rules_suggestion() {
898        // "MD00" should suggest "MD001" (or similar)
899        let content = "<!-- rumdl-disable MD00 -->\n";
900        let warnings = validate_inline_config_rules(content);
901        assert_eq!(warnings.len(), 1);
902        // Should have a suggestion since "MD00" is close to "MD001"
903        assert!(warnings[0].suggestion.is_some());
904    }
905
906    #[test]
907    fn test_validate_inline_config_rules_file_comments() {
908        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
909        let warnings = validate_inline_config_rules(content);
910        assert_eq!(warnings.len(), 2);
911        assert_eq!(warnings[0].comment_type, "disable-file");
912        assert_eq!(warnings[1].comment_type, "enable-file");
913    }
914
915    #[test]
916    fn test_validate_inline_config_rules_global_disable() {
917        // Global disable (no specific rules) should not produce warnings
918        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
919        let warnings = validate_inline_config_rules(content);
920        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
921    }
922
923    #[test]
924    fn test_validate_inline_config_rules_mixed_valid_invalid() {
925        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
926        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
927        let warnings = validate_inline_config_rules(content);
928        assert_eq!(warnings.len(), 2);
929        assert_eq!(warnings[0].rule_name, "abc");
930        assert_eq!(warnings[1].rule_name, "xyz");
931    }
932
933    #[test]
934    fn test_validate_inline_config_rules_configure_file() {
935        // configure-file comments contain rule names as JSON keys
936        let content =
937            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
938        let warnings = validate_inline_config_rules(content);
939        assert_eq!(warnings.len(), 1);
940        assert_eq!(warnings[0].rule_name, "nonexistent");
941        assert_eq!(warnings[0].comment_type, "configure-file");
942    }
943
944    #[test]
945    fn test_validate_inline_config_rules_markdownlint_variants() {
946        // Test markdownlint-* variants (not just rumdl-*)
947        let content = r#"<!-- markdownlint-disable unknown_rule -->
948<!-- markdownlint-enable another_fake -->
949<!-- markdownlint-disable-line bad_rule -->
950<!-- markdownlint-disable-next-line fake_rule -->
951<!-- markdownlint-disable-file missing_rule -->
952<!-- markdownlint-enable-file nonexistent -->
953"#;
954        let warnings = validate_inline_config_rules(content);
955        assert_eq!(warnings.len(), 6);
956        assert_eq!(warnings[0].rule_name, "unknown_rule");
957        assert_eq!(warnings[1].rule_name, "another_fake");
958        assert_eq!(warnings[2].rule_name, "bad_rule");
959        assert_eq!(warnings[3].rule_name, "fake_rule");
960        assert_eq!(warnings[4].rule_name, "missing_rule");
961        assert_eq!(warnings[5].rule_name, "nonexistent");
962    }
963
964    #[test]
965    fn test_validate_inline_config_rules_markdownlint_configure_file() {
966        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
967        let warnings = validate_inline_config_rules(content);
968        assert_eq!(warnings.len(), 1);
969        assert_eq!(warnings[0].rule_name, "fake_rule");
970        assert_eq!(warnings[0].comment_type, "configure-file");
971    }
972
973    #[test]
974    fn test_get_rule_config_from_configure_file() {
975        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
976
977This is a test line."#;
978
979        let inline_config = InlineConfig::from_content(content);
980        let config_override = inline_config.get_rule_config("MD013");
981
982        assert!(config_override.is_some(), "MD013 config should be found");
983        let json = config_override.unwrap();
984        assert!(json.is_object(), "Config should be an object");
985        let obj = json.as_object().unwrap();
986        assert!(obj.contains_key("line_length"), "Should have line_length key");
987        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
988    }
989
990    #[test]
991    fn test_get_rule_config_tables_false() {
992        // Test that tables=false inline config is correctly parsed
993        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
994
995        let inline_config = InlineConfig::from_content(content);
996        let config_override = inline_config.get_rule_config("MD013");
997
998        assert!(config_override.is_some(), "MD013 config should be found");
999        let json = config_override.unwrap();
1000        let obj = json.as_object().unwrap();
1001        assert!(obj.contains_key("tables"), "Should have tables key");
1002        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1003    }
1004}