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).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
30}
31
32fn has_inline_config_markers(content: &str) -> bool {
33    if !content.contains("<!--") {
34        return false;
35    }
36    content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
37}
38
39/// Type alias for the export_for_file_index return type:
40/// (file_disabled_rules, persistent_transitions, line_disabled_rules)
41pub type FileIndexExport = (
42    HashSet<String>,
43    Vec<(usize, HashSet<String>, HashSet<String>)>,
44    HashMap<usize, HashSet<String>>,
45);
46
47/// A state transition recording which rules are disabled/enabled starting at a given line.
48/// Transitions are stored in ascending line order. The state at any line is determined by
49/// the most recent transition at or before that line.
50#[derive(Debug, Clone)]
51struct StateTransition {
52    /// The 1-indexed line number where this state takes effect
53    line: usize,
54    /// The set of disabled rules at this point ("*" means all rules disabled)
55    disabled: HashSet<String>,
56    /// The set of explicitly enabled rules (only meaningful when disabled contains "*")
57    enabled: HashSet<String>,
58}
59
60#[derive(Debug, Clone)]
61pub struct InlineConfig {
62    /// State transitions for persistent disable/enable directives, sorted by line number.
63    /// Only stores entries where the state actually changes, not for every line.
64    transitions: Vec<StateTransition>,
65    /// Rules disabled for specific lines via disable-line (1-indexed)
66    line_disabled_rules: HashMap<usize, HashSet<String>>,
67    /// Rules disabled for the entire file
68    file_disabled_rules: HashSet<String>,
69    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
70    file_enabled_rules: HashSet<String>,
71    /// Configuration overrides for specific rules from configure-file comments
72    /// Maps rule name to configuration JSON value
73    file_rule_config: HashMap<String, JsonValue>,
74}
75
76impl Default for InlineConfig {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl InlineConfig {
83    pub fn new() -> Self {
84        Self {
85            transitions: Vec::new(),
86            line_disabled_rules: HashMap::new(),
87            file_disabled_rules: HashSet::new(),
88            file_enabled_rules: HashSet::new(),
89            file_rule_config: HashMap::new(),
90        }
91    }
92
93    /// Find the state transition that applies to the given line number.
94    /// Uses binary search to find the last transition at or before the given line.
95    fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
96        if self.transitions.is_empty() {
97            return None;
98        }
99        // Binary search for the rightmost transition with line <= line_number
100        match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
101            Ok(idx) => Some(&self.transitions[idx]),
102            Err(idx) => {
103                if idx > 0 {
104                    Some(&self.transitions[idx - 1])
105                } else {
106                    None
107                }
108            }
109        }
110    }
111
112    /// Process all inline comments in the content and return the configuration state
113    pub fn from_content(content: &str) -> Self {
114        if !has_inline_config_markers(content) {
115            return Self::new();
116        }
117
118        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
119        Self::from_content_with_code_blocks_internal(content, &code_blocks)
120    }
121
122    /// Process all inline comments in the content with precomputed code blocks.
123    pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
124        if !has_inline_config_markers(content) {
125            return Self::new();
126        }
127
128        Self::from_content_with_code_blocks_internal(content, code_blocks)
129    }
130
131    fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
132        let mut config = Self::new();
133        let lines: Vec<&str> = content.lines().collect();
134
135        // Pre-compute line positions for checking if a line is in a code block
136        let mut line_positions = Vec::with_capacity(lines.len());
137        let mut pos = 0;
138        for line in &lines {
139            line_positions.push(pos);
140            pos += line.len() + 1; // +1 for newline
141        }
142
143        // Track current state of disabled rules
144        let mut currently_disabled: HashSet<String> = HashSet::new();
145        let mut currently_enabled: HashSet<String> = HashSet::new();
146        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
147
148        // Track the previously recorded transition state to detect changes
149        let mut prev_disabled: HashSet<String> = HashSet::new();
150        let mut prev_enabled: HashSet<String> = HashSet::new();
151
152        // Record initial state (line 1: nothing disabled)
153        config.transitions.push(StateTransition {
154            line: 1,
155            disabled: HashSet::new(),
156            enabled: HashSet::new(),
157        });
158
159        for (idx, line) in lines.iter().enumerate() {
160            let line_num = idx + 1; // 1-indexed
161
162            // Record a transition only if state changed since last recorded transition.
163            // State for this line is the state BEFORE processing comments on this line.
164            if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
165                config.transitions.push(StateTransition {
166                    line: line_num,
167                    disabled: currently_disabled.clone(),
168                    enabled: currently_enabled.clone(),
169                });
170                prev_disabled.clone_from(&currently_disabled);
171                prev_enabled.clone_from(&currently_enabled);
172            }
173
174            // Skip processing if this line is inside a code block
175            let line_start = line_positions[idx];
176            let line_end = line_start + line.len();
177            let in_code_block = code_blocks
178                .iter()
179                .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
180
181            if in_code_block {
182                continue;
183            }
184
185            // Parse all directives on this line once via the unified parser.
186            // Directives come back in left-to-right order with correct disambiguation.
187            let directives = parse_inline_directives(line);
188
189            // Also check for prettier-ignore (not part of the rumdl/markdownlint format)
190            let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
191
192            // Pass 1: file-wide directives (affect the entire file, not state-tracked)
193            for directive in &directives {
194                match directive.kind {
195                    DirectiveKind::DisableFile => {
196                        if directive.rules.is_empty() {
197                            config.file_disabled_rules.clear();
198                            config.file_disabled_rules.insert("*".to_string());
199                        } else if config.file_disabled_rules.contains("*") {
200                            for rule in &directive.rules {
201                                config.file_enabled_rules.remove(&normalize_rule_name(rule));
202                            }
203                        } else {
204                            for rule in &directive.rules {
205                                config.file_disabled_rules.insert(normalize_rule_name(rule));
206                            }
207                        }
208                    }
209                    DirectiveKind::EnableFile => {
210                        if directive.rules.is_empty() {
211                            config.file_disabled_rules.clear();
212                            config.file_enabled_rules.clear();
213                        } else if config.file_disabled_rules.contains("*") {
214                            for rule in &directive.rules {
215                                config.file_enabled_rules.insert(normalize_rule_name(rule));
216                            }
217                        } else {
218                            for rule in &directive.rules {
219                                config.file_disabled_rules.remove(&normalize_rule_name(rule));
220                            }
221                        }
222                    }
223                    DirectiveKind::ConfigureFile => {
224                        if let Some(json_config) = parse_configure_file_comment(line)
225                            && let Some(obj) = json_config.as_object()
226                        {
227                            for (rule_name, rule_config) in obj {
228                                config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
229                            }
230                        }
231                    }
232                    _ => {}
233                }
234            }
235
236            // Pass 2: line-specific and state-changing directives (in document order)
237            for directive in &directives {
238                match directive.kind {
239                    DirectiveKind::DisableNextLine => {
240                        let next_line = line_num + 1;
241                        let line_rules = config.line_disabled_rules.entry(next_line).or_default();
242                        if directive.rules.is_empty() {
243                            line_rules.insert("*".to_string());
244                        } else {
245                            for rule in &directive.rules {
246                                line_rules.insert(normalize_rule_name(rule));
247                            }
248                        }
249                    }
250                    DirectiveKind::DisableLine => {
251                        let line_rules = config.line_disabled_rules.entry(line_num).or_default();
252                        if directive.rules.is_empty() {
253                            line_rules.insert("*".to_string());
254                        } else {
255                            for rule in &directive.rules {
256                                line_rules.insert(normalize_rule_name(rule));
257                            }
258                        }
259                    }
260                    DirectiveKind::Disable => {
261                        if directive.rules.is_empty() {
262                            currently_disabled.clear();
263                            currently_disabled.insert("*".to_string());
264                            currently_enabled.clear();
265                        } else if currently_disabled.contains("*") {
266                            for rule in &directive.rules {
267                                currently_enabled.remove(&normalize_rule_name(rule));
268                            }
269                        } else {
270                            for rule in &directive.rules {
271                                currently_disabled.insert(normalize_rule_name(rule));
272                            }
273                        }
274                    }
275                    DirectiveKind::Enable => {
276                        if directive.rules.is_empty() {
277                            currently_disabled.clear();
278                            currently_enabled.clear();
279                        } else if currently_disabled.contains("*") {
280                            for rule in &directive.rules {
281                                currently_enabled.insert(normalize_rule_name(rule));
282                            }
283                        } else {
284                            for rule in &directive.rules {
285                                currently_disabled.remove(&normalize_rule_name(rule));
286                            }
287                        }
288                    }
289                    DirectiveKind::Capture => {
290                        capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
291                    }
292                    DirectiveKind::Restore => {
293                        if let Some((disabled, enabled)) = capture_stack.pop() {
294                            currently_disabled = disabled;
295                            currently_enabled = enabled;
296                        }
297                    }
298                    // File-wide directives already handled in pass 1
299                    DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
300                }
301            }
302
303            // prettier-ignore: disables all rules for next line
304            if has_prettier_ignore {
305                let next_line = line_num + 1;
306                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
307                line_rules.insert("*".to_string());
308            }
309        }
310
311        // Record final transition if state changed after the last line was processed
312        if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
313            config.transitions.push(StateTransition {
314                line: lines.len() + 1,
315                disabled: currently_disabled,
316                enabled: currently_enabled,
317            });
318        }
319
320        config
321    }
322
323    /// Check if a rule is disabled at a specific line
324    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
325        // Check file-wide disables first (highest priority)
326        if self.file_disabled_rules.contains("*") {
327            // All rules are disabled for the file, check if this rule is explicitly enabled
328            return !self.file_enabled_rules.contains(rule_name);
329        } else if self.file_disabled_rules.contains(rule_name) {
330            return true;
331        }
332
333        // Check line-specific disables (disable-line, disable-next-line)
334        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
335            && (line_rules.contains("*") || line_rules.contains(rule_name))
336        {
337            return true;
338        }
339
340        // Check persistent disables via state transitions (binary search)
341        if let Some(transition) = self.find_transition(line_number) {
342            if transition.disabled.contains("*") {
343                return !transition.enabled.contains(rule_name);
344            } else {
345                return transition.disabled.contains(rule_name);
346            }
347        }
348
349        false
350    }
351
352    /// Get all disabled rules at a specific line
353    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
354        let mut disabled = HashSet::new();
355
356        // Add persistent disables via state transitions (binary search)
357        if let Some(transition) = self.find_transition(line_number) {
358            if transition.disabled.contains("*") {
359                disabled.insert("*".to_string());
360            } else {
361                for rule in &transition.disabled {
362                    disabled.insert(rule.clone());
363                }
364            }
365        }
366
367        // Add line-specific disables
368        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
369            for rule in line_rules {
370                disabled.insert(rule.clone());
371            }
372        }
373
374        disabled
375    }
376
377    /// Get configuration overrides for a specific rule from configure-file comments
378    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
379        self.file_rule_config.get(rule_name)
380    }
381
382    /// Get all configuration overrides from configure-file comments
383    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
384        &self.file_rule_config
385    }
386
387    /// Export the disabled rules data for storage in FileIndex.
388    ///
389    /// Returns (file_disabled_rules, persistent_transitions, line_disabled_rules).
390    pub fn export_for_file_index(&self) -> FileIndexExport {
391        let file_disabled = self.file_disabled_rules.clone();
392
393        let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
394            .transitions
395            .iter()
396            .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
397            .collect();
398
399        let line_disabled = self.line_disabled_rules.clone();
400
401        (file_disabled, persistent_transitions, line_disabled)
402    }
403}
404
405// ── Unified inline directive parser ──────────────────────────────────────────
406//
407// All inline config comments follow one pattern:
408//   <!-- (rumdl|markdownlint)-KEYWORD [RULES...] -->
409//
410// Disambiguation (e.g., "disable" vs "disable-line" vs "disable-next-line")
411// is handled ONCE here by matching the longest keyword first.
412
413/// The type of an inline configuration directive.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum DirectiveKind {
416    Disable,
417    DisableLine,
418    DisableNextLine,
419    DisableFile,
420    Enable,
421    EnableFile,
422    Capture,
423    Restore,
424    ConfigureFile,
425}
426
427/// A parsed inline configuration directive.
428#[derive(Debug, Clone, PartialEq)]
429pub struct InlineDirective<'a> {
430    pub kind: DirectiveKind,
431    pub rules: Vec<&'a str>,
432}
433
434/// Tool prefixes recognized in inline config comments.
435const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
436
437/// Directive keywords ordered so that more-specific prefixes come first.
438/// "disable-next-line" before "disable-line" before "disable-file" before "disable";
439/// "enable-file" before "enable". This ensures longest-match-first disambiguation.
440const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
441    (DirectiveKind::DisableNextLine, "disable-next-line"),
442    (DirectiveKind::DisableLine, "disable-line"),
443    (DirectiveKind::DisableFile, "disable-file"),
444    (DirectiveKind::Disable, "disable"),
445    (DirectiveKind::EnableFile, "enable-file"),
446    (DirectiveKind::Enable, "enable"),
447    (DirectiveKind::ConfigureFile, "configure-file"),
448    (DirectiveKind::Capture, "capture"),
449    (DirectiveKind::Restore, "restore"),
450];
451
452/// Try to parse a single directive from text immediately after `<!-- `.
453/// Returns the directive and the number of bytes consumed (from `s` onward)
454/// so the caller can advance past `-->`.
455fn try_parse_directive(s: &str) -> Option<(InlineDirective<'_>, usize)> {
456    for tool in TOOL_PREFIXES {
457        if !s.starts_with(tool) {
458            continue;
459        }
460        let after_tool = &s[tool.len()..];
461
462        for &(kind, keyword) in DIRECTIVE_KEYWORDS {
463            if !after_tool.starts_with(keyword) {
464                continue;
465            }
466            let after_kw = &after_tool[keyword.len()..];
467
468            // Word boundary: the keyword must be followed by whitespace, `-->`, or end-of-string.
469            // This prevents "disablefoo" from matching "disable".
470            if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
471                continue;
472            }
473
474            // Find closing -->
475            let close_offset = after_kw.find("-->")?;
476
477            let rules_str = after_kw[..close_offset].trim();
478            let rules = if rules_str.is_empty() {
479                Vec::new()
480            } else {
481                rules_str.split_whitespace().collect()
482            };
483
484            let consumed = tool.len() + keyword.len() + close_offset + 3; // 3 for "-->"
485            return Some((InlineDirective { kind, rules }, consumed));
486        }
487
488        // Tool prefix matched but no keyword — not a directive we recognize.
489        return None;
490    }
491    None
492}
493
494/// Parse all inline configuration directives from a line, in left-to-right order.
495///
496/// Each directive is a typed `InlineDirective` with its kind and rule list.
497/// Disambiguation between overlapping prefixes (e.g., `disable` vs `disable-line`)
498/// is handled by matching the longest keyword first — no ad-hoc guards needed.
499pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
500    let mut results = Vec::new();
501    let mut pos = 0;
502
503    while pos < line.len() {
504        let remaining = &line[pos..];
505        let Some(open_offset) = remaining.find("<!-- ") else {
506            break;
507        };
508        let comment_start = pos + open_offset;
509        let after_open = &line[comment_start + 5..]; // skip "<!-- "
510
511        if let Some((directive, consumed)) = try_parse_directive(after_open) {
512            results.push(directive);
513            pos = comment_start + 5 + consumed;
514        } else {
515            pos = comment_start + 5;
516        }
517    }
518
519    results
520}
521
522// ── Backward-compatible wrapper functions ────────────────────────────────────
523//
524// These delegate to parse_inline_directives and filter by DirectiveKind.
525// External callers (e.g., MD040) use these; internal code uses the unified parser.
526
527fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
528    parse_inline_directives(line)
529        .into_iter()
530        .find(|d| d.kind == kind)
531        .map(|d| d.rules)
532}
533
534/// Parse a disable comment and return the list of rules (empty vec means all rules)
535pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
536    find_directive_rules(line, DirectiveKind::Disable)
537}
538
539/// Parse an enable comment and return the list of rules (empty vec means all rules)
540pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
541    find_directive_rules(line, DirectiveKind::Enable)
542}
543
544/// Parse a disable-line comment
545pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
546    find_directive_rules(line, DirectiveKind::DisableLine)
547}
548
549/// Parse a disable-next-line comment
550pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
551    find_directive_rules(line, DirectiveKind::DisableNextLine)
552}
553
554/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
555pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
556    find_directive_rules(line, DirectiveKind::DisableFile)
557}
558
559/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
560pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
561    find_directive_rules(line, DirectiveKind::EnableFile)
562}
563
564/// Check if line contains a capture comment
565pub fn is_capture_comment(line: &str) -> bool {
566    parse_inline_directives(line)
567        .iter()
568        .any(|d| d.kind == DirectiveKind::Capture)
569}
570
571/// Check if line contains a restore comment
572pub fn is_restore_comment(line: &str) -> bool {
573    parse_inline_directives(line)
574        .iter()
575        .any(|d| d.kind == DirectiveKind::Restore)
576}
577
578/// Parse a configure-file comment and return the JSON configuration.
579///
580/// Uses the unified parser for directive detection/disambiguation, then
581/// extracts the raw JSON payload directly from the line (since JSON
582/// cannot be reliably reconstructed from whitespace-split tokens).
583pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
584    // First check if the unified parser even found a configure-file directive
585    if !parse_inline_directives(line)
586        .iter()
587        .any(|d| d.kind == DirectiveKind::ConfigureFile)
588    {
589        return None;
590    }
591
592    // Extract the raw JSON content between the keyword and -->
593    for tool in TOOL_PREFIXES {
594        let prefix = format!("<!-- {tool}configure-file");
595        if let Some(start) = line.find(&prefix) {
596            let after_prefix = &line[start + prefix.len()..];
597            if let Some(end) = after_prefix.find("-->") {
598                let json_str = after_prefix[..end].trim();
599                if !json_str.is_empty()
600                    && let Ok(value) = serde_json::from_str(json_str)
601                {
602                    return Some(value);
603                }
604            }
605        }
606    }
607    None
608}
609
610/// Warning about unknown rules in inline config comments
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct InlineConfigWarning {
613    /// The line number where the warning occurred (1-indexed)
614    pub line_number: usize,
615    /// The rule name that was not recognized
616    pub rule_name: String,
617    /// The type of inline config comment
618    pub comment_type: String,
619    /// Optional suggestion for similar rule names
620    pub suggestion: Option<String>,
621}
622
623impl InlineConfigWarning {
624    /// Format the warning message
625    pub fn format_message(&self) -> String {
626        if let Some(ref suggestion) = self.suggestion {
627            format!(
628                "Unknown rule in inline {} comment: {} (did you mean: {}?)",
629                self.comment_type, self.rule_name, suggestion
630            )
631        } else {
632            format!(
633                "Unknown rule in inline {} comment: {}",
634                self.comment_type, self.rule_name
635            )
636        }
637    }
638
639    /// Print the warning to stderr with file context
640    pub fn print_warning(&self, file_path: &str) {
641        eprintln!(
642            "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
643            file_path,
644            self.line_number,
645            self.format_message()
646        );
647    }
648}
649
650/// Validate all inline config comments in content and return warnings for unknown rules.
651///
652/// This function extracts rule names from all types of inline config comments
653/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
654/// and validates them against the known rule alias map.
655pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
656    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
657
658    let mut warnings = Vec::new();
659    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
660
661    for (idx, line) in content.lines().enumerate() {
662        let line_num = idx + 1;
663
664        // Parse all directives on this line once
665        let directives = parse_inline_directives(line);
666        let mut rule_entries: Vec<(&str, &str)> = Vec::new();
667
668        for directive in &directives {
669            let comment_type = match directive.kind {
670                DirectiveKind::Disable => "disable",
671                DirectiveKind::Enable => "enable",
672                DirectiveKind::DisableLine => "disable-line",
673                DirectiveKind::DisableNextLine => "disable-next-line",
674                DirectiveKind::DisableFile => "disable-file",
675                DirectiveKind::EnableFile => "enable-file",
676                DirectiveKind::ConfigureFile => {
677                    // configure-file: rule names are JSON keys, handle separately
678                    if let Some(json_config) = parse_configure_file_comment(line)
679                        && let Some(obj) = json_config.as_object()
680                    {
681                        for rule_name in obj.keys() {
682                            if !is_valid_rule_name(rule_name) {
683                                let suggestion = suggest_similar_key(rule_name, &all_rule_names)
684                                    .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
685                                warnings.push(InlineConfigWarning {
686                                    line_number: line_num,
687                                    rule_name: rule_name.clone(),
688                                    comment_type: "configure-file".to_string(),
689                                    suggestion,
690                                });
691                            }
692                        }
693                    }
694                    continue;
695                }
696                DirectiveKind::Capture | DirectiveKind::Restore => continue,
697            };
698            for rule in &directive.rules {
699                rule_entries.push((rule, comment_type));
700            }
701        }
702
703        // Validate each rule name
704        for (rule_name, comment_type) in rule_entries {
705            if !is_valid_rule_name(rule_name) {
706                let suggestion = suggest_similar_key(rule_name, &all_rule_names)
707                    .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
708                warnings.push(InlineConfigWarning {
709                    line_number: line_num,
710                    rule_name: rule_name.to_string(),
711                    comment_type: comment_type.to_string(),
712                    suggestion,
713                });
714            }
715        }
716    }
717
718    warnings
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    // ── Unified parser tests ─────────────────────────────────────────────
726
727    #[test]
728    fn test_parse_inline_directives_all_kinds() {
729        // Every directive kind is correctly identified
730        let cases: &[(&str, DirectiveKind)] = &[
731            ("<!-- rumdl-disable -->", DirectiveKind::Disable),
732            ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
733            ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
734            ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
735            ("<!-- rumdl-enable -->", DirectiveKind::Enable),
736            ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
737            ("<!-- rumdl-capture -->", DirectiveKind::Capture),
738            ("<!-- rumdl-restore -->", DirectiveKind::Restore),
739            ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
740            // markdownlint variants
741            ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
742            ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
743            (
744                "<!-- markdownlint-disable-next-line -->",
745                DirectiveKind::DisableNextLine,
746            ),
747            ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
748            ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
749            ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
750        ];
751        for (input, expected_kind) in cases {
752            let directives = parse_inline_directives(input);
753            assert_eq!(
754                directives.len(),
755                1,
756                "Expected 1 directive for {input:?}, got {directives:?}"
757            );
758            assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
759        }
760    }
761
762    #[test]
763    fn test_parse_inline_directives_disambiguation() {
764        // The core property: "disable" must NOT match "disable-line" etc.
765        let line = "<!-- rumdl-disable-line MD001 -->";
766        let directives = parse_inline_directives(line);
767        assert_eq!(directives.len(), 1);
768        assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
769
770        let line = "<!-- rumdl-disable-next-line -->";
771        let directives = parse_inline_directives(line);
772        assert_eq!(directives.len(), 1);
773        assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
774
775        let line = "<!-- rumdl-disable-file MD001 -->";
776        let directives = parse_inline_directives(line);
777        assert_eq!(directives.len(), 1);
778        assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
779
780        let line = "<!-- rumdl-enable-file -->";
781        let directives = parse_inline_directives(line);
782        assert_eq!(directives.len(), 1);
783        assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
784    }
785
786    #[test]
787    fn test_parse_inline_directives_no_space_before_close() {
788        // <!-- rumdl-disable--> must parse as Disable (the bug that started this refactor)
789        let directives = parse_inline_directives("<!-- rumdl-disable-->");
790        assert_eq!(directives.len(), 1);
791        assert_eq!(directives[0].kind, DirectiveKind::Disable);
792        assert!(directives[0].rules.is_empty());
793
794        let directives = parse_inline_directives("<!-- rumdl-enable-->");
795        assert_eq!(directives.len(), 1);
796        assert_eq!(directives[0].kind, DirectiveKind::Enable);
797    }
798
799    #[test]
800    fn test_parse_inline_directives_multiple_on_one_line() {
801        let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
802        let directives = parse_inline_directives(line);
803        assert_eq!(directives.len(), 2);
804        assert_eq!(directives[0].kind, DirectiveKind::Disable);
805        assert_eq!(directives[0].rules, vec!["MD001"]);
806        assert_eq!(directives[1].kind, DirectiveKind::Enable);
807        assert_eq!(directives[1].rules, vec!["MD001"]);
808    }
809
810    #[test]
811    fn test_parse_inline_directives_global_disable_then_specific_enable() {
812        let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
813        let directives = parse_inline_directives(line);
814        assert_eq!(directives.len(), 2);
815        assert_eq!(directives[0].kind, DirectiveKind::Disable);
816        assert!(directives[0].rules.is_empty());
817        assert_eq!(directives[1].kind, DirectiveKind::Enable);
818        assert_eq!(directives[1].rules, vec!["MD001"]);
819    }
820
821    #[test]
822    fn test_parse_inline_directives_word_boundary() {
823        // "disablefoo" should NOT match "disable"
824        assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
825        // "enablebar" should NOT match "enable"
826        assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
827        // "captures" should NOT match "capture"
828        assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
829    }
830
831    #[test]
832    fn test_parse_inline_directives_no_closing_tag() {
833        // Missing --> means no directive
834        assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
835        assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
836    }
837
838    #[test]
839    fn test_parse_inline_directives_not_a_comment() {
840        assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
841        assert!(parse_inline_directives("Some regular text").is_empty());
842        assert!(parse_inline_directives("").is_empty());
843    }
844
845    #[test]
846    fn test_parse_inline_directives_case_sensitive() {
847        assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
848        assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
849    }
850
851    #[test]
852    fn test_parse_inline_directives_rules_extraction() {
853        let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
854        assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
855
856        // Tabs between rules
857        let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
858        assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
859
860        // Extra whitespace
861        let directives = parse_inline_directives("<!-- rumdl-disable   MD001   -->");
862        assert_eq!(directives[0].rules, vec!["MD001"]);
863    }
864
865    #[test]
866    fn test_parse_inline_directives_embedded_in_text() {
867        let line = "Some text <!-- rumdl-disable MD001 --> more text";
868        let directives = parse_inline_directives(line);
869        assert_eq!(directives.len(), 1);
870        assert_eq!(directives[0].rules, vec!["MD001"]);
871
872        let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
873        let directives = parse_inline_directives(line);
874        assert_eq!(directives.len(), 1);
875        assert_eq!(directives[0].rules, vec!["MD001"]);
876    }
877
878    #[test]
879    fn test_parse_inline_directives_mixed_tools_same_line() {
880        let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
881        let directives = parse_inline_directives(line);
882        assert_eq!(directives.len(), 2);
883        assert_eq!(directives[0].kind, DirectiveKind::Disable);
884        assert_eq!(directives[0].rules, vec!["MD001"]);
885        assert_eq!(directives[1].kind, DirectiveKind::Enable);
886        assert_eq!(directives[1].rules, vec!["MD002"]);
887    }
888
889    // ── Backward-compatible wrapper tests ────────────────────────────────
890
891    #[test]
892    fn test_parse_disable_comment() {
893        // Global disable
894        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
895        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
896
897        // Specific rules
898        assert_eq!(
899            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
900            Some(vec!["MD001", "MD002"])
901        );
902
903        // No comment
904        assert_eq!(parse_disable_comment("Some regular text"), None);
905    }
906
907    #[test]
908    fn test_parse_disable_line_comment() {
909        // Global disable-line
910        assert_eq!(
911            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
912            Some(vec![])
913        );
914
915        // Specific rules
916        assert_eq!(
917            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
918            Some(vec!["MD013"])
919        );
920
921        // No comment
922        assert_eq!(parse_disable_line_comment("Some regular text"), None);
923    }
924
925    #[test]
926    fn test_inline_config_from_content() {
927        let content = r#"# Test Document
928
929<!-- markdownlint-disable MD013 -->
930This is a very long line that would normally trigger MD013 but it's disabled
931
932<!-- markdownlint-enable MD013 -->
933This line will be checked again
934
935<!-- markdownlint-disable-next-line MD001 -->
936# This heading will not be checked for MD001
937## But this one will
938
939Some text <!-- markdownlint-disable-line MD013 -->
940
941<!-- markdownlint-capture -->
942<!-- markdownlint-disable MD001 MD002 -->
943# Heading with MD001 disabled
944<!-- markdownlint-restore -->
945# Heading with MD001 enabled again
946"#;
947
948        let config = InlineConfig::from_content(content);
949
950        // Line 4 should have MD013 disabled (line after disable comment on line 3)
951        assert!(config.is_rule_disabled("MD013", 4));
952
953        // Line 7 should have MD013 enabled (line after enable comment on line 6)
954        assert!(!config.is_rule_disabled("MD013", 7));
955
956        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
957        assert!(config.is_rule_disabled("MD001", 10));
958
959        // Line 11 should not have MD001 disabled
960        assert!(!config.is_rule_disabled("MD001", 11));
961
962        // Line 13 should have MD013 disabled (from disable-line)
963        assert!(config.is_rule_disabled("MD013", 13));
964
965        // After restore (line 18), MD001 should be enabled again on line 19
966        assert!(!config.is_rule_disabled("MD001", 19));
967    }
968
969    #[test]
970    fn test_capture_restore() {
971        let content = r#"<!-- markdownlint-disable MD001 -->
972<!-- markdownlint-capture -->
973<!-- markdownlint-disable MD002 MD003 -->
974<!-- markdownlint-restore -->
975Some content after restore
976"#;
977
978        let config = InlineConfig::from_content(content);
979
980        // After restore (line 4), line 5 should only have MD001 disabled
981        assert!(config.is_rule_disabled("MD001", 5));
982        assert!(!config.is_rule_disabled("MD002", 5));
983        assert!(!config.is_rule_disabled("MD003", 5));
984    }
985
986    #[test]
987    fn test_validate_inline_config_rules_unknown_rule() {
988        let content = "<!-- rumdl-disable abc -->\nSome content";
989        let warnings = validate_inline_config_rules(content);
990        assert_eq!(warnings.len(), 1);
991        assert_eq!(warnings[0].line_number, 1);
992        assert_eq!(warnings[0].rule_name, "abc");
993        assert_eq!(warnings[0].comment_type, "disable");
994    }
995
996    #[test]
997    fn test_validate_inline_config_rules_valid_rule() {
998        let content = "<!-- rumdl-disable MD001 -->\nSome content";
999        let warnings = validate_inline_config_rules(content);
1000        assert!(
1001            warnings.is_empty(),
1002            "MD001 is a valid rule, should not produce warnings"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_validate_inline_config_rules_alias() {
1008        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1009        let warnings = validate_inline_config_rules(content);
1010        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1011    }
1012
1013    #[test]
1014    fn test_validate_inline_config_rules_multiple_unknown() {
1015        let content = r#"<!-- rumdl-disable abc xyz -->
1016<!-- rumdl-disable-line foo -->
1017<!-- markdownlint-disable-next-line bar -->
1018"#;
1019        let warnings = validate_inline_config_rules(content);
1020        assert_eq!(warnings.len(), 4);
1021        assert_eq!(warnings[0].rule_name, "abc");
1022        assert_eq!(warnings[1].rule_name, "xyz");
1023        assert_eq!(warnings[2].rule_name, "foo");
1024        assert_eq!(warnings[3].rule_name, "bar");
1025    }
1026
1027    #[test]
1028    fn test_validate_inline_config_rules_suggestion() {
1029        // "MD00" should suggest "MD001" (or similar)
1030        let content = "<!-- rumdl-disable MD00 -->\n";
1031        let warnings = validate_inline_config_rules(content);
1032        assert_eq!(warnings.len(), 1);
1033        // Should have a suggestion since "MD00" is close to "MD001"
1034        assert!(warnings[0].suggestion.is_some());
1035    }
1036
1037    #[test]
1038    fn test_validate_inline_config_rules_file_comments() {
1039        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1040        let warnings = validate_inline_config_rules(content);
1041        assert_eq!(warnings.len(), 2);
1042        assert_eq!(warnings[0].comment_type, "disable-file");
1043        assert_eq!(warnings[1].comment_type, "enable-file");
1044    }
1045
1046    #[test]
1047    fn test_validate_inline_config_rules_global_disable() {
1048        // Global disable (no specific rules) should not produce warnings
1049        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1050        let warnings = validate_inline_config_rules(content);
1051        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1052    }
1053
1054    #[test]
1055    fn test_validate_inline_config_rules_mixed_valid_invalid() {
1056        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
1057        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1058        let warnings = validate_inline_config_rules(content);
1059        assert_eq!(warnings.len(), 2);
1060        assert_eq!(warnings[0].rule_name, "abc");
1061        assert_eq!(warnings[1].rule_name, "xyz");
1062    }
1063
1064    #[test]
1065    fn test_validate_inline_config_rules_configure_file() {
1066        // configure-file comments contain rule names as JSON keys
1067        let content =
1068            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1069        let warnings = validate_inline_config_rules(content);
1070        assert_eq!(warnings.len(), 1);
1071        assert_eq!(warnings[0].rule_name, "nonexistent");
1072        assert_eq!(warnings[0].comment_type, "configure-file");
1073    }
1074
1075    #[test]
1076    fn test_validate_inline_config_rules_markdownlint_variants() {
1077        // Test markdownlint-* variants (not just rumdl-*)
1078        let content = r#"<!-- markdownlint-disable unknown_rule -->
1079<!-- markdownlint-enable another_fake -->
1080<!-- markdownlint-disable-line bad_rule -->
1081<!-- markdownlint-disable-next-line fake_rule -->
1082<!-- markdownlint-disable-file missing_rule -->
1083<!-- markdownlint-enable-file nonexistent -->
1084"#;
1085        let warnings = validate_inline_config_rules(content);
1086        assert_eq!(warnings.len(), 6);
1087        assert_eq!(warnings[0].rule_name, "unknown_rule");
1088        assert_eq!(warnings[1].rule_name, "another_fake");
1089        assert_eq!(warnings[2].rule_name, "bad_rule");
1090        assert_eq!(warnings[3].rule_name, "fake_rule");
1091        assert_eq!(warnings[4].rule_name, "missing_rule");
1092        assert_eq!(warnings[5].rule_name, "nonexistent");
1093    }
1094
1095    #[test]
1096    fn test_validate_inline_config_rules_markdownlint_configure_file() {
1097        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1098        let warnings = validate_inline_config_rules(content);
1099        assert_eq!(warnings.len(), 1);
1100        assert_eq!(warnings[0].rule_name, "fake_rule");
1101        assert_eq!(warnings[0].comment_type, "configure-file");
1102    }
1103
1104    #[test]
1105    fn test_get_rule_config_from_configure_file() {
1106        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1107
1108This is a test line."#;
1109
1110        let inline_config = InlineConfig::from_content(content);
1111        let config_override = inline_config.get_rule_config("MD013");
1112
1113        assert!(config_override.is_some(), "MD013 config should be found");
1114        let json = config_override.unwrap();
1115        assert!(json.is_object(), "Config should be an object");
1116        let obj = json.as_object().unwrap();
1117        assert!(obj.contains_key("line_length"), "Should have line_length key");
1118        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1119    }
1120
1121    #[test]
1122    fn test_get_rule_config_tables_false() {
1123        // Test that tables=false inline config is correctly parsed
1124        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1125
1126        let inline_config = InlineConfig::from_content(content);
1127        let config_override = inline_config.get_rule_config("MD013");
1128
1129        assert!(config_override.is_some(), "MD013 config should be found");
1130        let json = config_override.unwrap();
1131        let obj = json.as_object().unwrap();
1132        assert!(obj.contains_key("tables"), "Should have tables key");
1133        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1134    }
1135
1136    // ── parse_disable_comment / parse_enable_comment edge cases ──────────
1137
1138    #[test]
1139    fn test_parse_disable_does_not_match_disable_line() {
1140        // parse_disable_comment must NOT match disable-line or disable-next-line
1141        assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
1142        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
1143        assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
1144        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
1145        assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
1146        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
1147    }
1148
1149    #[test]
1150    fn test_parse_enable_does_not_match_enable_file() {
1151        assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
1152        assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
1153    }
1154
1155    #[test]
1156    fn test_parse_disable_comment_edge_cases() {
1157        // No space before closing
1158        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
1159
1160        // Tabs between rules
1161        assert_eq!(
1162            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
1163            Some(vec!["MD001", "MD002"])
1164        );
1165
1166        // Comment not at start of line
1167        assert_eq!(
1168            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
1169            Some(vec!["MD001"])
1170        );
1171
1172        // Malformed: no closing
1173        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
1174
1175        // Malformed: no opening
1176        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
1177
1178        // Case sensitive: uppercase should not match
1179        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
1180
1181        // Empty rule list with whitespace
1182        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
1183
1184        // Duplicate rules preserved (caller may deduplicate)
1185        assert_eq!(
1186            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
1187            Some(vec!["MD001", "MD001", "MD002"])
1188        );
1189
1190        // Unicode around the comment
1191        assert_eq!(
1192            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
1193            Some(vec!["MD001"])
1194        );
1195
1196        // 100 rules
1197        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
1198        let comment = format!("<!-- rumdl-disable {many_rules} -->");
1199        let parsed = parse_disable_comment(&comment);
1200        assert!(parsed.is_some());
1201        assert_eq!(parsed.unwrap().len(), 100);
1202
1203        // Special characters in rule names (forward compat)
1204        assert_eq!(
1205            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
1206            Some(vec!["MD001-test"])
1207        );
1208        assert_eq!(
1209            parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
1210            Some(vec!["custom_rule"])
1211        );
1212    }
1213
1214    #[test]
1215    fn test_parse_enable_comment_edge_cases() {
1216        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
1217        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
1218        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
1219        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
1220    }
1221
1222    // ── InlineConfig: code blocks must be transparent ────────────────────
1223
1224    #[test]
1225    fn test_disable_inside_fenced_code_block_ignored() {
1226        let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
1227        let config = InlineConfig::from_content(content);
1228        // The disable comment is inside a code block — must have no effect
1229        assert!(!config.is_rule_disabled("MD001", 6));
1230    }
1231
1232    #[test]
1233    fn test_disable_inside_tilde_fence_ignored() {
1234        let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
1235        let config = InlineConfig::from_content(content);
1236        assert!(!config.is_rule_disabled("MD001", 6));
1237    }
1238
1239    #[test]
1240    fn test_disable_before_code_block_persists_after() {
1241        // Disable before code block should persist through and after it
1242        let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
1243        let config = InlineConfig::from_content(content);
1244        assert!(config.is_rule_disabled("MD001", 5));
1245    }
1246
1247    #[test]
1248    fn test_enable_inside_code_block_ignored() {
1249        // Disable before, enable inside code block (should be ignored), still disabled after
1250        let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
1251        let config = InlineConfig::from_content(content);
1252        assert!(config.is_rule_disabled("MD001", 5));
1253    }
1254
1255    // ── InlineConfig: mixed comment styles ───────────────────────────────
1256
1257    #[test]
1258    fn test_markdownlint_disable_rumdl_enable_interop() {
1259        let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
1260        let config = InlineConfig::from_content(content);
1261        assert!(config.is_rule_disabled("MD001", 2));
1262        assert!(!config.is_rule_disabled("MD001", 4));
1263    }
1264
1265    #[test]
1266    fn test_rumdl_disable_markdownlint_enable_interop() {
1267        let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
1268        let config = InlineConfig::from_content(content);
1269        assert!(config.is_rule_disabled("MD013", 2));
1270        assert!(!config.is_rule_disabled("MD013", 4));
1271    }
1272
1273    // ── InlineConfig: nested/overlapping disable/enable ──────────────────
1274
1275    #[test]
1276    fn test_global_disable_then_specific_enable() {
1277        let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
1278        let config = InlineConfig::from_content(content);
1279        assert!(!config.is_rule_disabled("MD001", 4));
1280        assert!(config.is_rule_disabled("MD002", 4));
1281        assert!(config.is_rule_disabled("MD013", 4));
1282    }
1283
1284    #[test]
1285    fn test_specific_disable_then_global_enable() {
1286        let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
1287        let config = InlineConfig::from_content(content);
1288        assert!(config.is_rule_disabled("MD001", 2));
1289        assert!(config.is_rule_disabled("MD002", 2));
1290        assert!(!config.is_rule_disabled("MD001", 4));
1291        assert!(!config.is_rule_disabled("MD002", 4));
1292    }
1293
1294    #[test]
1295    fn test_multiple_rules_disable_enable_independently() {
1296        let content = "\
1297Line 1\n\
1298<!-- rumdl-disable MD001 MD002 -->\n\
1299Line 3\n\
1300<!-- rumdl-enable MD001 -->\n\
1301Line 5\n\
1302<!-- rumdl-disable -->\n\
1303Line 7\n\
1304<!-- rumdl-enable MD002 -->\n\
1305Line 9\n";
1306        let config = InlineConfig::from_content(content);
1307
1308        // Line 1: nothing disabled
1309        assert!(!config.is_rule_disabled("MD001", 1));
1310        assert!(!config.is_rule_disabled("MD002", 1));
1311
1312        // Line 3: both disabled
1313        assert!(config.is_rule_disabled("MD001", 3));
1314        assert!(config.is_rule_disabled("MD002", 3));
1315
1316        // Line 5: MD001 enabled, MD002 still disabled
1317        assert!(!config.is_rule_disabled("MD001", 5));
1318        assert!(config.is_rule_disabled("MD002", 5));
1319
1320        // Line 7: all disabled
1321        assert!(config.is_rule_disabled("MD001", 7));
1322        assert!(config.is_rule_disabled("MD002", 7));
1323
1324        // Line 9: MD002 enabled, MD001 still disabled
1325        assert!(config.is_rule_disabled("MD001", 9));
1326        assert!(!config.is_rule_disabled("MD002", 9));
1327    }
1328
1329    // ── InlineConfig: empty/minimal content ──────────────────────────────
1330
1331    #[test]
1332    fn test_empty_content() {
1333        let config = InlineConfig::from_content("");
1334        assert!(!config.is_rule_disabled("MD001", 1));
1335    }
1336
1337    #[test]
1338    fn test_single_disable_comment_only() {
1339        // Persistent disable takes effect from the NEXT line, not the current line.
1340        // For a single-line document, the disable on line 1 takes effect at line 2+.
1341        let config = InlineConfig::from_content("<!-- rumdl-disable -->");
1342        assert!(!config.is_rule_disabled("MD001", 1));
1343        assert!(config.is_rule_disabled("MD001", 2));
1344        assert!(config.is_rule_disabled("MD999", 2));
1345
1346        // With content after the disable, rules are disabled from line 2 onward
1347        let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
1348        assert!(!config.is_rule_disabled("MD001", 1));
1349        assert!(config.is_rule_disabled("MD001", 2));
1350        assert!(config.is_rule_disabled("MD001", 3));
1351    }
1352
1353    #[test]
1354    fn test_no_inline_markers() {
1355        let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
1356        assert!(!config.is_rule_disabled("MD001", 1));
1357        assert!(!config.is_rule_disabled("MD001", 5));
1358    }
1359
1360    // ── InlineConfig: export_for_file_index correctness ──────────────────
1361
1362    #[test]
1363    fn test_export_for_file_index_persistent_transitions() {
1364        let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
1365        let config = InlineConfig::from_content(content);
1366        let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
1367
1368        assert!(file_disabled.is_empty());
1369        // Should have transitions for the disable and enable
1370        assert!(
1371            persistent.len() >= 2,
1372            "Expected at least 2 transitions, got {}",
1373            persistent.len()
1374        );
1375    }
1376
1377    #[test]
1378    fn test_export_for_file_index_disable_file() {
1379        let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
1380        let config = InlineConfig::from_content(content);
1381        let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
1382
1383        assert!(file_disabled.contains("MD001"));
1384    }
1385
1386    #[test]
1387    fn test_export_for_file_index_disable_line() {
1388        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
1389        let config = InlineConfig::from_content(content);
1390        let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
1391
1392        assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
1393        assert!(line_disabled[&2].contains("MD001"));
1394        assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
1395    }
1396}