Skip to main content

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