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//!
21//! `configure-file` differs from every other directive in two ways: its comment
22//! may span multiple lines, and a rule may be given a boolean instead of an
23//! options object to turn it off (`false`) or on (`true`) for the whole file.
24//! Directives inside fenced code blocks are ignored.
25
26use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
27use crate::utils::code_block_utils::CodeBlockUtils;
28use serde_json::Value as JsonValue;
29use std::collections::{HashMap, HashSet};
30
31/// Normalize a rule name to its canonical form (e.g., "line-length" -> "MD013").
32/// If the rule name is not recognized, returns it uppercase (for forward compatibility).
33fn normalize_rule_name(rule: &str) -> String {
34    markdownlint_to_rumdl_rule_key(rule).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
35}
36
37fn has_inline_config_markers(content: &str) -> bool {
38    if !content.contains("<!--") {
39        return false;
40    }
41    content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
42}
43
44/// Type alias for the export_for_file_index return type:
45/// (file_disabled_rules, persistent_transitions, line_disabled_rules)
46pub type FileIndexExport = (
47    HashSet<String>,
48    Vec<(usize, HashSet<String>, HashSet<String>)>,
49    HashMap<usize, HashSet<String>>,
50);
51
52/// A state transition recording which rules are disabled/enabled starting at a given line.
53/// Transitions are stored in ascending line order. The state at any line is determined by
54/// the most recent transition at or before that line.
55#[derive(Debug, Clone)]
56struct StateTransition {
57    /// The 1-indexed line number where this state takes effect
58    line: usize,
59    /// The set of disabled rules at this point ("*" means all rules disabled)
60    disabled: HashSet<String>,
61    /// The set of explicitly enabled rules (only meaningful when disabled contains "*")
62    enabled: HashSet<String>,
63}
64
65#[derive(Debug, Clone)]
66pub struct InlineConfig {
67    /// State transitions for persistent disable/enable directives, sorted by line number.
68    /// Only stores entries where the state actually changes, not for every line.
69    transitions: Vec<StateTransition>,
70    /// Rules disabled for specific lines via disable-line (1-indexed)
71    line_disabled_rules: HashMap<usize, HashSet<String>>,
72    /// Rules disabled for the entire file
73    file_disabled_rules: HashSet<String>,
74    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
75    file_enabled_rules: HashSet<String>,
76    /// Configuration overrides for specific rules from configure-file comments
77    /// Maps rule name to configuration JSON value
78    file_rule_config: HashMap<String, JsonValue>,
79}
80
81impl Default for InlineConfig {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl InlineConfig {
88    pub fn new() -> Self {
89        Self {
90            transitions: Vec::new(),
91            line_disabled_rules: HashMap::new(),
92            file_disabled_rules: HashSet::new(),
93            file_enabled_rules: HashSet::new(),
94            file_rule_config: HashMap::new(),
95        }
96    }
97
98    /// Find the state transition that applies to the given line number.
99    /// Uses binary search to find the last transition at or before the given line.
100    fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
101        if self.transitions.is_empty() {
102            return None;
103        }
104        // Binary search for the rightmost transition with line <= line_number
105        match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
106            Ok(idx) => Some(&self.transitions[idx]),
107            Err(idx) => {
108                if idx > 0 {
109                    Some(&self.transitions[idx - 1])
110                } else {
111                    None
112                }
113            }
114        }
115    }
116
117    /// Process all inline comments in the content and return the configuration state
118    pub fn from_content(content: &str) -> Self {
119        if !has_inline_config_markers(content) {
120            return Self::new();
121        }
122
123        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
124        Self::from_content_with_code_blocks_internal(content, &code_blocks)
125    }
126
127    /// Process all inline comments in the content with precomputed code blocks.
128    pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
129        if !has_inline_config_markers(content) {
130            return Self::new();
131        }
132
133        Self::from_content_with_code_blocks_internal(content, code_blocks)
134    }
135
136    fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
137        let mut config = Self::new();
138        let lines: Vec<&str> = content.lines().collect();
139
140        // configure-file is scanned over the whole document rather than per
141        // line, because it is the one directive allowed to span lines, and it
142        // applies before any enable/disable directive regardless of where it
143        // sits. Comments inside fenced code blocks are skipped.
144        for (offset, json_config) in scan_configure_file_comments(content) {
145            if offset_in_code_block(offset, code_blocks) {
146                continue;
147            }
148            let Some(obj) = json_config.as_object() else {
149                continue;
150            };
151            for (rule_name, rule_config) in obj {
152                // A boolean turns a rule off or back on, e.g.
153                // `{ "no-trailing-spaces": false }`, so route those to the
154                // disable set instead of storing them as rule options.
155                let normalized = normalize_rule_name(rule_name);
156                if let Some(enabled) = rule_config.as_bool() {
157                    if enabled {
158                        config.file_disabled_rules.remove(&normalized);
159                    } else {
160                        config.file_disabled_rules.insert(normalized);
161                    }
162                    continue;
163                }
164                // Store under the canonical rule id so lookups by `MDxxx` also
165                // find configs written with an alias, e.g.
166                // `{ "line-length": { "line_length": 70 } }`.
167                config.file_rule_config.insert(normalized, rule_config.clone());
168            }
169        }
170
171        // Pre-compute line positions for checking if a line is in a code block
172        let mut line_positions = Vec::with_capacity(lines.len());
173        let mut pos = 0;
174        for line in &lines {
175            line_positions.push(pos);
176            pos += line.len() + 1; // +1 for newline
177        }
178
179        // Track current state of disabled rules
180        let mut currently_disabled: HashSet<String> = HashSet::new();
181        let mut currently_enabled: HashSet<String> = HashSet::new();
182        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
183
184        // Track the previously recorded transition state to detect changes
185        let mut prev_disabled: HashSet<String> = HashSet::new();
186        let mut prev_enabled: HashSet<String> = HashSet::new();
187
188        // Record initial state (line 1: nothing disabled)
189        config.transitions.push(StateTransition {
190            line: 1,
191            disabled: HashSet::new(),
192            enabled: HashSet::new(),
193        });
194
195        for (idx, line) in lines.iter().enumerate() {
196            let line_num = idx + 1; // 1-indexed
197
198            // Record a transition only if state changed since last recorded transition.
199            // State for this line is the state BEFORE processing comments on this line.
200            if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
201                config.transitions.push(StateTransition {
202                    line: line_num,
203                    disabled: currently_disabled.clone(),
204                    enabled: currently_enabled.clone(),
205                });
206                prev_disabled.clone_from(&currently_disabled);
207                prev_enabled.clone_from(&currently_enabled);
208            }
209
210            // Skip processing if this line is inside a code block
211            let line_start = line_positions[idx];
212            let line_end = line_start + line.len();
213            let in_code_block = code_blocks
214                .iter()
215                .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
216
217            if in_code_block {
218                continue;
219            }
220
221            // Parse all directives on this line once via the unified parser.
222            // Directives come back in left-to-right order with correct disambiguation.
223            let directives = parse_inline_directives(line);
224
225            // Also check for prettier-ignore (not part of the rumdl/markdownlint format)
226            let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
227
228            // Pass 1: file-wide directives (affect the entire file, not state-tracked)
229            for directive in &directives {
230                match directive.kind {
231                    DirectiveKind::DisableFile => {
232                        if directive.rules.is_empty() {
233                            config.file_disabled_rules.clear();
234                            config.file_disabled_rules.insert("*".to_string());
235                        } else if config.file_disabled_rules.contains("*") {
236                            for rule in &directive.rules {
237                                config.file_enabled_rules.remove(&normalize_rule_name(rule));
238                            }
239                        } else {
240                            for rule in &directive.rules {
241                                config.file_disabled_rules.insert(normalize_rule_name(rule));
242                            }
243                        }
244                    }
245                    DirectiveKind::EnableFile => {
246                        if directive.rules.is_empty() {
247                            config.file_disabled_rules.clear();
248                            config.file_enabled_rules.clear();
249                        } else if config.file_disabled_rules.contains("*") {
250                            for rule in &directive.rules {
251                                config.file_enabled_rules.insert(normalize_rule_name(rule));
252                            }
253                        } else {
254                            for rule in &directive.rules {
255                                config.file_disabled_rules.remove(&normalize_rule_name(rule));
256                            }
257                        }
258                    }
259                    // configure-file is handled document-wide before this loop.
260                    _ => {}
261                }
262            }
263
264            // Pass 2: line-specific and state-changing directives (in document order)
265            for directive in &directives {
266                match directive.kind {
267                    DirectiveKind::DisableNextLine => {
268                        let next_line = line_num + 1;
269                        let line_rules = config.line_disabled_rules.entry(next_line).or_default();
270                        if directive.rules.is_empty() {
271                            line_rules.insert("*".to_string());
272                        } else {
273                            for rule in &directive.rules {
274                                line_rules.insert(normalize_rule_name(rule));
275                            }
276                        }
277                    }
278                    DirectiveKind::DisableLine => {
279                        let line_rules = config.line_disabled_rules.entry(line_num).or_default();
280                        if directive.rules.is_empty() {
281                            line_rules.insert("*".to_string());
282                        } else {
283                            for rule in &directive.rules {
284                                line_rules.insert(normalize_rule_name(rule));
285                            }
286                        }
287                    }
288                    DirectiveKind::Disable => {
289                        if directive.rules.is_empty() {
290                            currently_disabled.clear();
291                            currently_disabled.insert("*".to_string());
292                            currently_enabled.clear();
293                        } else if currently_disabled.contains("*") {
294                            for rule in &directive.rules {
295                                currently_enabled.remove(&normalize_rule_name(rule));
296                            }
297                        } else {
298                            for rule in &directive.rules {
299                                currently_disabled.insert(normalize_rule_name(rule));
300                            }
301                        }
302                    }
303                    DirectiveKind::Enable => {
304                        if directive.rules.is_empty() {
305                            currently_disabled.clear();
306                            currently_enabled.clear();
307                        } else if currently_disabled.contains("*") {
308                            for rule in &directive.rules {
309                                currently_enabled.insert(normalize_rule_name(rule));
310                            }
311                        } else {
312                            for rule in &directive.rules {
313                                currently_disabled.remove(&normalize_rule_name(rule));
314                            }
315                        }
316                    }
317                    DirectiveKind::Capture => {
318                        capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
319                    }
320                    DirectiveKind::Restore => {
321                        if let Some((disabled, enabled)) = capture_stack.pop() {
322                            currently_disabled = disabled;
323                            currently_enabled = enabled;
324                        }
325                    }
326                    // File-wide directives already handled in pass 1
327                    DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
328                }
329            }
330
331            // prettier-ignore: disables all rules for next line
332            if has_prettier_ignore {
333                let next_line = line_num + 1;
334                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
335                line_rules.insert("*".to_string());
336            }
337        }
338
339        // Record final transition if state changed after the last line was processed
340        if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
341            config.transitions.push(StateTransition {
342                line: lines.len() + 1,
343                disabled: currently_disabled,
344                enabled: currently_enabled,
345            });
346        }
347
348        config
349    }
350
351    /// Check if a rule is disabled at a specific line
352    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
353        // Check file-wide disables first (highest priority)
354        if self.file_disabled_rules.contains("*") {
355            // All rules are disabled for the file, check if this rule is explicitly enabled
356            return !self.file_enabled_rules.contains(rule_name);
357        } else if self.file_disabled_rules.contains(rule_name) {
358            return true;
359        }
360
361        // Check line-specific disables (disable-line, disable-next-line)
362        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
363            && (line_rules.contains("*") || line_rules.contains(rule_name))
364        {
365            return true;
366        }
367
368        // Check persistent disables via state transitions (binary search)
369        if let Some(transition) = self.find_transition(line_number) {
370            if transition.disabled.contains("*") {
371                return !transition.enabled.contains(rule_name);
372            } else {
373                return transition.disabled.contains(rule_name);
374            }
375        }
376
377        false
378    }
379
380    /// Get all disabled rules at a specific line
381    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
382        let mut disabled = HashSet::new();
383
384        // Add persistent disables via state transitions (binary search)
385        if let Some(transition) = self.find_transition(line_number) {
386            if transition.disabled.contains("*") {
387                disabled.insert("*".to_string());
388            } else {
389                for rule in &transition.disabled {
390                    disabled.insert(rule.clone());
391                }
392            }
393        }
394
395        // Add line-specific disables
396        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
397            for rule in line_rules {
398                disabled.insert(rule.clone());
399            }
400        }
401
402        disabled
403    }
404
405    /// Get configuration overrides for a specific rule from configure-file comments
406    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
407        self.file_rule_config.get(rule_name)
408    }
409
410    /// Get all configuration overrides from configure-file comments
411    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
412        &self.file_rule_config
413    }
414
415    /// Export the disabled rules data for storage in FileIndex.
416    ///
417    /// Returns (file_disabled_rules, persistent_transitions, line_disabled_rules).
418    pub fn export_for_file_index(&self) -> FileIndexExport {
419        let file_disabled = self.file_disabled_rules.clone();
420
421        let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
422            .transitions
423            .iter()
424            .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
425            .collect();
426
427        let line_disabled = self.line_disabled_rules.clone();
428
429        (file_disabled, persistent_transitions, line_disabled)
430    }
431}
432
433// ── Unified inline directive parser ──────────────────────────────────────────
434//
435// All inline config comments follow one pattern:
436//   <!-- (rumdl|markdownlint)-KEYWORD [RULES...] -->
437//
438// Disambiguation (e.g., "disable" vs "disable-line" vs "disable-next-line")
439// is handled ONCE here by matching the longest keyword first.
440
441/// The type of an inline configuration directive.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum DirectiveKind {
444    Disable,
445    DisableLine,
446    DisableNextLine,
447    DisableFile,
448    Enable,
449    EnableFile,
450    Capture,
451    Restore,
452    ConfigureFile,
453}
454
455/// A parsed inline configuration directive.
456#[derive(Debug, Clone, PartialEq)]
457pub struct InlineDirective<'a> {
458    pub kind: DirectiveKind,
459    pub rules: Vec<&'a str>,
460}
461
462/// Tool prefixes recognized in inline config comments.
463const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
464
465/// Directive keywords ordered so that more-specific prefixes come first.
466/// "disable-next-line" before "disable-line" before "disable-file" before "disable";
467/// "enable-file" before "enable". This ensures longest-match-first disambiguation.
468const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
469    (DirectiveKind::DisableNextLine, "disable-next-line"),
470    (DirectiveKind::DisableLine, "disable-line"),
471    (DirectiveKind::DisableFile, "disable-file"),
472    (DirectiveKind::Disable, "disable"),
473    (DirectiveKind::EnableFile, "enable-file"),
474    (DirectiveKind::Enable, "enable"),
475    (DirectiveKind::ConfigureFile, "configure-file"),
476    (DirectiveKind::Capture, "capture"),
477    (DirectiveKind::Restore, "restore"),
478];
479
480/// Try to parse a single directive from text immediately after `<!-- `.
481/// Returns the directive and the number of bytes consumed (from `s` onward)
482/// so the caller can advance past `-->`.
483fn try_parse_directive(s: &str) -> Option<(InlineDirective<'_>, usize)> {
484    for tool in TOOL_PREFIXES {
485        if !s.starts_with(tool) {
486            continue;
487        }
488        let after_tool = &s[tool.len()..];
489
490        for &(kind, keyword) in DIRECTIVE_KEYWORDS {
491            if !after_tool.starts_with(keyword) {
492                continue;
493            }
494            let after_kw = &after_tool[keyword.len()..];
495
496            // Word boundary: the keyword must be followed by whitespace, `-->`, or end-of-string.
497            // This prevents "disablefoo" from matching "disable".
498            if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
499                continue;
500            }
501
502            // Find closing -->
503            let close_offset = after_kw.find("-->")?;
504
505            let rules_str = after_kw[..close_offset].trim();
506            let rules = if rules_str.is_empty() {
507                Vec::new()
508            } else {
509                rules_str.split_whitespace().collect()
510            };
511
512            let consumed = tool.len() + keyword.len() + close_offset + 3; // 3 for "-->"
513            return Some((InlineDirective { kind, rules }, consumed));
514        }
515
516        // Tool prefix matched but no keyword — not a directive we recognize.
517        return None;
518    }
519    None
520}
521
522/// Parse all inline configuration directives from a line, in left-to-right order.
523///
524/// Each directive is a typed `InlineDirective` with its kind and rule list.
525/// Disambiguation between overlapping prefixes (e.g., `disable` vs `disable-line`)
526/// is handled by matching the longest keyword first — no ad-hoc guards needed.
527pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
528    let mut results = Vec::new();
529    let mut pos = 0;
530
531    while pos < line.len() {
532        let remaining = &line[pos..];
533        let Some(open_offset) = remaining.find("<!-- ") else {
534            break;
535        };
536        let comment_start = pos + open_offset;
537        let after_open = &line[comment_start + 5..]; // skip "<!-- "
538
539        if let Some((directive, consumed)) = try_parse_directive(after_open) {
540            results.push(directive);
541            pos = comment_start + 5 + consumed;
542        } else {
543            pos = comment_start + 5;
544        }
545    }
546
547    results
548}
549
550// ── Backward-compatible wrapper functions ────────────────────────────────────
551//
552// These delegate to parse_inline_directives and filter by DirectiveKind.
553// External callers (e.g., MD040) use these; internal code uses the unified parser.
554
555fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
556    parse_inline_directives(line)
557        .into_iter()
558        .find(|d| d.kind == kind)
559        .map(|d| d.rules)
560}
561
562/// Parse a disable comment and return the list of rules (empty vec means all rules)
563pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
564    find_directive_rules(line, DirectiveKind::Disable)
565}
566
567/// Parse an enable comment and return the list of rules (empty vec means all rules)
568pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
569    find_directive_rules(line, DirectiveKind::Enable)
570}
571
572/// Parse a disable-line comment
573pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
574    find_directive_rules(line, DirectiveKind::DisableLine)
575}
576
577/// Parse a disable-next-line comment
578pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
579    find_directive_rules(line, DirectiveKind::DisableNextLine)
580}
581
582/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
583pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
584    find_directive_rules(line, DirectiveKind::DisableFile)
585}
586
587/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
588pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
589    find_directive_rules(line, DirectiveKind::EnableFile)
590}
591
592/// Check if line contains a capture comment
593pub fn is_capture_comment(line: &str) -> bool {
594    parse_inline_directives(line)
595        .iter()
596        .any(|d| d.kind == DirectiveKind::Capture)
597}
598
599/// Check if line contains a restore comment
600pub fn is_restore_comment(line: &str) -> bool {
601    parse_inline_directives(line)
602        .iter()
603        .any(|d| d.kind == DirectiveKind::Restore)
604}
605
606const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
607
608/// Whether a byte offset falls inside one of the given code block ranges.
609fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
610    code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
611}
612
613/// The 1-indexed line a byte offset falls on.
614fn line_of_offset(text: &str, offset: usize) -> usize {
615    text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
616}
617
618/// Find every configure-file comment in `text`, returning each JSON payload
619/// with the byte offset of its opening `<!--`.
620///
621/// `text` is normally the whole document: unlike every other directive, a
622/// configure-file comment may span lines, so its `-->` is searched for without
623/// regard to line boundaries. The offset lets callers map a payload back to a
624/// line number or test it against code block ranges.
625///
626/// Payloads that are empty or not valid JSON are skipped, and scanning
627/// continues past them.
628fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
629    let mut found = Vec::new();
630    let mut pos = 0;
631
632    while let Some(open_offset) = text[pos..].find("<!-- ") {
633        let comment_start = pos + open_offset;
634        let after_open = &text[comment_start + 5..]; // skip "<!-- "
635        // Advance past this opener by default, so an unrecognized or malformed
636        // comment cannot stall the scan.
637        pos = comment_start + 5;
638
639        for tool in TOOL_PREFIXES {
640            let Some(after_tool) = after_open.strip_prefix(tool) else {
641                continue;
642            };
643            let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
644                break;
645            };
646            // Word boundary: the keyword must be followed by whitespace or `-->`,
647            // so `configure-files` does not match.
648            if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
649                break;
650            }
651            let Some(close_offset) = after_kw.find("-->") else {
652                break;
653            };
654
655            let json_str = after_kw[..close_offset].trim();
656            if !json_str.is_empty()
657                && let Ok(value) = serde_json::from_str(json_str)
658            {
659                found.push((comment_start, value));
660            }
661            pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
662            break;
663        }
664    }
665
666    found
667}
668
669/// Parse a configure-file comment and return the JSON configuration.
670///
671/// Returns the first payload found. The text may span lines.
672pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
673    scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
674}
675
676/// What is wrong with an inline config comment.
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub enum InlineConfigProblem {
679    /// A rule name that is not recognized.
680    UnknownRule,
681    /// A recognized rule carrying an unrecognized option key inside a
682    /// configure-file config object.
683    UnknownOption { key: String },
684    /// An inline directive tries to enable a rule that configuration left
685    /// disabled. rumdl treats config-level rule selection as final, so the
686    /// enable has no effect; this makes that silent no-op visible.
687    EnableHasNoEffect,
688}
689
690/// Warning about an inline config comment.
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct InlineConfigWarning {
693    /// The line number where the warning occurred (1-indexed)
694    pub line_number: usize,
695    /// The rule the warning concerns
696    pub rule_name: String,
697    /// The type of inline config comment (e.g. "disable", "configure-file")
698    pub comment_type: String,
699    /// Suggestion for a similar rule name or option key, when one is close
700    pub suggestion: Option<String>,
701    /// What is wrong
702    pub problem: InlineConfigProblem,
703}
704
705impl InlineConfigWarning {
706    /// Format the warning message
707    pub fn format_message(&self) -> String {
708        // Wording for unknown rules/options matches the config-file validator so
709        // the same mistake reads the same way wherever it is written.
710        match &self.problem {
711            InlineConfigProblem::UnknownOption { key } => match self.suggestion {
712                Some(ref suggestion) => format!(
713                    "Unknown option for rule {}: {} (did you mean: {}?)",
714                    self.rule_name, key, suggestion
715                ),
716                None => format!("Unknown option for rule {}: {}", self.rule_name, key),
717            },
718            InlineConfigProblem::UnknownRule => match self.suggestion {
719                Some(ref suggestion) => format!(
720                    "Unknown rule in inline {} comment: {} (did you mean: {}?)",
721                    self.comment_type, self.rule_name, suggestion
722                ),
723                None => format!(
724                    "Unknown rule in inline {} comment: {}",
725                    self.comment_type, self.rule_name
726                ),
727            },
728            InlineConfigProblem::EnableHasNoEffect => format!(
729                "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
730                self.rule_name, self.comment_type
731            ),
732        }
733    }
734
735    /// Print the warning to stderr with file context
736    pub fn print_warning(&self, file_path: &str) {
737        eprintln!(
738            "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
739            file_path,
740            self.line_number,
741            self.format_message()
742        );
743    }
744}
745
746/// Validate all inline config comments in content and return warnings for unknown rules.
747///
748/// This function extracts rule names from all types of inline config comments
749/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
750/// and validates them against the known rule alias map.
751pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
752    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
753
754    let mut warnings = Vec::new();
755    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
756
757    let suggest = |rule_name: &str| {
758        suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
759    };
760
761    // configure-file carries its rule names as JSON keys and may span lines, so
762    // it is scanned over the whole document. Warnings are reported against the
763    // line the comment opens on.
764    let registry = crate::config::default_registry();
765    for (offset, json_config) in scan_configure_file_comments(content) {
766        let Some(obj) = json_config.as_object() else {
767            continue;
768        };
769        let line_number = line_of_offset(content, offset);
770        for (rule_name, rule_config) in obj {
771            if !is_valid_rule_name(rule_name) {
772                warnings.push(InlineConfigWarning {
773                    line_number,
774                    rule_name: rule_name.clone(),
775                    comment_type: "configure-file".to_string(),
776                    suggestion: suggest(rule_name),
777                    problem: InlineConfigProblem::UnknownRule,
778                });
779                // The rule itself is unknown, so its options cannot be checked
780                // against anything and would only add noise.
781                continue;
782            }
783            // A boolean turns the rule on or off and carries no options.
784            let Some(options) = rule_config.as_object() else {
785                continue;
786            };
787            let canonical = normalize_rule_name(rule_name);
788            let Some(valid_keys) = registry.config_keys_for(&canonical) else {
789                continue;
790            };
791            let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
792            for key in options.keys() {
793                if !valid_keys.contains(key) {
794                    warnings.push(InlineConfigWarning {
795                        line_number,
796                        rule_name: canonical.clone(),
797                        comment_type: "configure-file".to_string(),
798                        suggestion: suggest_similar_key(key, &valid_keys_vec),
799                        problem: InlineConfigProblem::UnknownOption { key: key.clone() },
800                    });
801                }
802            }
803        }
804    }
805
806    for (idx, line) in content.lines().enumerate() {
807        let line_num = idx + 1;
808
809        // Parse all directives on this line once
810        let directives = parse_inline_directives(line);
811        let mut rule_entries: Vec<(&str, &str)> = Vec::new();
812
813        for directive in &directives {
814            let comment_type = match directive.kind {
815                DirectiveKind::Disable => "disable",
816                DirectiveKind::Enable => "enable",
817                DirectiveKind::DisableLine => "disable-line",
818                DirectiveKind::DisableNextLine => "disable-next-line",
819                DirectiveKind::DisableFile => "disable-file",
820                DirectiveKind::EnableFile => "enable-file",
821                // configure-file is scanned document-wide above.
822                DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
823            };
824            for rule in &directive.rules {
825                rule_entries.push((rule, comment_type));
826            }
827        }
828
829        // Validate each rule name
830        for (rule_name, comment_type) in rule_entries {
831            if !is_valid_rule_name(rule_name) {
832                warnings.push(InlineConfigWarning {
833                    line_number: line_num,
834                    rule_name: rule_name.to_string(),
835                    comment_type: comment_type.to_string(),
836                    suggestion: suggest(rule_name),
837                    problem: InlineConfigProblem::UnknownRule,
838                });
839            }
840        }
841    }
842
843    // configure-file warnings are collected ahead of the per-line pass, so
844    // restore document order before returning.
845    warnings.sort_by_key(|w| w.line_number);
846    warnings
847}
848
849/// Warn when an inline directive tries to ENABLE a rule that configuration left
850/// disabled, so the enable has no effect.
851///
852/// rumdl removes disabled rules from the rule set before any file is read
853/// (`filter_rules`), so a disabled rule is never instantiated and inline config
854/// cannot bring it back. `active_rules` is the set of canonical rule ids that
855/// configuration left enabled; a valid rule outside it is not running.
856///
857/// Only recognized rule names outside the active set warn. Unknown names are
858/// left to `validate_inline_config_rules`, a bare `enable`/`enable-file` (no
859/// rules, meaning "all") targets no specific rule, and a `configure-file`
860/// boolean warns only for `true` (an enable), never `false` (a disable).
861pub fn validate_inline_enables_against_active_rules(
862    content: &str,
863    active_rules: &HashSet<String>,
864) -> Vec<InlineConfigWarning> {
865    use crate::config::is_valid_rule_name;
866
867    let mut warnings = Vec::new();
868
869    let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
870        // Skip unrecognized names (handled elsewhere) and rules that are active.
871        if !is_valid_rule_name(name) {
872            return;
873        }
874        let canonical = normalize_rule_name(name);
875        if active_rules.contains(&canonical) {
876            return;
877        }
878        warnings.push(InlineConfigWarning {
879            line_number: line,
880            rule_name: canonical,
881            comment_type: comment_type.to_string(),
882            suggestion: None,
883            problem: InlineConfigProblem::EnableHasNoEffect,
884        });
885    };
886
887    // configure-file may span lines and is scanned over the whole document.
888    for (offset, json_config) in scan_configure_file_comments(content) {
889        let Some(obj) = json_config.as_object() else {
890            continue;
891        };
892        let line = line_of_offset(content, offset);
893        for (rule_name, rule_config) in obj {
894            // Only a boolean `true` is an enable; `false` disables and an
895            // options object configures without enabling.
896            if rule_config.as_bool() == Some(true) {
897                flag(&mut warnings, rule_name, "configure-file", line);
898            }
899        }
900    }
901
902    // enable / enable-file are line-scoped; an empty rule list means "all".
903    for (idx, line) in content.lines().enumerate() {
904        for directive in parse_inline_directives(line) {
905            let comment_type = match directive.kind {
906                DirectiveKind::Enable => "enable",
907                DirectiveKind::EnableFile => "enable-file",
908                _ => continue,
909            };
910            for rule in &directive.rules {
911                flag(&mut warnings, rule, comment_type, idx + 1);
912            }
913        }
914    }
915
916    warnings.sort_by_key(|w| w.line_number);
917    warnings
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    // ── Unified parser tests ─────────────────────────────────────────────
925
926    #[test]
927    fn test_parse_inline_directives_all_kinds() {
928        // Every directive kind is correctly identified
929        let cases: &[(&str, DirectiveKind)] = &[
930            ("<!-- rumdl-disable -->", DirectiveKind::Disable),
931            ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
932            ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
933            ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
934            ("<!-- rumdl-enable -->", DirectiveKind::Enable),
935            ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
936            ("<!-- rumdl-capture -->", DirectiveKind::Capture),
937            ("<!-- rumdl-restore -->", DirectiveKind::Restore),
938            ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
939            // markdownlint variants
940            ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
941            ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
942            (
943                "<!-- markdownlint-disable-next-line -->",
944                DirectiveKind::DisableNextLine,
945            ),
946            ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
947            ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
948            ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
949        ];
950        for (input, expected_kind) in cases {
951            let directives = parse_inline_directives(input);
952            assert_eq!(
953                directives.len(),
954                1,
955                "Expected 1 directive for {input:?}, got {directives:?}"
956            );
957            assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
958        }
959    }
960
961    #[test]
962    fn test_parse_inline_directives_disambiguation() {
963        // The core property: "disable" must NOT match "disable-line" etc.
964        let line = "<!-- rumdl-disable-line MD001 -->";
965        let directives = parse_inline_directives(line);
966        assert_eq!(directives.len(), 1);
967        assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
968
969        let line = "<!-- rumdl-disable-next-line -->";
970        let directives = parse_inline_directives(line);
971        assert_eq!(directives.len(), 1);
972        assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
973
974        let line = "<!-- rumdl-disable-file MD001 -->";
975        let directives = parse_inline_directives(line);
976        assert_eq!(directives.len(), 1);
977        assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
978
979        let line = "<!-- rumdl-enable-file -->";
980        let directives = parse_inline_directives(line);
981        assert_eq!(directives.len(), 1);
982        assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
983    }
984
985    #[test]
986    fn test_parse_inline_directives_no_space_before_close() {
987        // <!-- rumdl-disable--> must parse as Disable (the bug that started this refactor)
988        let directives = parse_inline_directives("<!-- rumdl-disable-->");
989        assert_eq!(directives.len(), 1);
990        assert_eq!(directives[0].kind, DirectiveKind::Disable);
991        assert!(directives[0].rules.is_empty());
992
993        let directives = parse_inline_directives("<!-- rumdl-enable-->");
994        assert_eq!(directives.len(), 1);
995        assert_eq!(directives[0].kind, DirectiveKind::Enable);
996    }
997
998    #[test]
999    fn test_parse_inline_directives_multiple_on_one_line() {
1000        let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1001        let directives = parse_inline_directives(line);
1002        assert_eq!(directives.len(), 2);
1003        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1004        assert_eq!(directives[0].rules, vec!["MD001"]);
1005        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1006        assert_eq!(directives[1].rules, vec!["MD001"]);
1007    }
1008
1009    #[test]
1010    fn test_parse_inline_directives_global_disable_then_specific_enable() {
1011        let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1012        let directives = parse_inline_directives(line);
1013        assert_eq!(directives.len(), 2);
1014        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1015        assert!(directives[0].rules.is_empty());
1016        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1017        assert_eq!(directives[1].rules, vec!["MD001"]);
1018    }
1019
1020    #[test]
1021    fn test_parse_inline_directives_word_boundary() {
1022        // "disablefoo" should NOT match "disable"
1023        assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1024        // "enablebar" should NOT match "enable"
1025        assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1026        // "captures" should NOT match "capture"
1027        assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1028    }
1029
1030    #[test]
1031    fn test_parse_inline_directives_no_closing_tag() {
1032        // Missing --> means no directive
1033        assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1034        assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1035    }
1036
1037    #[test]
1038    fn test_parse_inline_directives_not_a_comment() {
1039        assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1040        assert!(parse_inline_directives("Some regular text").is_empty());
1041        assert!(parse_inline_directives("").is_empty());
1042    }
1043
1044    #[test]
1045    fn test_parse_inline_directives_case_sensitive() {
1046        assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1047        assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1048    }
1049
1050    #[test]
1051    fn test_parse_inline_directives_rules_extraction() {
1052        let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1053        assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1054
1055        // Tabs between rules
1056        let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1057        assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1058
1059        // Extra whitespace
1060        let directives = parse_inline_directives("<!-- rumdl-disable   MD001   -->");
1061        assert_eq!(directives[0].rules, vec!["MD001"]);
1062    }
1063
1064    #[test]
1065    fn test_parse_inline_directives_embedded_in_text() {
1066        let line = "Some text <!-- rumdl-disable MD001 --> more text";
1067        let directives = parse_inline_directives(line);
1068        assert_eq!(directives.len(), 1);
1069        assert_eq!(directives[0].rules, vec!["MD001"]);
1070
1071        let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1072        let directives = parse_inline_directives(line);
1073        assert_eq!(directives.len(), 1);
1074        assert_eq!(directives[0].rules, vec!["MD001"]);
1075    }
1076
1077    #[test]
1078    fn test_parse_inline_directives_mixed_tools_same_line() {
1079        let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1080        let directives = parse_inline_directives(line);
1081        assert_eq!(directives.len(), 2);
1082        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1083        assert_eq!(directives[0].rules, vec!["MD001"]);
1084        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1085        assert_eq!(directives[1].rules, vec!["MD002"]);
1086    }
1087
1088    // ── Backward-compatible wrapper tests ────────────────────────────────
1089
1090    #[test]
1091    fn test_parse_disable_comment() {
1092        // Global disable
1093        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1094        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1095
1096        // Specific rules
1097        assert_eq!(
1098            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1099            Some(vec!["MD001", "MD002"])
1100        );
1101
1102        // No comment
1103        assert_eq!(parse_disable_comment("Some regular text"), None);
1104    }
1105
1106    #[test]
1107    fn test_parse_disable_line_comment() {
1108        // Global disable-line
1109        assert_eq!(
1110            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1111            Some(vec![])
1112        );
1113
1114        // Specific rules
1115        assert_eq!(
1116            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1117            Some(vec!["MD013"])
1118        );
1119
1120        // No comment
1121        assert_eq!(parse_disable_line_comment("Some regular text"), None);
1122    }
1123
1124    #[test]
1125    fn test_inline_config_from_content() {
1126        let content = r#"# Test Document
1127
1128<!-- markdownlint-disable MD013 -->
1129This is a very long line that would normally trigger MD013 but it's disabled
1130
1131<!-- markdownlint-enable MD013 -->
1132This line will be checked again
1133
1134<!-- markdownlint-disable-next-line MD001 -->
1135# This heading will not be checked for MD001
1136## But this one will
1137
1138Some text <!-- markdownlint-disable-line MD013 -->
1139
1140<!-- markdownlint-capture -->
1141<!-- markdownlint-disable MD001 MD002 -->
1142# Heading with MD001 disabled
1143<!-- markdownlint-restore -->
1144# Heading with MD001 enabled again
1145"#;
1146
1147        let config = InlineConfig::from_content(content);
1148
1149        // Line 4 should have MD013 disabled (line after disable comment on line 3)
1150        assert!(config.is_rule_disabled("MD013", 4));
1151
1152        // Line 7 should have MD013 enabled (line after enable comment on line 6)
1153        assert!(!config.is_rule_disabled("MD013", 7));
1154
1155        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
1156        assert!(config.is_rule_disabled("MD001", 10));
1157
1158        // Line 11 should not have MD001 disabled
1159        assert!(!config.is_rule_disabled("MD001", 11));
1160
1161        // Line 13 should have MD013 disabled (from disable-line)
1162        assert!(config.is_rule_disabled("MD013", 13));
1163
1164        // After restore (line 18), MD001 should be enabled again on line 19
1165        assert!(!config.is_rule_disabled("MD001", 19));
1166    }
1167
1168    #[test]
1169    fn test_capture_restore() {
1170        let content = r#"<!-- markdownlint-disable MD001 -->
1171<!-- markdownlint-capture -->
1172<!-- markdownlint-disable MD002 MD003 -->
1173<!-- markdownlint-restore -->
1174Some content after restore
1175"#;
1176
1177        let config = InlineConfig::from_content(content);
1178
1179        // After restore (line 4), line 5 should only have MD001 disabled
1180        assert!(config.is_rule_disabled("MD001", 5));
1181        assert!(!config.is_rule_disabled("MD002", 5));
1182        assert!(!config.is_rule_disabled("MD003", 5));
1183    }
1184
1185    #[test]
1186    fn test_validate_inline_config_rules_unknown_rule() {
1187        let content = "<!-- rumdl-disable abc -->\nSome content";
1188        let warnings = validate_inline_config_rules(content);
1189        assert_eq!(warnings.len(), 1);
1190        assert_eq!(warnings[0].line_number, 1);
1191        assert_eq!(warnings[0].rule_name, "abc");
1192        assert_eq!(warnings[0].comment_type, "disable");
1193    }
1194
1195    #[test]
1196    fn test_validate_inline_config_rules_valid_rule() {
1197        let content = "<!-- rumdl-disable MD001 -->\nSome content";
1198        let warnings = validate_inline_config_rules(content);
1199        assert!(
1200            warnings.is_empty(),
1201            "MD001 is a valid rule, should not produce warnings"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_validate_inline_config_rules_alias() {
1207        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1208        let warnings = validate_inline_config_rules(content);
1209        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1210    }
1211
1212    #[test]
1213    fn test_validate_inline_config_rules_multiple_unknown() {
1214        let content = r#"<!-- rumdl-disable abc xyz -->
1215<!-- rumdl-disable-line foo -->
1216<!-- markdownlint-disable-next-line bar -->
1217"#;
1218        let warnings = validate_inline_config_rules(content);
1219        assert_eq!(warnings.len(), 4);
1220        assert_eq!(warnings[0].rule_name, "abc");
1221        assert_eq!(warnings[1].rule_name, "xyz");
1222        assert_eq!(warnings[2].rule_name, "foo");
1223        assert_eq!(warnings[3].rule_name, "bar");
1224    }
1225
1226    #[test]
1227    fn test_validate_inline_config_rules_suggestion() {
1228        // "MD00" should suggest "MD001" (or similar)
1229        let content = "<!-- rumdl-disable MD00 -->\n";
1230        let warnings = validate_inline_config_rules(content);
1231        assert_eq!(warnings.len(), 1);
1232        // Should have a suggestion since "MD00" is close to "MD001"
1233        assert!(warnings[0].suggestion.is_some());
1234    }
1235
1236    #[test]
1237    fn test_validate_inline_config_rules_file_comments() {
1238        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1239        let warnings = validate_inline_config_rules(content);
1240        assert_eq!(warnings.len(), 2);
1241        assert_eq!(warnings[0].comment_type, "disable-file");
1242        assert_eq!(warnings[1].comment_type, "enable-file");
1243    }
1244
1245    #[test]
1246    fn test_validate_inline_config_rules_global_disable() {
1247        // Global disable (no specific rules) should not produce warnings
1248        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1249        let warnings = validate_inline_config_rules(content);
1250        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1251    }
1252
1253    #[test]
1254    fn test_validate_inline_config_rules_mixed_valid_invalid() {
1255        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
1256        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1257        let warnings = validate_inline_config_rules(content);
1258        assert_eq!(warnings.len(), 2);
1259        assert_eq!(warnings[0].rule_name, "abc");
1260        assert_eq!(warnings[1].rule_name, "xyz");
1261    }
1262
1263    #[test]
1264    fn test_validate_inline_config_rules_configure_file() {
1265        // configure-file comments contain rule names as JSON keys
1266        let content =
1267            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1268        let warnings = validate_inline_config_rules(content);
1269        assert_eq!(warnings.len(), 1);
1270        assert_eq!(warnings[0].rule_name, "nonexistent");
1271        assert_eq!(warnings[0].comment_type, "configure-file");
1272    }
1273
1274    #[test]
1275    fn test_validate_inline_config_rules_markdownlint_variants() {
1276        // Test markdownlint-* variants (not just rumdl-*)
1277        let content = r#"<!-- markdownlint-disable unknown_rule -->
1278<!-- markdownlint-enable another_fake -->
1279<!-- markdownlint-disable-line bad_rule -->
1280<!-- markdownlint-disable-next-line fake_rule -->
1281<!-- markdownlint-disable-file missing_rule -->
1282<!-- markdownlint-enable-file nonexistent -->
1283"#;
1284        let warnings = validate_inline_config_rules(content);
1285        assert_eq!(warnings.len(), 6);
1286        assert_eq!(warnings[0].rule_name, "unknown_rule");
1287        assert_eq!(warnings[1].rule_name, "another_fake");
1288        assert_eq!(warnings[2].rule_name, "bad_rule");
1289        assert_eq!(warnings[3].rule_name, "fake_rule");
1290        assert_eq!(warnings[4].rule_name, "missing_rule");
1291        assert_eq!(warnings[5].rule_name, "nonexistent");
1292    }
1293
1294    #[test]
1295    fn test_validate_inline_config_rules_markdownlint_configure_file() {
1296        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1297        let warnings = validate_inline_config_rules(content);
1298        assert_eq!(warnings.len(), 1);
1299        assert_eq!(warnings[0].rule_name, "fake_rule");
1300        assert_eq!(warnings[0].comment_type, "configure-file");
1301    }
1302
1303    #[test]
1304    fn test_get_rule_config_from_configure_file() {
1305        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1306
1307This is a test line."#;
1308
1309        let inline_config = InlineConfig::from_content(content);
1310        let config_override = inline_config.get_rule_config("MD013");
1311
1312        assert!(config_override.is_some(), "MD013 config should be found");
1313        let json = config_override.unwrap();
1314        assert!(json.is_object(), "Config should be an object");
1315        let obj = json.as_object().unwrap();
1316        assert!(obj.contains_key("line_length"), "Should have line_length key");
1317        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1318    }
1319
1320    #[test]
1321    fn test_get_rule_config_tables_false() {
1322        // Test that tables=false inline config is correctly parsed
1323        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1324
1325        let inline_config = InlineConfig::from_content(content);
1326        let config_override = inline_config.get_rule_config("MD013");
1327
1328        assert!(config_override.is_some(), "MD013 config should be found");
1329        let json = config_override.unwrap();
1330        let obj = json.as_object().unwrap();
1331        assert!(obj.contains_key("tables"), "Should have tables key");
1332        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1333    }
1334
1335    // ── multi-line configure-file ────────────────────────────────────────
1336    //
1337    // markdownlint scans configure-file over the whole document rather than
1338    // per line, so the comment may span lines. Every other directive stays
1339    // line-scoped in both tools.
1340
1341    // ── inline enable of a config-disabled rule ──────────────────────────
1342    //
1343    // rumdl treats config-level rule selection as final: a rule configuration
1344    // disabled is never instantiated, so an inline enable of it does nothing.
1345    // These warnings make that silent no-op visible.
1346
1347    fn active_set(names: &[&str]) -> HashSet<String> {
1348        names.iter().map(|s| (*s).to_string()).collect()
1349    }
1350
1351    #[test]
1352    fn test_inline_enable_of_inactive_rule_warns() {
1353        let active = active_set(&["MD013", "MD022"]);
1354        let content = "<!-- rumdl-enable MD012 -->\n";
1355
1356        let warnings = validate_inline_enables_against_active_rules(content, &active);
1357
1358        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1359        assert_eq!(warnings[0].rule_name, "MD012");
1360        assert_eq!(warnings[0].comment_type, "enable");
1361        assert_eq!(warnings[0].problem, InlineConfigProblem::EnableHasNoEffect);
1362        assert_eq!(warnings[0].line_number, 1);
1363    }
1364
1365    #[test]
1366    fn test_inline_enable_of_active_rule_does_not_warn() {
1367        // The load-bearing false-positive guard: enabling a rule that IS active
1368        // must stay silent.
1369        let active = active_set(&["MD012", "MD013"]);
1370        let content = "<!-- rumdl-enable MD012 -->\n";
1371
1372        let warnings = validate_inline_enables_against_active_rules(content, &active);
1373
1374        assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1375    }
1376
1377    #[test]
1378    fn test_bare_enable_all_does_not_warn() {
1379        // `enable` with no rule list means "all"; it targets no specific rule.
1380        let active = active_set(&["MD013"]);
1381        for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1382            let warnings = validate_inline_enables_against_active_rules(content, &active);
1383            assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1384        }
1385    }
1386
1387    #[test]
1388    fn test_enable_file_and_alias_of_inactive_rule_warn() {
1389        let active = active_set(&["MD013"]);
1390        // enable-file, plus an alias for an inactive rule, both flagged.
1391        let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1392
1393        let warnings = validate_inline_enables_against_active_rules(content, &active);
1394
1395        assert_eq!(warnings.len(), 1, "{warnings:?}");
1396        assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1397        assert_eq!(warnings[0].comment_type, "enable-file");
1398    }
1399
1400    #[test]
1401    fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1402        let active = active_set(&["MD013"]);
1403        let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1404        let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1405
1406        let warn_true = validate_inline_enables_against_active_rules(enable, &active);
1407        let warn_false = validate_inline_enables_against_active_rules(disable, &active);
1408
1409        assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1410        assert_eq!(warn_true[0].rule_name, "MD012");
1411        assert!(
1412            warn_false.is_empty(),
1413            "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1419        // An unrecognized name is reported by validate_inline_config_rules; it
1420        // must not also be flagged here (it is not a known-but-inactive rule).
1421        let active = active_set(&["MD013"]);
1422        let content = "<!-- rumdl-enable NotARule -->\n";
1423
1424        let warnings = validate_inline_enables_against_active_rules(content, &active);
1425
1426        assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1427    }
1428
1429    #[test]
1430    fn test_disable_directive_never_warns_as_no_effect() {
1431        // Only enables are no-ops against a disabled rule; a disable of an
1432        // inactive rule is meaningless but harmless and must stay silent.
1433        let active = active_set(&["MD013"]);
1434        let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1435
1436        let warnings = validate_inline_enables_against_active_rules(content, &active);
1437
1438        assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1439    }
1440
1441    // ── unknown option keys inside configure-file ────────────────────────
1442    //
1443    // A typo'd option key used to be dropped in silence, while the same typo
1444    // in a config file was reported with a suggestion.
1445
1446    #[test]
1447    fn test_unknown_option_key_in_configure_file_warns() {
1448        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
1449
1450        let warnings = validate_inline_config_rules(content);
1451
1452        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1453        assert_eq!(warnings[0].rule_name, "MD013");
1454        assert_eq!(
1455            warnings[0].problem,
1456            InlineConfigProblem::UnknownOption {
1457                key: "line_lenght".to_string()
1458            }
1459        );
1460        assert!(
1461            warnings[0].suggestion.is_some(),
1462            "a near-miss key should suggest the real one"
1463        );
1464        assert!(
1465            warnings[0].format_message().contains("Unknown option for rule MD013"),
1466            "message was: {}",
1467            warnings[0].format_message()
1468        );
1469    }
1470
1471    #[test]
1472    fn test_valid_option_keys_do_not_warn_in_either_case_style() {
1473        // The false-positive guard that matters: both spellings are legal, and
1474        // warning on them would be worse than the silence this replaces.
1475        for content in [
1476            r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
1477            r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
1478        ] {
1479            let warnings = validate_inline_config_rules(content);
1480            assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
1481        }
1482    }
1483
1484    #[test]
1485    fn test_unknown_rule_does_not_also_warn_about_its_options() {
1486        // The rule name is already reported; validating options of a rule that
1487        // does not exist would just be noise.
1488        let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
1489
1490        let warnings = validate_inline_config_rules(content);
1491
1492        assert_eq!(
1493            warnings.len(),
1494            1,
1495            "expected only the unknown-rule warning: {warnings:?}"
1496        );
1497        assert_eq!(warnings[0].rule_name, "nonexistent");
1498        assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
1499    }
1500
1501    #[test]
1502    fn test_boolean_rule_value_has_no_option_warnings() {
1503        let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
1504
1505        let warnings = validate_inline_config_rules(content);
1506
1507        assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
1508    }
1509
1510    #[test]
1511    fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
1512        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
1513
1514        let warnings = validate_inline_config_rules(content);
1515
1516        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1517        assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
1518    }
1519
1520    #[test]
1521    fn test_configure_file_spanning_multiple_lines_applies() {
1522        let content = "<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
1523
1524        let inline_config = InlineConfig::from_content(content);
1525        let config_override = inline_config.get_rule_config("MD013");
1526
1527        assert!(config_override.is_some(), "a multi-line configure-file must apply");
1528        let obj = config_override.unwrap().as_object().unwrap();
1529        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
1530    }
1531
1532    #[test]
1533    fn test_every_configure_file_comment_applies_not_just_the_first() {
1534        // Scanning the whole document must not collapse to the first match:
1535        // both comments configure a different rule and both must land.
1536        let content = "<!-- markdownlint-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n\n<!-- markdownlint-configure-file\n{\n  \"MD007\": { \"indent\": 4 }\n}\n-->\n\n# Head\n";
1537
1538        let inline_config = InlineConfig::from_content(content);
1539
1540        assert!(
1541            inline_config.get_rule_config("MD013").is_some(),
1542            "first (single-line) configure-file dropped"
1543        );
1544        assert!(
1545            inline_config.get_rule_config("MD007").is_some(),
1546            "second (multi-line) configure-file dropped"
1547        );
1548    }
1549
1550    #[test]
1551    fn test_multiline_configure_file_in_code_block_is_ignored() {
1552        // rumdl ignores inline config inside fences; markdownlint does not.
1553        // Widening to a whole-document scan must not lose that.
1554        let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
1555
1556        let inline_config = InlineConfig::from_content(content);
1557
1558        assert!(
1559            inline_config.get_rule_config("MD013").is_none(),
1560            "configure-file inside a fenced code block must not apply"
1561        );
1562    }
1563
1564    #[test]
1565    fn test_multiline_configure_file_bool_and_alias_still_honored() {
1566        // The boolean and alias handling must survive the move off the
1567        // per-line path, in the multi-line form too.
1568        let content = "<!-- markdownlint-configure-file\n{\n  \"no-multiple-blanks\": false,\n  \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
1569
1570        let inline_config = InlineConfig::from_content(content);
1571
1572        assert!(
1573            inline_config.is_rule_disabled("MD012", 1),
1574            "boolean alias key must disable the rule"
1575        );
1576        let obj = inline_config
1577            .get_rule_config("MD013")
1578            .expect("alias-keyed config should resolve to MD013")
1579            .as_object()
1580            .unwrap();
1581        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
1582    }
1583
1584    #[test]
1585    fn test_multiline_configure_file_warning_reports_start_line() {
1586        // An unknown rule inside a multi-line comment is reported at the line
1587        // the comment opens on, not line 1 and not the closing line.
1588        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"nonexistent\": { \"foo\": true }\n}\n-->\n";
1589
1590        let warnings = validate_inline_config_rules(content);
1591
1592        assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
1593        assert_eq!(warnings[0].rule_name, "nonexistent");
1594        assert_eq!(warnings[0].comment_type, "configure-file");
1595        assert_eq!(
1596            warnings[0].line_number, 3,
1597            "warning must point at the line the comment starts on"
1598        );
1599    }
1600
1601    #[test]
1602    fn test_configure_file_bool_false_disables_rule() {
1603        // markdownlint documents a boolean as a way to turn a rule off for the
1604        // whole file, e.g. `{ "no-trailing-spaces": false }`.
1605        let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1606
1607        let inline_config = InlineConfig::from_content(content);
1608
1609        assert!(inline_config.is_rule_disabled("MD012", 1));
1610        assert!(
1611            inline_config.get_rule_config("MD012").is_none(),
1612            "a boolean should not be stored as rule options"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_configure_file_bool_false_disables_rule_by_alias() {
1618        let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
1619
1620        let inline_config = InlineConfig::from_content(content);
1621
1622        assert!(inline_config.is_rule_disabled("MD012", 1));
1623    }
1624
1625    #[test]
1626    fn test_configure_file_bool_true_leaves_rule_enabled() {
1627        let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1628
1629        let inline_config = InlineConfig::from_content(content);
1630
1631        assert!(!inline_config.is_rule_disabled("MD012", 1));
1632    }
1633
1634    #[test]
1635    fn test_get_rule_config_from_configure_file_alias_key() {
1636        // A config written with the rule's alias must be reachable by its id.
1637        let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
1638
1639        let inline_config = InlineConfig::from_content(content);
1640        let config_override = inline_config.get_rule_config("MD013");
1641
1642        assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
1643        let obj = config_override.unwrap().as_object().unwrap();
1644        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1645    }
1646
1647    // ── parse_disable_comment / parse_enable_comment edge cases ──────────
1648
1649    #[test]
1650    fn test_parse_disable_does_not_match_disable_line() {
1651        // parse_disable_comment must NOT match disable-line or disable-next-line
1652        assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
1653        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
1654        assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
1655        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
1656        assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
1657        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
1658    }
1659
1660    #[test]
1661    fn test_parse_enable_does_not_match_enable_file() {
1662        assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
1663        assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
1664    }
1665
1666    #[test]
1667    fn test_parse_disable_comment_edge_cases() {
1668        // No space before closing
1669        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
1670
1671        // Tabs between rules
1672        assert_eq!(
1673            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
1674            Some(vec!["MD001", "MD002"])
1675        );
1676
1677        // Comment not at start of line
1678        assert_eq!(
1679            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
1680            Some(vec!["MD001"])
1681        );
1682
1683        // Malformed: no closing
1684        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
1685
1686        // Malformed: no opening
1687        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
1688
1689        // Case sensitive: uppercase should not match
1690        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
1691
1692        // Empty rule list with whitespace
1693        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
1694
1695        // Duplicate rules preserved (caller may deduplicate)
1696        assert_eq!(
1697            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
1698            Some(vec!["MD001", "MD001", "MD002"])
1699        );
1700
1701        // Unicode around the comment
1702        assert_eq!(
1703            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
1704            Some(vec!["MD001"])
1705        );
1706
1707        // 100 rules
1708        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
1709        let comment = format!("<!-- rumdl-disable {many_rules} -->");
1710        let parsed = parse_disable_comment(&comment);
1711        assert!(parsed.is_some());
1712        assert_eq!(parsed.unwrap().len(), 100);
1713
1714        // Special characters in rule names (forward compat)
1715        assert_eq!(
1716            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
1717            Some(vec!["MD001-test"])
1718        );
1719        assert_eq!(
1720            parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
1721            Some(vec!["custom_rule"])
1722        );
1723    }
1724
1725    #[test]
1726    fn test_parse_enable_comment_edge_cases() {
1727        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
1728        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
1729        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
1730        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
1731    }
1732
1733    // ── InlineConfig: code blocks must be transparent ────────────────────
1734
1735    #[test]
1736    fn test_disable_inside_fenced_code_block_ignored() {
1737        let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
1738        let config = InlineConfig::from_content(content);
1739        // The disable comment is inside a code block — must have no effect
1740        assert!(!config.is_rule_disabled("MD001", 6));
1741    }
1742
1743    #[test]
1744    fn test_disable_inside_tilde_fence_ignored() {
1745        let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
1746        let config = InlineConfig::from_content(content);
1747        assert!(!config.is_rule_disabled("MD001", 6));
1748    }
1749
1750    #[test]
1751    fn test_disable_before_code_block_persists_after() {
1752        // Disable before code block should persist through and after it
1753        let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
1754        let config = InlineConfig::from_content(content);
1755        assert!(config.is_rule_disabled("MD001", 5));
1756    }
1757
1758    #[test]
1759    fn test_enable_inside_code_block_ignored() {
1760        // Disable before, enable inside code block (should be ignored), still disabled after
1761        let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
1762        let config = InlineConfig::from_content(content);
1763        assert!(config.is_rule_disabled("MD001", 5));
1764    }
1765
1766    // ── InlineConfig: mixed comment styles ───────────────────────────────
1767
1768    #[test]
1769    fn test_markdownlint_disable_rumdl_enable_interop() {
1770        let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
1771        let config = InlineConfig::from_content(content);
1772        assert!(config.is_rule_disabled("MD001", 2));
1773        assert!(!config.is_rule_disabled("MD001", 4));
1774    }
1775
1776    #[test]
1777    fn test_rumdl_disable_markdownlint_enable_interop() {
1778        let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
1779        let config = InlineConfig::from_content(content);
1780        assert!(config.is_rule_disabled("MD013", 2));
1781        assert!(!config.is_rule_disabled("MD013", 4));
1782    }
1783
1784    // ── InlineConfig: nested/overlapping disable/enable ──────────────────
1785
1786    #[test]
1787    fn test_global_disable_then_specific_enable() {
1788        let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
1789        let config = InlineConfig::from_content(content);
1790        assert!(!config.is_rule_disabled("MD001", 4));
1791        assert!(config.is_rule_disabled("MD002", 4));
1792        assert!(config.is_rule_disabled("MD013", 4));
1793    }
1794
1795    #[test]
1796    fn test_specific_disable_then_global_enable() {
1797        let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
1798        let config = InlineConfig::from_content(content);
1799        assert!(config.is_rule_disabled("MD001", 2));
1800        assert!(config.is_rule_disabled("MD002", 2));
1801        assert!(!config.is_rule_disabled("MD001", 4));
1802        assert!(!config.is_rule_disabled("MD002", 4));
1803    }
1804
1805    #[test]
1806    fn test_multiple_rules_disable_enable_independently() {
1807        let content = "\
1808Line 1\n\
1809<!-- rumdl-disable MD001 MD002 -->\n\
1810Line 3\n\
1811<!-- rumdl-enable MD001 -->\n\
1812Line 5\n\
1813<!-- rumdl-disable -->\n\
1814Line 7\n\
1815<!-- rumdl-enable MD002 -->\n\
1816Line 9\n";
1817        let config = InlineConfig::from_content(content);
1818
1819        // Line 1: nothing disabled
1820        assert!(!config.is_rule_disabled("MD001", 1));
1821        assert!(!config.is_rule_disabled("MD002", 1));
1822
1823        // Line 3: both disabled
1824        assert!(config.is_rule_disabled("MD001", 3));
1825        assert!(config.is_rule_disabled("MD002", 3));
1826
1827        // Line 5: MD001 enabled, MD002 still disabled
1828        assert!(!config.is_rule_disabled("MD001", 5));
1829        assert!(config.is_rule_disabled("MD002", 5));
1830
1831        // Line 7: all disabled
1832        assert!(config.is_rule_disabled("MD001", 7));
1833        assert!(config.is_rule_disabled("MD002", 7));
1834
1835        // Line 9: MD002 enabled, MD001 still disabled
1836        assert!(config.is_rule_disabled("MD001", 9));
1837        assert!(!config.is_rule_disabled("MD002", 9));
1838    }
1839
1840    // ── InlineConfig: empty/minimal content ──────────────────────────────
1841
1842    #[test]
1843    fn test_empty_content() {
1844        let config = InlineConfig::from_content("");
1845        assert!(!config.is_rule_disabled("MD001", 1));
1846    }
1847
1848    #[test]
1849    fn test_single_disable_comment_only() {
1850        // Persistent disable takes effect from the NEXT line, not the current line.
1851        // For a single-line document, the disable on line 1 takes effect at line 2+.
1852        let config = InlineConfig::from_content("<!-- rumdl-disable -->");
1853        assert!(!config.is_rule_disabled("MD001", 1));
1854        assert!(config.is_rule_disabled("MD001", 2));
1855        assert!(config.is_rule_disabled("MD999", 2));
1856
1857        // With content after the disable, rules are disabled from line 2 onward
1858        let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
1859        assert!(!config.is_rule_disabled("MD001", 1));
1860        assert!(config.is_rule_disabled("MD001", 2));
1861        assert!(config.is_rule_disabled("MD001", 3));
1862    }
1863
1864    #[test]
1865    fn test_no_inline_markers() {
1866        let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
1867        assert!(!config.is_rule_disabled("MD001", 1));
1868        assert!(!config.is_rule_disabled("MD001", 5));
1869    }
1870
1871    // ── InlineConfig: export_for_file_index correctness ──────────────────
1872
1873    #[test]
1874    fn test_export_for_file_index_persistent_transitions() {
1875        let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
1876        let config = InlineConfig::from_content(content);
1877        let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
1878
1879        assert!(file_disabled.is_empty());
1880        // Should have transitions for the disable and enable
1881        assert!(
1882            persistent.len() >= 2,
1883            "Expected at least 2 transitions, got {}",
1884            persistent.len()
1885        );
1886    }
1887
1888    #[test]
1889    fn test_export_for_file_index_disable_file() {
1890        let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
1891        let config = InlineConfig::from_content(content);
1892        let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
1893
1894        assert!(file_disabled.contains("MD001"));
1895    }
1896
1897    #[test]
1898    fn test_export_for_file_index_disable_line() {
1899        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
1900        let config = InlineConfig::from_content(content);
1901        let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
1902
1903        assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
1904        assert!(line_disabled[&2].contains("MD001"));
1905        assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
1906    }
1907}