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