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).
33pub(crate) fn 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/// The kind of directive a rule's disabled state at a line comes from.
66///
67/// The three layers are resolved independently of each other, so removing a
68/// comment from one of them leaves the other two answering exactly as before.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DisableLayer {
71    /// `disable-file`, or a `configure-file` entry set to `false`
72    File,
73    /// `disable`, up to the `enable` that closes it
74    Block,
75    /// `disable-line`, `disable-next-line` or `prettier-ignore`
76    Line,
77}
78
79#[derive(Debug, Clone)]
80pub struct InlineConfig {
81    /// State transitions for persistent disable/enable directives, sorted by line number.
82    /// Only stores entries where the state actually changes, not for every line.
83    transitions: Vec<StateTransition>,
84    /// Rules disabled for specific lines via disable-line (1-indexed)
85    line_disabled_rules: HashMap<usize, HashSet<String>>,
86    /// Rules disabled for the entire file
87    file_disabled_rules: HashSet<String>,
88    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
89    file_enabled_rules: HashSet<String>,
90    /// Configuration overrides for specific rules from configure-file comments
91    /// Maps rule name to configuration JSON value
92    file_rule_config: HashMap<String, JsonValue>,
93}
94
95impl Default for InlineConfig {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl InlineConfig {
102    pub fn new() -> Self {
103        Self {
104            transitions: Vec::new(),
105            line_disabled_rules: HashMap::new(),
106            file_disabled_rules: HashSet::new(),
107            file_enabled_rules: HashSet::new(),
108            file_rule_config: HashMap::new(),
109        }
110    }
111
112    /// Find the state transition that applies to the given line number.
113    /// Uses binary search to find the last transition at or before the given line.
114    fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
115        if self.transitions.is_empty() {
116            return None;
117        }
118        // Binary search for the rightmost transition with line <= line_number
119        match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
120            Ok(idx) => Some(&self.transitions[idx]),
121            Err(idx) => {
122                if idx > 0 {
123                    Some(&self.transitions[idx - 1])
124                } else {
125                    None
126                }
127            }
128        }
129    }
130
131    /// Process all inline comments in the content and return the configuration state
132    pub fn from_content(content: &str) -> Self {
133        if !has_inline_config_markers(content) {
134            return Self::new();
135        }
136
137        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
138        Self::from_content_with_code_blocks_internal(content, &code_blocks)
139    }
140
141    /// Process all inline comments in the content with precomputed code blocks.
142    pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
143        if !has_inline_config_markers(content) {
144            return Self::new();
145        }
146
147        Self::from_content_with_code_blocks_internal(content, code_blocks)
148    }
149
150    fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
151        let mut config = Self::new();
152        let lines: Vec<&str> = content.lines().collect();
153
154        // configure-file is scanned over the whole document rather than per
155        // line, because it is the one directive allowed to span lines, and it
156        // applies before any enable/disable directive regardless of where it
157        // sits. Comments inside fenced code blocks are skipped.
158        for (offset, json_config) in scan_configure_file_comments(content) {
159            if offset_in_code_block(offset, code_blocks) {
160                continue;
161            }
162            let Some(obj) = json_config.as_object() else {
163                continue;
164            };
165            for (rule_name, rule_config) in obj {
166                // A boolean turns a rule off or back on, e.g.
167                // `{ "no-trailing-spaces": false }`, so route those to the
168                // disable set instead of storing them as rule options.
169                let normalized = normalize_rule_name(rule_name);
170                if let Some(enabled) = rule_config.as_bool() {
171                    if enabled {
172                        config.file_disabled_rules.remove(&normalized);
173                    } else {
174                        config.file_disabled_rules.insert(normalized);
175                    }
176                    continue;
177                }
178                // Store under the canonical rule id so lookups by `MDxxx` also
179                // find configs written with an alias, e.g.
180                // `{ "line-length": { "line_length": 70 } }`.
181                config.file_rule_config.insert(normalized, rule_config.clone());
182            }
183        }
184
185        // Pre-compute line positions for checking if a line is in a code block
186        let mut line_positions = Vec::with_capacity(lines.len());
187        let mut pos = 0;
188        for line in &lines {
189            line_positions.push(pos);
190            pos += line.len() + 1; // +1 for newline
191        }
192
193        // Track current state of disabled rules
194        let mut currently_disabled: HashSet<String> = HashSet::new();
195        let mut currently_enabled: HashSet<String> = HashSet::new();
196        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
197
198        // Track the previously recorded transition state to detect changes
199        let mut prev_disabled: HashSet<String> = HashSet::new();
200        let mut prev_enabled: HashSet<String> = HashSet::new();
201
202        // Record initial state (line 1: nothing disabled)
203        config.transitions.push(StateTransition {
204            line: 1,
205            disabled: HashSet::new(),
206            enabled: HashSet::new(),
207        });
208
209        for (idx, line) in lines.iter().enumerate() {
210            let line_num = idx + 1; // 1-indexed
211
212            // Record a transition only if state changed since last recorded transition.
213            // State for this line is the state BEFORE processing comments on this line.
214            if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
215                config.transitions.push(StateTransition {
216                    line: line_num,
217                    disabled: currently_disabled.clone(),
218                    enabled: currently_enabled.clone(),
219                });
220                prev_disabled.clone_from(&currently_disabled);
221                prev_enabled.clone_from(&currently_enabled);
222            }
223
224            // Skip processing if this line is inside a code block
225            if line_in_code_block(line_positions[idx], line, code_blocks) {
226                continue;
227            }
228
229            // Parse all directives on this line once via the unified parser.
230            // Directives come back in left-to-right order with correct disambiguation.
231            let directives = parse_inline_directives(line);
232
233            // Also check for prettier-ignore (not part of the rumdl/markdownlint format)
234            let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
235
236            // Pass 1: file-wide directives (affect the entire file, not state-tracked)
237            for directive in &directives {
238                match directive.kind {
239                    DirectiveKind::DisableFile => {
240                        if directive.rules.is_empty() {
241                            config.file_disabled_rules.clear();
242                            config.file_disabled_rules.insert("*".to_string());
243                        } else if config.file_disabled_rules.contains("*") {
244                            for rule in &directive.rules {
245                                config.file_enabled_rules.remove(&normalize_rule_name(rule));
246                            }
247                        } else {
248                            for rule in &directive.rules {
249                                config.file_disabled_rules.insert(normalize_rule_name(rule));
250                            }
251                        }
252                    }
253                    DirectiveKind::EnableFile => {
254                        if directive.rules.is_empty() {
255                            config.file_disabled_rules.clear();
256                            config.file_enabled_rules.clear();
257                        } else if config.file_disabled_rules.contains("*") {
258                            for rule in &directive.rules {
259                                config.file_enabled_rules.insert(normalize_rule_name(rule));
260                            }
261                        } else {
262                            for rule in &directive.rules {
263                                config.file_disabled_rules.remove(&normalize_rule_name(rule));
264                            }
265                        }
266                    }
267                    // configure-file is handled document-wide before this loop.
268                    _ => {}
269                }
270            }
271
272            // Pass 2: line-specific and state-changing directives (in document order)
273            for directive in &directives {
274                match directive.kind {
275                    DirectiveKind::DisableNextLine => {
276                        let next_line = line_num + 1;
277                        let line_rules = config.line_disabled_rules.entry(next_line).or_default();
278                        if directive.rules.is_empty() {
279                            line_rules.insert("*".to_string());
280                        } else {
281                            for rule in &directive.rules {
282                                line_rules.insert(normalize_rule_name(rule));
283                            }
284                        }
285                    }
286                    DirectiveKind::DisableLine => {
287                        let line_rules = config.line_disabled_rules.entry(line_num).or_default();
288                        if directive.rules.is_empty() {
289                            line_rules.insert("*".to_string());
290                        } else {
291                            for rule in &directive.rules {
292                                line_rules.insert(normalize_rule_name(rule));
293                            }
294                        }
295                    }
296                    DirectiveKind::Disable => {
297                        if directive.rules.is_empty() {
298                            currently_disabled.clear();
299                            currently_disabled.insert("*".to_string());
300                            currently_enabled.clear();
301                        } else if currently_disabled.contains("*") {
302                            for rule in &directive.rules {
303                                currently_enabled.remove(&normalize_rule_name(rule));
304                            }
305                        } else {
306                            for rule in &directive.rules {
307                                currently_disabled.insert(normalize_rule_name(rule));
308                            }
309                        }
310                    }
311                    DirectiveKind::Enable => {
312                        if directive.rules.is_empty() {
313                            currently_disabled.clear();
314                            currently_enabled.clear();
315                        } else if currently_disabled.contains("*") {
316                            for rule in &directive.rules {
317                                currently_enabled.insert(normalize_rule_name(rule));
318                            }
319                        } else {
320                            for rule in &directive.rules {
321                                currently_disabled.remove(&normalize_rule_name(rule));
322                            }
323                        }
324                    }
325                    DirectiveKind::Capture => {
326                        capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
327                    }
328                    DirectiveKind::Restore => {
329                        if let Some((disabled, enabled)) = capture_stack.pop() {
330                            currently_disabled = disabled;
331                            currently_enabled = enabled;
332                        }
333                    }
334                    // File-wide directives already handled in pass 1
335                    DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
336                }
337            }
338
339            // prettier-ignore: disables all rules for next line
340            if has_prettier_ignore {
341                let next_line = line_num + 1;
342                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
343                line_rules.insert("*".to_string());
344            }
345        }
346
347        // Record final transition if state changed after the last line was processed
348        if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
349            config.transitions.push(StateTransition {
350                line: lines.len() + 1,
351                disabled: currently_disabled,
352                enabled: currently_enabled,
353            });
354        }
355
356        config
357    }
358
359    /// Check if a rule is disabled at a specific line
360    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
361        self.disabling_layer(rule_name, line_number).is_some()
362    }
363
364    /// Which kind of directive keeps `rule_name` off at `line_number`, if any.
365    ///
366    /// A file-wide disable answers first: it decides on its own, so a rule enabled
367    /// for the file stays enabled however the line below is written. The remaining
368    /// two layers both merely disable, so the wider one answers, naming the
369    /// directive a caller would have to remove to see the rule report again.
370    pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
371        if self.file_disabled_rules.contains("*") {
372            // All rules are disabled for the file, check if this rule is explicitly enabled
373            return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
374        } else if self.file_disabled_rules.contains(rule_name) {
375            return Some(DisableLayer::File);
376        }
377
378        // Persistent disables via state transitions (binary search)
379        if let Some(transition) = self.find_transition(line_number) {
380            let disabled = if transition.disabled.contains("*") {
381                !transition.enabled.contains(rule_name)
382            } else {
383                transition.disabled.contains(rule_name)
384            };
385            if disabled {
386                return Some(DisableLayer::Block);
387            }
388        }
389
390        // Line-specific disables (disable-line, disable-next-line, prettier-ignore)
391        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
392            && (line_rules.contains("*") || line_rules.contains(rule_name))
393        {
394            return Some(DisableLayer::Line);
395        }
396
397        None
398    }
399
400    /// Get all disabled rules at a specific line
401    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
402        let mut disabled = HashSet::new();
403
404        // Add persistent disables via state transitions (binary search)
405        if let Some(transition) = self.find_transition(line_number) {
406            if transition.disabled.contains("*") {
407                disabled.insert("*".to_string());
408            } else {
409                for rule in &transition.disabled {
410                    disabled.insert(rule.clone());
411                }
412            }
413        }
414
415        // Add line-specific disables
416        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
417            for rule in line_rules {
418                disabled.insert(rule.clone());
419            }
420        }
421
422        disabled
423    }
424
425    /// Get configuration overrides for a specific rule from configure-file comments
426    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
427        self.file_rule_config.get(rule_name)
428    }
429
430    /// Get all configuration overrides from configure-file comments
431    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
432        &self.file_rule_config
433    }
434
435    /// Export the disabled rules data for storage in FileIndex.
436    ///
437    /// Returns (file_disabled_rules, persistent_transitions, line_disabled_rules).
438    pub fn export_for_file_index(&self) -> FileIndexExport {
439        let file_disabled = self.file_disabled_rules.clone();
440
441        let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
442            .transitions
443            .iter()
444            .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
445            .collect();
446
447        let line_disabled = self.line_disabled_rules.clone();
448
449        (file_disabled, persistent_transitions, line_disabled)
450    }
451}
452
453// ── Unified inline directive parser ──────────────────────────────────────────
454//
455// All inline config comments follow one pattern:
456//   <!-- (rumdl|markdownlint)-KEYWORD [RULES...] -->
457//
458// Disambiguation (e.g., "disable" vs "disable-line" vs "disable-next-line")
459// is handled ONCE here by matching the longest keyword first.
460
461/// The type of an inline configuration directive.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum DirectiveKind {
464    Disable,
465    DisableLine,
466    DisableNextLine,
467    DisableFile,
468    Enable,
469    EnableFile,
470    Capture,
471    Restore,
472    ConfigureFile,
473}
474
475/// A parsed inline configuration directive.
476#[derive(Debug, Clone, PartialEq)]
477pub struct InlineDirective<'a> {
478    pub kind: DirectiveKind,
479    pub rules: Vec<&'a str>,
480    /// Byte range of the whole comment, `<!--` through `-->`, within its line
481    pub span: std::ops::Range<usize>,
482}
483
484/// Tool prefixes recognized in inline config comments.
485const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
486
487/// Directive keywords ordered so that more-specific prefixes come first.
488/// "disable-next-line" before "disable-line" before "disable-file" before "disable";
489/// "enable-file" before "enable". This ensures longest-match-first disambiguation.
490const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
491    (DirectiveKind::DisableNextLine, "disable-next-line"),
492    (DirectiveKind::DisableLine, "disable-line"),
493    (DirectiveKind::DisableFile, "disable-file"),
494    (DirectiveKind::Disable, "disable"),
495    (DirectiveKind::EnableFile, "enable-file"),
496    (DirectiveKind::Enable, "enable"),
497    (DirectiveKind::ConfigureFile, "configure-file"),
498    (DirectiveKind::Capture, "capture"),
499    (DirectiveKind::Restore, "restore"),
500];
501
502/// Try to parse a single directive from text immediately after `<!-- `.
503/// Returns the kind, its rule list, and the number of bytes consumed (from `s`
504/// onward) so the caller can advance past `-->` and place the comment's span.
505fn try_parse_directive(s: &str) -> Option<(DirectiveKind, Vec<&str>, usize)> {
506    for tool in TOOL_PREFIXES {
507        if !s.starts_with(tool) {
508            continue;
509        }
510        let after_tool = &s[tool.len()..];
511
512        for &(kind, keyword) in DIRECTIVE_KEYWORDS {
513            if !after_tool.starts_with(keyword) {
514                continue;
515            }
516            let after_kw = &after_tool[keyword.len()..];
517
518            // Word boundary: the keyword must be followed by whitespace, `-->`, or end-of-string.
519            // This prevents "disablefoo" from matching "disable".
520            if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
521                continue;
522            }
523
524            // Find closing -->
525            let close_offset = after_kw.find("-->")?;
526
527            let rules_str = after_kw[..close_offset].trim();
528            let rules = if rules_str.is_empty() {
529                Vec::new()
530            } else {
531                rules_str.split_whitespace().collect()
532            };
533
534            let consumed = tool.len() + keyword.len() + close_offset + 3; // 3 for "-->"
535            return Some((kind, rules, consumed));
536        }
537
538        // Tool prefix matched but no keyword — not a directive we recognize.
539        return None;
540    }
541    None
542}
543
544/// Parse all inline configuration directives from a line, in left-to-right order.
545///
546/// Each directive is a typed `InlineDirective` with its kind and rule list.
547/// Disambiguation between overlapping prefixes (e.g., `disable` vs `disable-line`)
548/// is handled by matching the longest keyword first — no ad-hoc guards needed.
549pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
550    let mut results = Vec::new();
551    let mut pos = 0;
552
553    while pos < line.len() {
554        let remaining = &line[pos..];
555        let Some(open_offset) = remaining.find("<!-- ") else {
556            break;
557        };
558        let comment_start = pos + open_offset;
559        let after_open = &line[comment_start + 5..]; // skip "<!-- "
560
561        if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
562            let comment_end = comment_start + 5 + consumed;
563            results.push(InlineDirective {
564                kind,
565                rules,
566                span: comment_start..comment_end,
567            });
568            pos = comment_end;
569        } else {
570            pos = comment_start + 5;
571        }
572    }
573
574    results
575}
576
577// ── Backward-compatible wrapper functions ────────────────────────────────────
578//
579// These delegate to parse_inline_directives and filter by DirectiveKind.
580// External callers (e.g., MD040) use these; internal code uses the unified parser.
581
582fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
583    parse_inline_directives(line)
584        .into_iter()
585        .find(|d| d.kind == kind)
586        .map(|d| d.rules)
587}
588
589/// Parse a disable comment and return the list of rules (empty vec means all rules)
590pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
591    find_directive_rules(line, DirectiveKind::Disable)
592}
593
594/// Parse an enable comment and return the list of rules (empty vec means all rules)
595pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
596    find_directive_rules(line, DirectiveKind::Enable)
597}
598
599/// Parse a disable-line comment
600pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
601    find_directive_rules(line, DirectiveKind::DisableLine)
602}
603
604/// Parse a disable-next-line comment
605pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
606    find_directive_rules(line, DirectiveKind::DisableNextLine)
607}
608
609/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
610pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
611    find_directive_rules(line, DirectiveKind::DisableFile)
612}
613
614/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
615pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
616    find_directive_rules(line, DirectiveKind::EnableFile)
617}
618
619/// Check if line contains a capture comment
620pub fn is_capture_comment(line: &str) -> bool {
621    parse_inline_directives(line)
622        .iter()
623        .any(|d| d.kind == DirectiveKind::Capture)
624}
625
626/// Check if line contains a restore comment
627pub fn is_restore_comment(line: &str) -> bool {
628    parse_inline_directives(line)
629        .iter()
630        .any(|d| d.kind == DirectiveKind::Restore)
631}
632
633const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
634
635/// Whether a byte offset falls inside one of the given code block ranges.
636fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
637    code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
638}
639
640/// Whether a directive written on this line sits inside a code block.
641///
642/// The line is probed at its first non-whitespace byte, because an indented code
643/// block's range starts at the indented content rather than at the start of the
644/// line. Asking whether the whole line span is contained would answer "no" for
645/// every indented block and let a directive written in a code sample configure
646/// the document.
647fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
648    let probe = line
649        .find(|c: char| !c.is_whitespace())
650        .map_or(line_start, |indent| line_start + indent);
651    offset_in_code_block(probe, code_blocks)
652}
653
654/// The 1-indexed line a byte offset falls on.
655fn line_of_offset(text: &str, offset: usize) -> usize {
656    text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
657}
658
659/// Drop warnings raised by a directive a code block covers.
660///
661/// `InlineConfig` skips directives inside code blocks, so a fenced example
662/// documenting a directive configures nothing and there is nothing to report
663/// about it. The ranges cost a parse of the document, so they are computed only
664/// once there is a warning to filter.
665fn drop_warnings_inside_code_blocks(content: &str, warnings: &mut Vec<InlineConfigWarning>) {
666    if warnings.is_empty() {
667        return;
668    }
669    let code_blocks = CodeBlockUtils::detect_code_blocks(content);
670    if code_blocks.is_empty() {
671        return;
672    }
673
674    // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a CRLF
675    // document's offsets match the ranges above.
676    let line_spans: Vec<(usize, &str)> = {
677        let mut spans = Vec::new();
678        let mut start = 0;
679        for line in content.split('\n') {
680            spans.push((start, line));
681            start += line.len() + 1;
682        }
683        spans
684    };
685
686    warnings.retain(|warning| {
687        let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
688            return true;
689        };
690        !line_in_code_block(line_start, line, &code_blocks)
691    });
692}
693
694/// Find every configure-file comment in `text`, returning each JSON payload
695/// with the byte offset of its opening `<!--`.
696///
697/// `text` is normally the whole document: unlike every other directive, a
698/// configure-file comment may span lines, so its `-->` is searched for without
699/// regard to line boundaries. The offset lets callers map a payload back to a
700/// line number or test it against code block ranges.
701///
702/// Payloads that are empty or not valid JSON are skipped, and scanning
703/// continues past them.
704fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
705    let mut found = Vec::new();
706    let mut pos = 0;
707
708    while let Some(open_offset) = text[pos..].find("<!-- ") {
709        let comment_start = pos + open_offset;
710        let after_open = &text[comment_start + 5..]; // skip "<!-- "
711        // Advance past this opener by default, so an unrecognized or malformed
712        // comment cannot stall the scan.
713        pos = comment_start + 5;
714
715        for tool in TOOL_PREFIXES {
716            let Some(after_tool) = after_open.strip_prefix(tool) else {
717                continue;
718            };
719            let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
720                break;
721            };
722            // Word boundary: the keyword must be followed by whitespace or `-->`,
723            // so `configure-files` does not match.
724            if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
725                break;
726            }
727            let Some(close_offset) = after_kw.find("-->") else {
728                break;
729            };
730
731            let json_str = after_kw[..close_offset].trim();
732            if !json_str.is_empty()
733                && let Ok(value) = serde_json::from_str(json_str)
734            {
735                found.push((comment_start, value));
736            }
737            pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
738            break;
739        }
740    }
741
742    found
743}
744
745/// Parse a configure-file comment and return the JSON configuration.
746///
747/// Returns the first payload found. The text may span lines.
748pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
749    scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
750}
751
752// ── Disable-comment inventory ────────────────────────────────────────────────
753
754/// The lines an inline disable comment can suppress a finding on.
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum DisableScope {
757    /// Exactly this 1-indexed line
758    Line(usize),
759    /// The lines below this 1-indexed one, up to the end of the document
760    Block(usize),
761    /// Every line of the document
762    File,
763}
764
765impl DisableScope {
766    /// Whether this comment is one of those keeping a rule off at `line`, given the
767    /// layer that answered for it there.
768    ///
769    /// Answering for a line credits every comment of that layer reaching it, so no
770    /// comment loses its credit to another. A comment left uncredited is one whose
771    /// every line is spoken for by a layer it is not in, and removing it therefore
772    /// changes nothing the run reports.
773    ///
774    /// A block `disable` is treated as reaching the end of the document even when a
775    /// later `enable` closes it. Crediting a comment for more than it holds can only
776    /// leave a stale comment unreported.
777    pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
778        match (self, layer) {
779            (Self::Line(target), DisableLayer::Line) => target == line,
780            // A block disable takes effect on the line after the comment.
781            (Self::Block(start), DisableLayer::Block) => start < line,
782            (Self::File, DisableLayer::File) => true,
783            _ => false,
784        }
785    }
786}
787
788/// An inline comment that disables rules, and the lines it can act on.
789#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct DisableSite {
791    /// 1-indexed line the comment opens on
792    pub line: usize,
793    /// Byte range of the comment within that line, clipped to the line's end
794    pub span: std::ops::Range<usize>,
795    /// The directive as written, e.g. `disable-line`
796    pub kind: &'static str,
797    /// The rule names the comment carries, as written; empty means every rule
798    pub rules: Vec<String>,
799    /// The lines the comment can suppress a finding on
800    pub scope: DisableScope,
801}
802
803/// Every inline comment that disables rules, in document order.
804///
805/// Comments inside code blocks configure nothing, so they are left out, matching
806/// what `InlineConfig` applies. `prettier-ignore` belongs to another formatter
807/// and is left out as well.
808pub fn collect_disable_sites(content: &str) -> Vec<DisableSite> {
809    if !has_inline_config_markers(content) {
810        return Vec::new();
811    }
812
813    let code_blocks = CodeBlockUtils::detect_code_blocks(content);
814
815    // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a CRLF
816    // document's offsets match the code block ranges.
817    let line_spans: Vec<(usize, &str)> = {
818        let mut spans = Vec::new();
819        let mut start = 0;
820        for line in content.split('\n') {
821            spans.push((start, line));
822            start += line.len() + 1;
823        }
824        spans
825    };
826
827    let mut sites = Vec::new();
828
829    for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
830        if line_in_code_block(line_start, line, &code_blocks) {
831            continue;
832        }
833        let line_num = idx + 1;
834        for directive in parse_inline_directives(line) {
835            let (kind, scope) = match directive.kind {
836                DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
837                DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
838                DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
839                DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
840                DirectiveKind::Enable
841                | DirectiveKind::EnableFile
842                | DirectiveKind::Capture
843                | DirectiveKind::Restore
844                | DirectiveKind::ConfigureFile => continue,
845            };
846            sites.push(DisableSite {
847                line: line_num,
848                span: directive.span,
849                kind,
850                rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
851                scope,
852            });
853        }
854    }
855
856    // A configure-file entry given `false` turns its rule off for the whole file,
857    // exactly as disable-file does. The comment may span lines, so it is scanned
858    // over the document and its span is clipped to the line it opens on.
859    for (offset, json_config) in scan_configure_file_comments(content) {
860        if offset_in_code_block(offset, &code_blocks) {
861            continue;
862        }
863        let Some(obj) = json_config.as_object() else {
864            continue;
865        };
866        let rules: Vec<String> = obj
867            .iter()
868            .filter(|(_, value)| value.as_bool() == Some(false))
869            .map(|(name, _)| name.clone())
870            .collect();
871        if rules.is_empty() {
872            continue;
873        }
874        let line_num = line_of_offset(content, offset);
875        let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
876            continue;
877        };
878        let comment_end = content[offset..]
879            .find("-->")
880            .map_or(content.len(), |close| offset + close + 3);
881        sites.push(DisableSite {
882            line: line_num,
883            span: (offset - line_start)..(comment_end - line_start).min(line.len()),
884            kind: "configure-file",
885            rules,
886            scope: DisableScope::File,
887        });
888    }
889
890    sites.sort_by_key(|site| (site.line, site.span.start));
891    sites
892}
893
894/// What is wrong with an inline config comment.
895#[derive(Debug, Clone, PartialEq, Eq)]
896pub enum InlineConfigProblem {
897    /// A rule name that is not recognized.
898    UnknownRule,
899    /// A recognized rule carrying an unrecognized option key inside a
900    /// configure-file config object.
901    UnknownOption { key: String },
902    /// An inline directive tries to enable a rule that will not run over this
903    /// document. rumdl treats config-level rule selection as final, so the
904    /// enable has no effect; this makes that silent no-op visible.
905    EnableHasNoEffect { reason: EnableNoEffectReason },
906}
907
908/// Why an inline enable cannot take effect.
909///
910/// The two are separate settings, so the message names the one actually in play
911/// rather than sending the reader to a knob their config does not have set.
912#[derive(Debug, Clone, Copy, PartialEq, Eq)]
913pub enum EnableNoEffectReason {
914    /// Configuration did not enable the rule for this run at all.
915    NotEnabled,
916    /// Configuration enabled the rule, but `per-file-ignores` suppresses it for
917    /// this particular file.
918    IgnoredForFile,
919}
920
921/// Warning about an inline config comment.
922#[derive(Debug, Clone, PartialEq, Eq)]
923pub struct InlineConfigWarning {
924    /// The line number where the warning occurred (1-indexed)
925    pub line_number: usize,
926    /// The rule the warning concerns
927    pub rule_name: String,
928    /// The type of inline config comment (e.g. "disable", "configure-file")
929    pub comment_type: String,
930    /// Suggestion for a similar rule name or option key, when one is close
931    pub suggestion: Option<String>,
932    /// What is wrong
933    pub problem: InlineConfigProblem,
934}
935
936impl InlineConfigWarning {
937    /// Format the warning message
938    pub fn format_message(&self) -> String {
939        // Wording for unknown rules/options matches the config-file validator so
940        // the same mistake reads the same way wherever it is written.
941        match &self.problem {
942            InlineConfigProblem::UnknownOption { key } => match self.suggestion {
943                Some(ref suggestion) => format!(
944                    "Unknown option for rule {}: {} (did you mean: {}?)",
945                    self.rule_name, key, suggestion
946                ),
947                None => format!("Unknown option for rule {}: {}", self.rule_name, key),
948            },
949            InlineConfigProblem::UnknownRule => match self.suggestion {
950                Some(ref suggestion) => format!(
951                    "Unknown rule in inline {} comment: {} (did you mean: {}?)",
952                    self.comment_type, self.rule_name, suggestion
953                ),
954                None => format!(
955                    "Unknown rule in inline {} comment: {}",
956                    self.comment_type, self.rule_name
957                ),
958            },
959            InlineConfigProblem::EnableHasNoEffect {
960                reason: EnableNoEffectReason::NotEnabled,
961            } => format!(
962                "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
963                self.rule_name, self.comment_type
964            ),
965            InlineConfigProblem::EnableHasNoEffect {
966                reason: EnableNoEffectReason::IgnoredForFile,
967            } => format!(
968                "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
969                self.rule_name, self.comment_type
970            ),
971        }
972    }
973
974    /// Print the warning to stderr with file context
975    pub fn print_warning(&self, file_path: &str) {
976        eprintln!(
977            "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
978            file_path,
979            self.line_number,
980            self.format_message()
981        );
982    }
983
984    /// The warning as a lint diagnostic anchored to the comment that raised it.
985    ///
986    /// A directive naming a rule that does not exist silently does nothing, and on
987    /// the CLI that is a line of stderr next to the findings. Editors show
988    /// diagnostics, so the language server reports it as one and the mistake is
989    /// visible where it was written. The span covers the whole line: the validator
990    /// locates the comment, not the rule name inside it.
991    pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
992        let line_width = content
993            .lines()
994            .nth(self.line_number.saturating_sub(1))
995            .map_or(0, |line| line.chars().count());
996        crate::rule::LintWarning {
997            message: self.format_message(),
998            line: self.line_number,
999            column: 1,
1000            end_line: self.line_number,
1001            end_column: line_width + 1,
1002            severity: crate::rule::Severity::Warning,
1003            fix: None,
1004            rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1005        }
1006    }
1007}
1008
1009/// The diagnostic code inline config warnings carry.
1010///
1011/// Not a rule name: it names the class of problem so an editor can tell these
1012/// apart from rule violations, and so nothing looks it up in the rule registry.
1013pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1014
1015/// Validate all inline config comments in content and return warnings for unknown rules.
1016///
1017/// This function extracts rule names from all types of inline config comments
1018/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
1019/// and validates them against the known rule alias map. Comments inside code
1020/// blocks are documentation rather than configuration, so they are left alone,
1021/// matching what `InlineConfig` applies.
1022pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
1023    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1024
1025    let mut warnings = Vec::new();
1026    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
1027
1028    let suggest = |rule_name: &str| {
1029        suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1030    };
1031
1032    // configure-file carries its rule names as JSON keys and may span lines, so
1033    // it is scanned over the whole document. Warnings are reported against the
1034    // line the comment opens on.
1035    let registry = crate::config::default_registry();
1036    for (offset, json_config) in scan_configure_file_comments(content) {
1037        let Some(obj) = json_config.as_object() else {
1038            continue;
1039        };
1040        let line_number = line_of_offset(content, offset);
1041        for (rule_name, rule_config) in obj {
1042            if !is_valid_rule_name(rule_name) {
1043                warnings.push(InlineConfigWarning {
1044                    line_number,
1045                    rule_name: rule_name.clone(),
1046                    comment_type: "configure-file".to_string(),
1047                    suggestion: suggest(rule_name),
1048                    problem: InlineConfigProblem::UnknownRule,
1049                });
1050                // The rule itself is unknown, so its options cannot be checked
1051                // against anything and would only add noise.
1052                continue;
1053            }
1054            // A boolean turns the rule on or off and carries no options.
1055            let Some(options) = rule_config.as_object() else {
1056                continue;
1057            };
1058            let canonical = normalize_rule_name(rule_name);
1059            let Some(valid_keys) = registry.config_keys_for(&canonical) else {
1060                continue;
1061            };
1062            let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1063            for key in options.keys() {
1064                if !valid_keys.contains(key) {
1065                    warnings.push(InlineConfigWarning {
1066                        line_number,
1067                        rule_name: canonical.clone(),
1068                        comment_type: "configure-file".to_string(),
1069                        suggestion: suggest_similar_key(key, &valid_keys_vec),
1070                        problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1071                    });
1072                }
1073            }
1074        }
1075    }
1076
1077    for (idx, line) in content.lines().enumerate() {
1078        let line_num = idx + 1;
1079
1080        // Parse all directives on this line once
1081        let directives = parse_inline_directives(line);
1082        let mut rule_entries: Vec<(&str, &str)> = Vec::new();
1083
1084        for directive in &directives {
1085            let comment_type = match directive.kind {
1086                DirectiveKind::Disable => "disable",
1087                DirectiveKind::Enable => "enable",
1088                DirectiveKind::DisableLine => "disable-line",
1089                DirectiveKind::DisableNextLine => "disable-next-line",
1090                DirectiveKind::DisableFile => "disable-file",
1091                DirectiveKind::EnableFile => "enable-file",
1092                // configure-file is scanned document-wide above.
1093                DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
1094            };
1095            for rule in &directive.rules {
1096                rule_entries.push((rule, comment_type));
1097            }
1098        }
1099
1100        // Validate each rule name
1101        for (rule_name, comment_type) in rule_entries {
1102            if !is_valid_rule_name(rule_name) {
1103                warnings.push(InlineConfigWarning {
1104                    line_number: line_num,
1105                    rule_name: rule_name.to_string(),
1106                    comment_type: comment_type.to_string(),
1107                    suggestion: suggest(rule_name),
1108                    problem: InlineConfigProblem::UnknownRule,
1109                });
1110            }
1111        }
1112    }
1113
1114    // configure-file warnings are collected ahead of the per-line pass, so
1115    // restore document order before returning.
1116    warnings.sort_by_key(|w| w.line_number);
1117    drop_warnings_inside_code_blocks(content, &mut warnings);
1118    warnings
1119}
1120
1121/// Warn when an inline directive tries to ENABLE a rule that will not run over
1122/// this document, so the enable has no effect.
1123///
1124/// rumdl removes disabled rules from the rule set before any file is read
1125/// (`filter_rules`), so a disabled rule is never instantiated and inline config
1126/// cannot bring it back. `per-file-ignores` removes further rules for the file
1127/// at hand, with the same finality. `active_rules` is the set of canonical rule
1128/// ids configuration left enabled and `ignored_for_file` the ids
1129/// `per-file-ignores` then takes away; a valid rule outside the first, or inside
1130/// the second, is not running.
1131///
1132/// Only recognized rule names warn. Unknown names are left to
1133/// `validate_inline_config_rules`, a bare `enable`/`enable-file` (no rules,
1134/// meaning "all") targets no specific rule, a `configure-file` boolean warns
1135/// only for `true` (an enable), never `false` (a disable), and a directive
1136/// inside a code block enables nothing to begin with.
1137pub fn validate_inline_enables_against_active_rules(
1138    content: &str,
1139    active_rules: &HashSet<String>,
1140    ignored_for_file: &HashSet<String>,
1141) -> Vec<InlineConfigWarning> {
1142    use crate::config::is_valid_rule_name;
1143
1144    let mut warnings = Vec::new();
1145
1146    let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
1147        // Skip unrecognized names, which are handled elsewhere.
1148        if !is_valid_rule_name(name) {
1149            return;
1150        }
1151        let canonical = normalize_rule_name(name);
1152        // A rule configuration never enabled reports that, even when
1153        // per-file-ignores also names it: the redundant ignore entry is not the
1154        // thing standing in the way.
1155        let reason = if !active_rules.contains(&canonical) {
1156            EnableNoEffectReason::NotEnabled
1157        } else if ignored_for_file.contains(&canonical) {
1158            EnableNoEffectReason::IgnoredForFile
1159        } else {
1160            return;
1161        };
1162        warnings.push(InlineConfigWarning {
1163            line_number: line,
1164            rule_name: canonical,
1165            comment_type: comment_type.to_string(),
1166            suggestion: None,
1167            problem: InlineConfigProblem::EnableHasNoEffect { reason },
1168        });
1169    };
1170
1171    // configure-file may span lines and is scanned over the whole document.
1172    for (offset, json_config) in scan_configure_file_comments(content) {
1173        let Some(obj) = json_config.as_object() else {
1174            continue;
1175        };
1176        let line = line_of_offset(content, offset);
1177        for (rule_name, rule_config) in obj {
1178            // Only a boolean `true` is an enable; `false` disables and an
1179            // options object configures without enabling.
1180            if rule_config.as_bool() == Some(true) {
1181                flag(&mut warnings, rule_name, "configure-file", line);
1182            }
1183        }
1184    }
1185
1186    // enable / enable-file are line-scoped; an empty rule list means "all".
1187    for (idx, line) in content.lines().enumerate() {
1188        for directive in parse_inline_directives(line) {
1189            let comment_type = match directive.kind {
1190                DirectiveKind::Enable => "enable",
1191                DirectiveKind::EnableFile => "enable-file",
1192                _ => continue,
1193            };
1194            for rule in &directive.rules {
1195                flag(&mut warnings, rule, comment_type, idx + 1);
1196            }
1197        }
1198    }
1199
1200    warnings.sort_by_key(|w| w.line_number);
1201    drop_warnings_inside_code_blocks(content, &mut warnings);
1202    warnings
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    // ── Unified parser tests ─────────────────────────────────────────────
1210
1211    #[test]
1212    fn test_parse_inline_directives_all_kinds() {
1213        // Every directive kind is correctly identified
1214        let cases: &[(&str, DirectiveKind)] = &[
1215            ("<!-- rumdl-disable -->", DirectiveKind::Disable),
1216            ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
1217            ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
1218            ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
1219            ("<!-- rumdl-enable -->", DirectiveKind::Enable),
1220            ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
1221            ("<!-- rumdl-capture -->", DirectiveKind::Capture),
1222            ("<!-- rumdl-restore -->", DirectiveKind::Restore),
1223            ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
1224            // markdownlint variants
1225            ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
1226            ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
1227            (
1228                "<!-- markdownlint-disable-next-line -->",
1229                DirectiveKind::DisableNextLine,
1230            ),
1231            ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
1232            ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
1233            ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
1234        ];
1235        for (input, expected_kind) in cases {
1236            let directives = parse_inline_directives(input);
1237            assert_eq!(
1238                directives.len(),
1239                1,
1240                "Expected 1 directive for {input:?}, got {directives:?}"
1241            );
1242            assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
1243        }
1244    }
1245
1246    #[test]
1247    fn test_parse_inline_directives_disambiguation() {
1248        // The core property: "disable" must NOT match "disable-line" etc.
1249        let line = "<!-- rumdl-disable-line MD001 -->";
1250        let directives = parse_inline_directives(line);
1251        assert_eq!(directives.len(), 1);
1252        assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
1253
1254        let line = "<!-- rumdl-disable-next-line -->";
1255        let directives = parse_inline_directives(line);
1256        assert_eq!(directives.len(), 1);
1257        assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
1258
1259        let line = "<!-- rumdl-disable-file MD001 -->";
1260        let directives = parse_inline_directives(line);
1261        assert_eq!(directives.len(), 1);
1262        assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
1263
1264        let line = "<!-- rumdl-enable-file -->";
1265        let directives = parse_inline_directives(line);
1266        assert_eq!(directives.len(), 1);
1267        assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
1268    }
1269
1270    #[test]
1271    fn test_parse_inline_directives_no_space_before_close() {
1272        // <!-- rumdl-disable--> must parse as Disable (the bug that started this refactor)
1273        let directives = parse_inline_directives("<!-- rumdl-disable-->");
1274        assert_eq!(directives.len(), 1);
1275        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1276        assert!(directives[0].rules.is_empty());
1277
1278        let directives = parse_inline_directives("<!-- rumdl-enable-->");
1279        assert_eq!(directives.len(), 1);
1280        assert_eq!(directives[0].kind, DirectiveKind::Enable);
1281    }
1282
1283    #[test]
1284    fn test_parse_inline_directives_multiple_on_one_line() {
1285        let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1286        let directives = parse_inline_directives(line);
1287        assert_eq!(directives.len(), 2);
1288        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1289        assert_eq!(directives[0].rules, vec!["MD001"]);
1290        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1291        assert_eq!(directives[1].rules, vec!["MD001"]);
1292    }
1293
1294    #[test]
1295    fn test_parse_inline_directives_global_disable_then_specific_enable() {
1296        let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1297        let directives = parse_inline_directives(line);
1298        assert_eq!(directives.len(), 2);
1299        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1300        assert!(directives[0].rules.is_empty());
1301        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1302        assert_eq!(directives[1].rules, vec!["MD001"]);
1303    }
1304
1305    #[test]
1306    fn test_parse_inline_directives_word_boundary() {
1307        // "disablefoo" should NOT match "disable"
1308        assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1309        // "enablebar" should NOT match "enable"
1310        assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1311        // "captures" should NOT match "capture"
1312        assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1313    }
1314
1315    #[test]
1316    fn test_parse_inline_directives_no_closing_tag() {
1317        // Missing --> means no directive
1318        assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1319        assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1320    }
1321
1322    #[test]
1323    fn test_parse_inline_directives_not_a_comment() {
1324        assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1325        assert!(parse_inline_directives("Some regular text").is_empty());
1326        assert!(parse_inline_directives("").is_empty());
1327    }
1328
1329    #[test]
1330    fn test_parse_inline_directives_case_sensitive() {
1331        assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1332        assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1333    }
1334
1335    #[test]
1336    fn test_parse_inline_directives_rules_extraction() {
1337        let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1338        assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1339
1340        // Tabs between rules
1341        let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1342        assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1343
1344        // Extra whitespace
1345        let directives = parse_inline_directives("<!-- rumdl-disable   MD001   -->");
1346        assert_eq!(directives[0].rules, vec!["MD001"]);
1347    }
1348
1349    #[test]
1350    fn test_parse_inline_directives_embedded_in_text() {
1351        let line = "Some text <!-- rumdl-disable MD001 --> more text";
1352        let directives = parse_inline_directives(line);
1353        assert_eq!(directives.len(), 1);
1354        assert_eq!(directives[0].rules, vec!["MD001"]);
1355
1356        let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1357        let directives = parse_inline_directives(line);
1358        assert_eq!(directives.len(), 1);
1359        assert_eq!(directives[0].rules, vec!["MD001"]);
1360    }
1361
1362    #[test]
1363    fn test_parse_inline_directives_mixed_tools_same_line() {
1364        let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1365        let directives = parse_inline_directives(line);
1366        assert_eq!(directives.len(), 2);
1367        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1368        assert_eq!(directives[0].rules, vec!["MD001"]);
1369        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1370        assert_eq!(directives[1].rules, vec!["MD002"]);
1371    }
1372
1373    // ── Backward-compatible wrapper tests ────────────────────────────────
1374
1375    #[test]
1376    fn test_parse_disable_comment() {
1377        // Global disable
1378        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1379        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1380
1381        // Specific rules
1382        assert_eq!(
1383            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1384            Some(vec!["MD001", "MD002"])
1385        );
1386
1387        // No comment
1388        assert_eq!(parse_disable_comment("Some regular text"), None);
1389    }
1390
1391    #[test]
1392    fn test_parse_disable_line_comment() {
1393        // Global disable-line
1394        assert_eq!(
1395            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1396            Some(vec![])
1397        );
1398
1399        // Specific rules
1400        assert_eq!(
1401            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1402            Some(vec!["MD013"])
1403        );
1404
1405        // No comment
1406        assert_eq!(parse_disable_line_comment("Some regular text"), None);
1407    }
1408
1409    #[test]
1410    fn test_inline_config_from_content() {
1411        let content = r#"# Test Document
1412
1413<!-- markdownlint-disable MD013 -->
1414This is a very long line that would normally trigger MD013 but it's disabled
1415
1416<!-- markdownlint-enable MD013 -->
1417This line will be checked again
1418
1419<!-- markdownlint-disable-next-line MD001 -->
1420# This heading will not be checked for MD001
1421## But this one will
1422
1423Some text <!-- markdownlint-disable-line MD013 -->
1424
1425<!-- markdownlint-capture -->
1426<!-- markdownlint-disable MD001 MD002 -->
1427# Heading with MD001 disabled
1428<!-- markdownlint-restore -->
1429# Heading with MD001 enabled again
1430"#;
1431
1432        let config = InlineConfig::from_content(content);
1433
1434        // Line 4 should have MD013 disabled (line after disable comment on line 3)
1435        assert!(config.is_rule_disabled("MD013", 4));
1436
1437        // Line 7 should have MD013 enabled (line after enable comment on line 6)
1438        assert!(!config.is_rule_disabled("MD013", 7));
1439
1440        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
1441        assert!(config.is_rule_disabled("MD001", 10));
1442
1443        // Line 11 should not have MD001 disabled
1444        assert!(!config.is_rule_disabled("MD001", 11));
1445
1446        // Line 13 should have MD013 disabled (from disable-line)
1447        assert!(config.is_rule_disabled("MD013", 13));
1448
1449        // After restore (line 18), MD001 should be enabled again on line 19
1450        assert!(!config.is_rule_disabled("MD001", 19));
1451    }
1452
1453    #[test]
1454    fn test_capture_restore() {
1455        let content = r#"<!-- markdownlint-disable MD001 -->
1456<!-- markdownlint-capture -->
1457<!-- markdownlint-disable MD002 MD003 -->
1458<!-- markdownlint-restore -->
1459Some content after restore
1460"#;
1461
1462        let config = InlineConfig::from_content(content);
1463
1464        // After restore (line 4), line 5 should only have MD001 disabled
1465        assert!(config.is_rule_disabled("MD001", 5));
1466        assert!(!config.is_rule_disabled("MD002", 5));
1467        assert!(!config.is_rule_disabled("MD003", 5));
1468    }
1469
1470    #[test]
1471    fn test_validate_inline_config_rules_unknown_rule() {
1472        let content = "<!-- rumdl-disable abc -->\nSome content";
1473        let warnings = validate_inline_config_rules(content);
1474        assert_eq!(warnings.len(), 1);
1475        assert_eq!(warnings[0].line_number, 1);
1476        assert_eq!(warnings[0].rule_name, "abc");
1477        assert_eq!(warnings[0].comment_type, "disable");
1478    }
1479
1480    #[test]
1481    fn test_validate_inline_config_rules_valid_rule() {
1482        let content = "<!-- rumdl-disable MD001 -->\nSome content";
1483        let warnings = validate_inline_config_rules(content);
1484        assert!(
1485            warnings.is_empty(),
1486            "MD001 is a valid rule, should not produce warnings"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_validate_inline_config_rules_alias() {
1492        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1493        let warnings = validate_inline_config_rules(content);
1494        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1495    }
1496
1497    #[test]
1498    fn test_validate_inline_config_rules_multiple_unknown() {
1499        let content = r#"<!-- rumdl-disable abc xyz -->
1500<!-- rumdl-disable-line foo -->
1501<!-- markdownlint-disable-next-line bar -->
1502"#;
1503        let warnings = validate_inline_config_rules(content);
1504        assert_eq!(warnings.len(), 4);
1505        assert_eq!(warnings[0].rule_name, "abc");
1506        assert_eq!(warnings[1].rule_name, "xyz");
1507        assert_eq!(warnings[2].rule_name, "foo");
1508        assert_eq!(warnings[3].rule_name, "bar");
1509    }
1510
1511    #[test]
1512    fn test_validate_inline_config_rules_suggestion() {
1513        // "MD00" should suggest "MD001" (or similar)
1514        let content = "<!-- rumdl-disable MD00 -->\n";
1515        let warnings = validate_inline_config_rules(content);
1516        assert_eq!(warnings.len(), 1);
1517        // Should have a suggestion since "MD00" is close to "MD001"
1518        assert!(warnings[0].suggestion.is_some());
1519    }
1520
1521    #[test]
1522    fn test_validate_inline_config_rules_file_comments() {
1523        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1524        let warnings = validate_inline_config_rules(content);
1525        assert_eq!(warnings.len(), 2);
1526        assert_eq!(warnings[0].comment_type, "disable-file");
1527        assert_eq!(warnings[1].comment_type, "enable-file");
1528    }
1529
1530    #[test]
1531    fn test_validate_inline_config_rules_global_disable() {
1532        // Global disable (no specific rules) should not produce warnings
1533        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1534        let warnings = validate_inline_config_rules(content);
1535        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1536    }
1537
1538    #[test]
1539    fn test_validate_inline_config_rules_mixed_valid_invalid() {
1540        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
1541        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1542        let warnings = validate_inline_config_rules(content);
1543        assert_eq!(warnings.len(), 2);
1544        assert_eq!(warnings[0].rule_name, "abc");
1545        assert_eq!(warnings[1].rule_name, "xyz");
1546    }
1547
1548    #[test]
1549    fn test_validate_inline_config_rules_configure_file() {
1550        // configure-file comments contain rule names as JSON keys
1551        let content =
1552            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1553        let warnings = validate_inline_config_rules(content);
1554        assert_eq!(warnings.len(), 1);
1555        assert_eq!(warnings[0].rule_name, "nonexistent");
1556        assert_eq!(warnings[0].comment_type, "configure-file");
1557    }
1558
1559    #[test]
1560    fn test_validate_inline_config_rules_markdownlint_variants() {
1561        // Test markdownlint-* variants (not just rumdl-*)
1562        let content = r#"<!-- markdownlint-disable unknown_rule -->
1563<!-- markdownlint-enable another_fake -->
1564<!-- markdownlint-disable-line bad_rule -->
1565<!-- markdownlint-disable-next-line fake_rule -->
1566<!-- markdownlint-disable-file missing_rule -->
1567<!-- markdownlint-enable-file nonexistent -->
1568"#;
1569        let warnings = validate_inline_config_rules(content);
1570        assert_eq!(warnings.len(), 6);
1571        assert_eq!(warnings[0].rule_name, "unknown_rule");
1572        assert_eq!(warnings[1].rule_name, "another_fake");
1573        assert_eq!(warnings[2].rule_name, "bad_rule");
1574        assert_eq!(warnings[3].rule_name, "fake_rule");
1575        assert_eq!(warnings[4].rule_name, "missing_rule");
1576        assert_eq!(warnings[5].rule_name, "nonexistent");
1577    }
1578
1579    /// A directive inside a code block configures nothing, so documenting one in
1580    /// a fenced example must not be reported. The control outside the fence
1581    /// proves the same directive is still validated where it takes effect.
1582    #[test]
1583    fn test_validate_inline_config_rules_ignores_code_blocks() {
1584        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
1585        assert!(
1586            validate_inline_config_rules(fenced).is_empty(),
1587            "a directive inside a fence is documentation, not configuration"
1588        );
1589
1590        let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
1591        assert!(validate_inline_config_rules(tilde).is_empty());
1592
1593        let indented = "# Doc\n\n    <!-- rumdl-disable made_up_rule -->\n";
1594        assert!(validate_inline_config_rules(indented).is_empty());
1595
1596        let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
1597        assert_eq!(validate_inline_config_rules(outside).len(), 1);
1598    }
1599
1600    /// configure-file is scanned over the whole document, so its code-block
1601    /// exemption is checked separately from the per-line directives.
1602    #[test]
1603    fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
1604        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
1605        assert!(validate_inline_config_rules(fenced).is_empty());
1606
1607        let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
1608        assert!(validate_inline_config_rules(multiline).is_empty());
1609
1610        let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
1611        assert_eq!(validate_inline_config_rules(outside).len(), 1);
1612    }
1613
1614    #[test]
1615    fn test_validate_inline_config_rules_markdownlint_configure_file() {
1616        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1617        let warnings = validate_inline_config_rules(content);
1618        assert_eq!(warnings.len(), 1);
1619        assert_eq!(warnings[0].rule_name, "fake_rule");
1620        assert_eq!(warnings[0].comment_type, "configure-file");
1621    }
1622
1623    #[test]
1624    fn test_get_rule_config_from_configure_file() {
1625        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1626
1627This is a test line."#;
1628
1629        let inline_config = InlineConfig::from_content(content);
1630        let config_override = inline_config.get_rule_config("MD013");
1631
1632        assert!(config_override.is_some(), "MD013 config should be found");
1633        let json = config_override.unwrap();
1634        assert!(json.is_object(), "Config should be an object");
1635        let obj = json.as_object().unwrap();
1636        assert!(obj.contains_key("line_length"), "Should have line_length key");
1637        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1638    }
1639
1640    #[test]
1641    fn test_get_rule_config_tables_false() {
1642        // Test that tables=false inline config is correctly parsed
1643        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1644
1645        let inline_config = InlineConfig::from_content(content);
1646        let config_override = inline_config.get_rule_config("MD013");
1647
1648        assert!(config_override.is_some(), "MD013 config should be found");
1649        let json = config_override.unwrap();
1650        let obj = json.as_object().unwrap();
1651        assert!(obj.contains_key("tables"), "Should have tables key");
1652        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1653    }
1654
1655    // ── multi-line configure-file ────────────────────────────────────────
1656    //
1657    // markdownlint scans configure-file over the whole document rather than
1658    // per line, so the comment may span lines. Every other directive stays
1659    // line-scoped in both tools.
1660
1661    // ── inline enable of a rule that will not run ────────────────────────
1662    //
1663    // rumdl treats rule selection as final: a rule configuration disabled is
1664    // never instantiated, and one per-file-ignores excludes is dropped before
1665    // the file is linted, so an inline enable of either does nothing. These
1666    // warnings make that silent no-op visible.
1667
1668    fn active_set(names: &[&str]) -> HashSet<String> {
1669        names.iter().map(|s| (*s).to_string()).collect()
1670    }
1671
1672    fn no_ignores() -> HashSet<String> {
1673        HashSet::new()
1674    }
1675
1676    #[test]
1677    fn test_inline_enable_of_inactive_rule_warns() {
1678        let active = active_set(&["MD013", "MD022"]);
1679        let content = "<!-- rumdl-enable MD012 -->\n";
1680
1681        let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1682
1683        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1684        assert_eq!(warnings[0].rule_name, "MD012");
1685        assert_eq!(warnings[0].comment_type, "enable");
1686        assert_eq!(
1687            warnings[0].problem,
1688            InlineConfigProblem::EnableHasNoEffect {
1689                reason: EnableNoEffectReason::NotEnabled
1690            }
1691        );
1692        assert_eq!(warnings[0].line_number, 1);
1693    }
1694
1695    #[test]
1696    fn test_inline_enable_of_active_rule_does_not_warn() {
1697        // The load-bearing false-positive guard: enabling a rule that IS active
1698        // must stay silent.
1699        let active = active_set(&["MD012", "MD013"]);
1700        let content = "<!-- rumdl-enable MD012 -->\n";
1701
1702        let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1703
1704        assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1705    }
1706
1707    #[test]
1708    fn test_inline_enable_of_per_file_ignored_rule_warns() {
1709        // The rule is enabled in configuration, so nothing about the config
1710        // explains the silence; per-file-ignores is what drops it here.
1711        let active = active_set(&["MD012", "MD013"]);
1712        let ignored = active_set(&["MD012"]);
1713        let content = "<!-- rumdl-enable MD012 -->\n";
1714
1715        let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1716
1717        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1718        assert_eq!(warnings[0].rule_name, "MD012");
1719        assert_eq!(
1720            warnings[0].problem,
1721            InlineConfigProblem::EnableHasNoEffect {
1722                reason: EnableNoEffectReason::IgnoredForFile
1723            }
1724        );
1725        assert!(
1726            warnings[0].format_message().contains("per-file-ignores"),
1727            "the message must name the setting in play: {}",
1728            warnings[0].format_message()
1729        );
1730    }
1731
1732    #[test]
1733    fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
1734        // A per-file-ignores entry for a rule configuration never enabled is
1735        // redundant; the setting standing in the way is the config one.
1736        let active = active_set(&["MD013"]);
1737        let ignored = active_set(&["MD012"]);
1738        let content = "<!-- rumdl-enable MD012 -->\n";
1739
1740        let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1741
1742        assert_eq!(warnings.len(), 1, "{warnings:?}");
1743        assert_eq!(
1744            warnings[0].problem,
1745            InlineConfigProblem::EnableHasNoEffect {
1746                reason: EnableNoEffectReason::NotEnabled
1747            }
1748        );
1749    }
1750
1751    #[test]
1752    fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
1753        // The negative control for the per-file-ignores arm: an ignore entry
1754        // naming a different rule must leave this enable alone.
1755        let active = active_set(&["MD012", "MD013"]);
1756        let ignored = active_set(&["MD013"]);
1757        let content = "<!-- rumdl-enable MD012 -->\n";
1758
1759        let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1760
1761        assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
1762    }
1763
1764    #[test]
1765    fn test_enable_inside_code_block_does_not_warn() {
1766        // A fenced example enables nothing, so neither reason applies to it.
1767        // The same document with the fence removed is the positive control.
1768        let active = active_set(&["MD012", "MD013"]);
1769        let ignored = active_set(&["MD012"]);
1770
1771        for content in [
1772            "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
1773            "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
1774            "# Doc\n\n    <!-- rumdl-enable MD012 -->\n",
1775        ] {
1776            let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1777            assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
1778        }
1779
1780        for content in [
1781            "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
1782            "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
1783        ] {
1784            let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1785            assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
1786        }
1787    }
1788
1789    #[test]
1790    fn test_bare_enable_all_does_not_warn() {
1791        // `enable` with no rule list means "all"; it targets no specific rule.
1792        let active = active_set(&["MD013"]);
1793        for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1794            let warnings = validate_inline_enables_against_active_rules(content, &active, &active_set(&["MD013"]));
1795            assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1796        }
1797    }
1798
1799    #[test]
1800    fn test_enable_file_and_alias_of_inactive_rule_warn() {
1801        let active = active_set(&["MD013"]);
1802        // enable-file, plus an alias for an inactive rule, both flagged.
1803        let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1804
1805        let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1806
1807        assert_eq!(warnings.len(), 1, "{warnings:?}");
1808        assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1809        assert_eq!(warnings[0].comment_type, "enable-file");
1810    }
1811
1812    #[test]
1813    fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1814        let active = active_set(&["MD013"]);
1815        let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1816        let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1817
1818        let warn_true = validate_inline_enables_against_active_rules(enable, &active, &no_ignores());
1819        let warn_false = validate_inline_enables_against_active_rules(disable, &active, &no_ignores());
1820
1821        assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1822        assert_eq!(warn_true[0].rule_name, "MD012");
1823        assert!(
1824            warn_false.is_empty(),
1825            "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1831        // An unrecognized name is reported by validate_inline_config_rules; it
1832        // must not also be flagged here (it is not a known-but-inactive rule).
1833        let active = active_set(&["MD013"]);
1834        let content = "<!-- rumdl-enable NotARule -->\n";
1835
1836        let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1837
1838        assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1839    }
1840
1841    #[test]
1842    fn test_disable_directive_never_warns_as_no_effect() {
1843        // Only enables are no-ops against a rule that will not run; a disable of
1844        // one is meaningless but harmless and must stay silent.
1845        let active = active_set(&["MD012", "MD013"]);
1846        let ignored = active_set(&["MD012"]);
1847        let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1848
1849        let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1850
1851        assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1852    }
1853
1854    // ── unknown option keys inside configure-file ────────────────────────
1855    //
1856    // A typo'd option key used to be dropped in silence, while the same typo
1857    // in a config file was reported with a suggestion.
1858
1859    #[test]
1860    fn test_unknown_option_key_in_configure_file_warns() {
1861        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
1862
1863        let warnings = validate_inline_config_rules(content);
1864
1865        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1866        assert_eq!(warnings[0].rule_name, "MD013");
1867        assert_eq!(
1868            warnings[0].problem,
1869            InlineConfigProblem::UnknownOption {
1870                key: "line_lenght".to_string()
1871            }
1872        );
1873        assert!(
1874            warnings[0].suggestion.is_some(),
1875            "a near-miss key should suggest the real one"
1876        );
1877        assert!(
1878            warnings[0].format_message().contains("Unknown option for rule MD013"),
1879            "message was: {}",
1880            warnings[0].format_message()
1881        );
1882    }
1883
1884    #[test]
1885    fn test_valid_option_keys_do_not_warn_in_either_case_style() {
1886        // The false-positive guard that matters: both spellings are legal, and
1887        // warning on them would be worse than the silence this replaces.
1888        for content in [
1889            r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
1890            r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
1891        ] {
1892            let warnings = validate_inline_config_rules(content);
1893            assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
1894        }
1895    }
1896
1897    #[test]
1898    fn test_unknown_rule_does_not_also_warn_about_its_options() {
1899        // The rule name is already reported; validating options of a rule that
1900        // does not exist would just be noise.
1901        let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
1902
1903        let warnings = validate_inline_config_rules(content);
1904
1905        assert_eq!(
1906            warnings.len(),
1907            1,
1908            "expected only the unknown-rule warning: {warnings:?}"
1909        );
1910        assert_eq!(warnings[0].rule_name, "nonexistent");
1911        assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
1912    }
1913
1914    #[test]
1915    fn test_boolean_rule_value_has_no_option_warnings() {
1916        let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
1917
1918        let warnings = validate_inline_config_rules(content);
1919
1920        assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
1921    }
1922
1923    #[test]
1924    fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
1925        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
1926
1927        let warnings = validate_inline_config_rules(content);
1928
1929        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1930        assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
1931    }
1932
1933    #[test]
1934    fn test_configure_file_spanning_multiple_lines_applies() {
1935        let content = "<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
1936
1937        let inline_config = InlineConfig::from_content(content);
1938        let config_override = inline_config.get_rule_config("MD013");
1939
1940        assert!(config_override.is_some(), "a multi-line configure-file must apply");
1941        let obj = config_override.unwrap().as_object().unwrap();
1942        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
1943    }
1944
1945    #[test]
1946    fn test_every_configure_file_comment_applies_not_just_the_first() {
1947        // Scanning the whole document must not collapse to the first match:
1948        // both comments configure a different rule and both must land.
1949        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";
1950
1951        let inline_config = InlineConfig::from_content(content);
1952
1953        assert!(
1954            inline_config.get_rule_config("MD013").is_some(),
1955            "first (single-line) configure-file dropped"
1956        );
1957        assert!(
1958            inline_config.get_rule_config("MD007").is_some(),
1959            "second (multi-line) configure-file dropped"
1960        );
1961    }
1962
1963    #[test]
1964    fn test_multiline_configure_file_in_code_block_is_ignored() {
1965        // rumdl ignores inline config inside fences; markdownlint does not.
1966        // Widening to a whole-document scan must not lose that.
1967        let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
1968
1969        let inline_config = InlineConfig::from_content(content);
1970
1971        assert!(
1972            inline_config.get_rule_config("MD013").is_none(),
1973            "configure-file inside a fenced code block must not apply"
1974        );
1975    }
1976
1977    #[test]
1978    fn test_multiline_configure_file_bool_and_alias_still_honored() {
1979        // The boolean and alias handling must survive the move off the
1980        // per-line path, in the multi-line form too.
1981        let content = "<!-- markdownlint-configure-file\n{\n  \"no-multiple-blanks\": false,\n  \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
1982
1983        let inline_config = InlineConfig::from_content(content);
1984
1985        assert!(
1986            inline_config.is_rule_disabled("MD012", 1),
1987            "boolean alias key must disable the rule"
1988        );
1989        let obj = inline_config
1990            .get_rule_config("MD013")
1991            .expect("alias-keyed config should resolve to MD013")
1992            .as_object()
1993            .unwrap();
1994        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
1995    }
1996
1997    #[test]
1998    fn test_multiline_configure_file_warning_reports_start_line() {
1999        // An unknown rule inside a multi-line comment is reported at the line
2000        // the comment opens on, not line 1 and not the closing line.
2001        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2002
2003        let warnings = validate_inline_config_rules(content);
2004
2005        assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2006        assert_eq!(warnings[0].rule_name, "nonexistent");
2007        assert_eq!(warnings[0].comment_type, "configure-file");
2008        assert_eq!(
2009            warnings[0].line_number, 3,
2010            "warning must point at the line the comment starts on"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_configure_file_bool_false_disables_rule() {
2016        // markdownlint documents a boolean as a way to turn a rule off for the
2017        // whole file, e.g. `{ "no-trailing-spaces": false }`.
2018        let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2019
2020        let inline_config = InlineConfig::from_content(content);
2021
2022        assert!(inline_config.is_rule_disabled("MD012", 1));
2023        assert!(
2024            inline_config.get_rule_config("MD012").is_none(),
2025            "a boolean should not be stored as rule options"
2026        );
2027    }
2028
2029    #[test]
2030    fn test_configure_file_bool_false_disables_rule_by_alias() {
2031        let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2032
2033        let inline_config = InlineConfig::from_content(content);
2034
2035        assert!(inline_config.is_rule_disabled("MD012", 1));
2036    }
2037
2038    #[test]
2039    fn test_configure_file_bool_true_leaves_rule_enabled() {
2040        let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2041
2042        let inline_config = InlineConfig::from_content(content);
2043
2044        assert!(!inline_config.is_rule_disabled("MD012", 1));
2045    }
2046
2047    #[test]
2048    fn test_get_rule_config_from_configure_file_alias_key() {
2049        // A config written with the rule's alias must be reachable by its id.
2050        let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2051
2052        let inline_config = InlineConfig::from_content(content);
2053        let config_override = inline_config.get_rule_config("MD013");
2054
2055        assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2056        let obj = config_override.unwrap().as_object().unwrap();
2057        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2058    }
2059
2060    // ── parse_disable_comment / parse_enable_comment edge cases ──────────
2061
2062    #[test]
2063    fn test_parse_disable_does_not_match_disable_line() {
2064        // parse_disable_comment must NOT match disable-line or disable-next-line
2065        assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2066        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2067        assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2068        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2069        assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2070        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2071    }
2072
2073    #[test]
2074    fn test_parse_enable_does_not_match_enable_file() {
2075        assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2076        assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2077    }
2078
2079    #[test]
2080    fn test_parse_disable_comment_edge_cases() {
2081        // No space before closing
2082        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2083
2084        // Tabs between rules
2085        assert_eq!(
2086            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2087            Some(vec!["MD001", "MD002"])
2088        );
2089
2090        // Comment not at start of line
2091        assert_eq!(
2092            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2093            Some(vec!["MD001"])
2094        );
2095
2096        // Malformed: no closing
2097        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2098
2099        // Malformed: no opening
2100        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2101
2102        // Case sensitive: uppercase should not match
2103        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2104
2105        // Empty rule list with whitespace
2106        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
2107
2108        // Duplicate rules preserved (caller may deduplicate)
2109        assert_eq!(
2110            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2111            Some(vec!["MD001", "MD001", "MD002"])
2112        );
2113
2114        // Unicode around the comment
2115        assert_eq!(
2116            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
2117            Some(vec!["MD001"])
2118        );
2119
2120        // 100 rules
2121        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
2122        let comment = format!("<!-- rumdl-disable {many_rules} -->");
2123        let parsed = parse_disable_comment(&comment);
2124        assert!(parsed.is_some());
2125        assert_eq!(parsed.unwrap().len(), 100);
2126
2127        // Special characters in rule names (forward compat)
2128        assert_eq!(
2129            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
2130            Some(vec!["MD001-test"])
2131        );
2132        assert_eq!(
2133            parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
2134            Some(vec!["custom_rule"])
2135        );
2136    }
2137
2138    #[test]
2139    fn test_parse_enable_comment_edge_cases() {
2140        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
2141        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
2142        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
2143        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
2144    }
2145
2146    // ── InlineConfig: code blocks must be transparent ────────────────────
2147
2148    #[test]
2149    fn test_disable_inside_fenced_code_block_ignored() {
2150        let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
2151        let config = InlineConfig::from_content(content);
2152        // The disable comment is inside a code block — must have no effect
2153        assert!(!config.is_rule_disabled("MD001", 6));
2154    }
2155
2156    #[test]
2157    fn test_disable_inside_tilde_fence_ignored() {
2158        let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
2159        let config = InlineConfig::from_content(content);
2160        assert!(!config.is_rule_disabled("MD001", 6));
2161    }
2162
2163    #[test]
2164    fn test_disable_before_code_block_persists_after() {
2165        // Disable before code block should persist through and after it
2166        let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
2167        let config = InlineConfig::from_content(content);
2168        assert!(config.is_rule_disabled("MD001", 5));
2169    }
2170
2171    #[test]
2172    fn test_enable_inside_code_block_ignored() {
2173        // Disable before, enable inside code block (should be ignored), still disabled after
2174        let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
2175        let config = InlineConfig::from_content(content);
2176        assert!(config.is_rule_disabled("MD001", 5));
2177    }
2178
2179    #[test]
2180    fn test_disable_inside_indented_code_block_ignored() {
2181        // An indented code block's range starts at the indented content rather
2182        // than at the start of the line, so containment of the whole line span
2183        // would miss it and let a code sample disable a rule.
2184        let content = "# Document\n\n    <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
2185        let config = InlineConfig::from_content(content);
2186        assert!(!config.is_rule_disabled("MD001", 5));
2187
2188        // Control: the same comment at column 1 is a directive and applies.
2189        let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
2190        let config = InlineConfig::from_content(unindented);
2191        assert!(config.is_rule_disabled("MD001", 5));
2192    }
2193
2194    #[test]
2195    fn test_configure_file_inside_indented_code_block_ignored() {
2196        // configure-file is scanned over the whole document, so it reaches the
2197        // same conclusion by its own path; both must agree.
2198        let content = "# Document\n\n    <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2199        let config = InlineConfig::from_content(content);
2200        assert!(config.get_rule_config("MD013").is_none());
2201
2202        let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2203        let config = InlineConfig::from_content(unindented);
2204        assert!(config.get_rule_config("MD013").is_some());
2205    }
2206
2207    // ── InlineConfig: mixed comment styles ───────────────────────────────
2208
2209    #[test]
2210    fn test_markdownlint_disable_rumdl_enable_interop() {
2211        let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
2212        let config = InlineConfig::from_content(content);
2213        assert!(config.is_rule_disabled("MD001", 2));
2214        assert!(!config.is_rule_disabled("MD001", 4));
2215    }
2216
2217    #[test]
2218    fn test_rumdl_disable_markdownlint_enable_interop() {
2219        let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
2220        let config = InlineConfig::from_content(content);
2221        assert!(config.is_rule_disabled("MD013", 2));
2222        assert!(!config.is_rule_disabled("MD013", 4));
2223    }
2224
2225    // ── InlineConfig: nested/overlapping disable/enable ──────────────────
2226
2227    #[test]
2228    fn test_global_disable_then_specific_enable() {
2229        let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
2230        let config = InlineConfig::from_content(content);
2231        assert!(!config.is_rule_disabled("MD001", 4));
2232        assert!(config.is_rule_disabled("MD002", 4));
2233        assert!(config.is_rule_disabled("MD013", 4));
2234    }
2235
2236    #[test]
2237    fn test_specific_disable_then_global_enable() {
2238        let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
2239        let config = InlineConfig::from_content(content);
2240        assert!(config.is_rule_disabled("MD001", 2));
2241        assert!(config.is_rule_disabled("MD002", 2));
2242        assert!(!config.is_rule_disabled("MD001", 4));
2243        assert!(!config.is_rule_disabled("MD002", 4));
2244    }
2245
2246    #[test]
2247    fn test_multiple_rules_disable_enable_independently() {
2248        let content = "\
2249Line 1\n\
2250<!-- rumdl-disable MD001 MD002 -->\n\
2251Line 3\n\
2252<!-- rumdl-enable MD001 -->\n\
2253Line 5\n\
2254<!-- rumdl-disable -->\n\
2255Line 7\n\
2256<!-- rumdl-enable MD002 -->\n\
2257Line 9\n";
2258        let config = InlineConfig::from_content(content);
2259
2260        // Line 1: nothing disabled
2261        assert!(!config.is_rule_disabled("MD001", 1));
2262        assert!(!config.is_rule_disabled("MD002", 1));
2263
2264        // Line 3: both disabled
2265        assert!(config.is_rule_disabled("MD001", 3));
2266        assert!(config.is_rule_disabled("MD002", 3));
2267
2268        // Line 5: MD001 enabled, MD002 still disabled
2269        assert!(!config.is_rule_disabled("MD001", 5));
2270        assert!(config.is_rule_disabled("MD002", 5));
2271
2272        // Line 7: all disabled
2273        assert!(config.is_rule_disabled("MD001", 7));
2274        assert!(config.is_rule_disabled("MD002", 7));
2275
2276        // Line 9: MD002 enabled, MD001 still disabled
2277        assert!(config.is_rule_disabled("MD001", 9));
2278        assert!(!config.is_rule_disabled("MD002", 9));
2279    }
2280
2281    // ── InlineConfig: empty/minimal content ──────────────────────────────
2282
2283    #[test]
2284    fn test_empty_content() {
2285        let config = InlineConfig::from_content("");
2286        assert!(!config.is_rule_disabled("MD001", 1));
2287    }
2288
2289    #[test]
2290    fn test_single_disable_comment_only() {
2291        // Persistent disable takes effect from the NEXT line, not the current line.
2292        // For a single-line document, the disable on line 1 takes effect at line 2+.
2293        let config = InlineConfig::from_content("<!-- rumdl-disable -->");
2294        assert!(!config.is_rule_disabled("MD001", 1));
2295        assert!(config.is_rule_disabled("MD001", 2));
2296        assert!(config.is_rule_disabled("MD999", 2));
2297
2298        // With content after the disable, rules are disabled from line 2 onward
2299        let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
2300        assert!(!config.is_rule_disabled("MD001", 1));
2301        assert!(config.is_rule_disabled("MD001", 2));
2302        assert!(config.is_rule_disabled("MD001", 3));
2303    }
2304
2305    #[test]
2306    fn test_no_inline_markers() {
2307        let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
2308        assert!(!config.is_rule_disabled("MD001", 1));
2309        assert!(!config.is_rule_disabled("MD001", 5));
2310    }
2311
2312    // ── InlineConfig: export_for_file_index correctness ──────────────────
2313
2314    #[test]
2315    fn test_export_for_file_index_persistent_transitions() {
2316        let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
2317        let config = InlineConfig::from_content(content);
2318        let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
2319
2320        assert!(file_disabled.is_empty());
2321        // Should have transitions for the disable and enable
2322        assert!(
2323            persistent.len() >= 2,
2324            "Expected at least 2 transitions, got {}",
2325            persistent.len()
2326        );
2327    }
2328
2329    #[test]
2330    fn test_export_for_file_index_disable_file() {
2331        let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
2332        let config = InlineConfig::from_content(content);
2333        let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
2334
2335        assert!(file_disabled.contains("MD001"));
2336    }
2337
2338    #[test]
2339    fn test_export_for_file_index_disable_line() {
2340        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
2341        let config = InlineConfig::from_content(content);
2342        let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
2343
2344        assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
2345        assert!(line_disabled[&2].contains("MD001"));
2346        assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
2347    }
2348}