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