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