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
38/// Whether this source can contain a recognized inline configuration comment.
39/// This is a candidate check only; parsing still decides whether it is valid,
40/// inside code, or applicable to the document.
41pub fn has_inline_config_markers(content: &str) -> bool {
42    let mut quotes = BlockquoteTracker::new();
43    memchr::memmem::find_iter(content.as_bytes(), COMMENT_OPEN.as_bytes()).any(|offset| {
44        let after_open = comment_body(
45            &content[offset + COMMENT_OPEN.len()..],
46            quotes.quote_depth_at(content, offset),
47        );
48        after_open.starts_with("rumdl-")
49            || after_open.starts_with("markdownlint-")
50            || prettier_ignore_len(after_open).is_some()
51    })
52}
53
54/// Candidate option overrides use the native directive body and quote handling.
55/// Invalid comments may be candidates; a valid override must never be missed.
56pub(crate) fn inline_rule_option_candidate_offsets(content: &str) -> impl Iterator<Item = usize> + '_ {
57    let mut quotes = BlockquoteTracker::new();
58    memchr::memmem::find_iter(content.as_bytes(), COMMENT_OPEN.as_bytes()).filter(move |&offset| {
59        let body = comment_body(
60            &content[offset + COMMENT_OPEN.len()..],
61            quotes.quote_depth_at(content, offset),
62        );
63        TOOL_PREFIXES.iter().any(|prefix| {
64            body.strip_prefix(prefix)
65                .is_some_and(|tail| tail.starts_with(CONFIGURE_FILE_KEYWORD))
66        })
67    })
68}
69
70/// The opener of an HTML comment.
71const COMMENT_OPEN: &str = "<!--";
72
73/// Whether `ch` is whitespace inside a comment.
74///
75/// The body of an HTML comment is free text, and the tools whose directives rumdl
76/// reads (markdownlint, remark-lint) split and trim it with JavaScript `\s`, which
77/// is Unicode White_Space plus U+FEFF. `char::is_whitespace` is White_Space alone,
78/// so U+FEFF is added: the union accepts everything either side accepts. U+200B
79/// is whitespace in neither and separates nothing. Every helper that reads a
80/// comment uses this one class.
81pub(crate) fn is_comment_space(ch: char) -> bool {
82    ch.is_whitespace() || ch == '\u{feff}'
83}
84
85/// The text of a comment after its `<!--` opener and any whitespace that follows
86/// it, so `<!--rumdl-disable -->`, `<!-- rumdl-disable -->` and a tab- or
87/// multi-space-separated opener all lead to the same directive text.
88///
89/// `after_open` is the text immediately after `<!--`. When the comment opens
90/// inside `depth` levels of blockquote and goes on to a later line, that many
91/// blockquote markers at the start of the line are not part of it either (`> <!--`
92/// then `> rumdl-disable`). Any further `>` is comment text.
93fn comment_body(after_open: &str, depth: usize) -> &str {
94    let mut line_lead = false;
95    let mut markers_left = depth;
96    for (idx, ch) in after_open.char_indices() {
97        match ch {
98            '\n' => {
99                line_lead = true;
100                markers_left = depth;
101            }
102            '>' if line_lead && markers_left > 0 => markers_left -= 1,
103            _ if is_comment_space(ch) => {}
104            _ => return &after_open[idx..],
105        }
106    }
107    ""
108}
109
110/// A line of a comment that continues from an earlier one, without the
111/// whitespace that opens it and, in a blockquote, its markers.
112fn comment_continuation(line: &str, depth: usize) -> &str {
113    let mut markers_left = depth;
114    line.trim_start_matches(|ch: char| {
115        if ch == '>' && markers_left > 0 {
116            markers_left -= 1;
117            return true;
118        }
119        is_comment_space(ch)
120    })
121}
122
123/// Whether a line begins with container syntax that includes a blockquote marker:
124/// a run of `>` markers, `-`/`+`/`*` bullets and `1.`/`1)` numbers, each bullet or
125/// number followed by whitespace, with at least one `>`. Anything else ends the
126/// prefix, so `1 > 0 <!--` is prose, not a quote.
127fn line_quote_depth(line: &str) -> usize {
128    let mut rest = line;
129    let mut depth = 0;
130    loop {
131        rest = rest.trim_start_matches([' ', '\t']);
132        if let Some(after) = rest.strip_prefix('>') {
133            depth += 1;
134            rest = after;
135            continue;
136        }
137        let after_marker = if let Some(after) = rest.strip_prefix(['-', '+', '*']) {
138            Some(after)
139        } else {
140            let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
141            if (1..=9).contains(&digits) {
142                rest[digits..].strip_prefix(['.', ')'])
143            } else {
144                None
145            }
146        };
147        match after_marker {
148            Some(after) if after.starts_with([' ', '\t']) => rest = after,
149            _ => return depth,
150        }
151    }
152}
153
154/// Answers whether the comment opening at a byte offset sits in a blockquote: the
155/// container prefix of its line (the leading run of blockquote `>` markers, list
156/// bullets and numbers) holds at least one `>`. Prose between the prefix and the
157/// opener does not matter.
158///
159/// Openers are asked about in increasing order. Each question looks only at the
160/// text since the previous one, and the answer is worked out once per line, so a
161/// long line of comments costs no more than the line.
162struct BlockquoteTracker {
163    scanned_to: usize,
164    /// Whether the line at `scanned_to` has been classified yet
165    classified: bool,
166    depth: usize,
167}
168
169impl BlockquoteTracker {
170    fn new() -> Self {
171        Self {
172            scanned_to: 0,
173            classified: false,
174            depth: 0,
175        }
176    }
177
178    fn quote_depth_at(&mut self, text: &str, open: usize) -> usize {
179        let line_start = match text[self.scanned_to..open].rfind('\n') {
180            Some(newline) => Some(self.scanned_to + newline + 1),
181            None if !self.classified => Some(0),
182            None => None,
183        };
184        if let Some(line_start) = line_start {
185            self.depth = line_quote_depth(&text[line_start..]);
186            self.classified = true;
187        }
188        self.scanned_to = open;
189        self.depth
190    }
191}
192
193/// Byte length of a `prettier-ignore` comment body (`body` is the text after
194/// `<!--` and its whitespace) through its closing `-->`, or `None` when the body
195/// is not exactly `prettier-ignore` followed by optional whitespace and `-->`.
196/// `prettier-ignore-start` and `-end` are other directives and do not match.
197fn prettier_ignore_len(body: &str) -> Option<usize> {
198    const KEYWORD: &str = "prettier-ignore";
199    let after_keyword = body.strip_prefix(KEYWORD)?;
200    let after_space = after_keyword.trim_start_matches(is_comment_space);
201    let after_close = after_space.strip_prefix("-->")?;
202    Some(body.len() - after_close.len())
203}
204
205/// Whether a comment body (the text after `<!--` and its whitespace) is a linter
206/// configuration comment. Vale and remark-lint have no common prefix, so each is
207/// matched by its own syntax with a word boundary after every keyword:
208/// `vale offset ...` and `lint disabled ...` are prose.
209fn is_tool_comment_body(body: &str, depth: usize) -> bool {
210    TOOL_PREFIXES.iter().any(|tool| body.starts_with(tool)) || is_vale_body(body, depth) || is_lint_body(body, depth)
211}
212
213/// Whether `rest` ends a word: nothing, whitespace, or the comment's closer.
214fn at_word_end(rest: &str) -> bool {
215    rest.is_empty() || rest.starts_with(is_comment_space) || rest.starts_with("-->")
216}
217
218/// `vale off`, `vale on`, or a setting such as `vale Style.Rule = NO`.
219fn is_vale_body(body: &str, depth: usize) -> bool {
220    let Some(rest) = body.strip_prefix("vale") else {
221        return false;
222    };
223    if !rest.starts_with(is_comment_space) {
224        return false;
225    }
226    let rest = comment_body(rest, depth);
227    if ["off", "on"]
228        .iter()
229        .any(|keyword| rest.strip_prefix(keyword).is_some_and(at_word_end))
230    {
231        return true;
232    }
233    let name_end = rest
234        .find(|ch: char| is_comment_space(ch) || ch == '=')
235        .unwrap_or(rest.len());
236    name_end > 0 && comment_body(&rest[name_end..], depth).starts_with('=')
237}
238
239/// `lint disable`, `lint enable` or `lint ignore`.
240fn is_lint_body(body: &str, depth: usize) -> bool {
241    let Some(rest) = body.strip_prefix("lint") else {
242        return false;
243    };
244    if !rest.starts_with(is_comment_space) {
245        return false;
246    }
247    let rest = comment_body(rest, depth);
248    ["disable", "enable", "ignore"]
249        .iter()
250        .any(|keyword| rest.strip_prefix(keyword).is_some_and(at_word_end))
251}
252
253/// The byte ranges of the linter configuration comments `in_code` does not call
254/// code, each from its `<!--` through its `-->` (or to the end of its line when
255/// nothing closes it), in document order.
256///
257/// A rule that must leave directive text alone skips what falls inside these
258/// ranges. Prose outside the comment, before its opener or after its `-->`, stays
259/// the rule's, and a comment in code is an example rather than a directive.
260pub(crate) fn config_comment_ranges(content: &str, in_code: impl Fn(usize) -> bool) -> Vec<std::ops::Range<usize>> {
261    let mut ranges = Vec::new();
262    let mut pos = 0;
263    let mut quotes = BlockquoteTracker::new();
264    // The first `-->` at or after the last search start, and whether any remains.
265    // A closer is searched for again only once the scan has passed it, and never
266    // after a search found none, so openers that share a closer (or have none)
267    // cost one search between them.
268    let mut next_close: Option<usize> = None;
269    let mut closers_left = true;
270    while let Some(open_offset) = content[pos..].find(COMMENT_OPEN) {
271        let comment_start = pos + open_offset;
272        let depth = quotes.quote_depth_at(content, comment_start);
273        let body = comment_body(&content[comment_start + COMMENT_OPEN.len()..], depth);
274        pos = comment_start + COMMENT_OPEN.len();
275        if !is_tool_comment_body(body, depth) || in_code(comment_start) {
276            continue;
277        }
278        let body_start = content.len() - body.len();
279        if closers_left && next_close.is_none_or(|close| close < body_start) {
280            next_close = body.find("-->").map(|close| body_start + close);
281            closers_left = next_close.is_some();
282        }
283        let comment_end = match next_close {
284            Some(close) => close + 3,
285            None => content[comment_start..]
286                .find('\n')
287                .map_or(content.len(), |newline| comment_start + newline),
288        };
289        pos = comment_end;
290        ranges.push(comment_start..comment_end);
291    }
292    ranges
293}
294
295/// Whether `offset` falls inside one of `ranges`, as `config_comment_ranges`
296/// returns them (sorted, disjoint). Found by binary search, so a rule asking about
297/// every match of a large document does not scan the whole inventory each time.
298pub(crate) fn offset_in_ranges(ranges: &[std::ops::Range<usize>], offset: usize) -> bool {
299    let after = ranges.partition_point(|range| range.end <= offset);
300    ranges.get(after).is_some_and(|range| range.start <= offset)
301}
302
303/// Type alias for the export_for_file_index return type:
304/// (file_disabled_rules, persistent_transitions, line_disabled_rules)
305pub type FileIndexExport = (
306    HashSet<String>,
307    Vec<(usize, HashSet<String>, HashSet<String>)>,
308    HashMap<usize, HashSet<String>>,
309);
310
311/// A state transition recording which rules are disabled/enabled starting at a given line.
312/// Transitions are stored in ascending line order. The state at any line is determined by
313/// the most recent transition at or before that line.
314#[derive(Debug, Clone)]
315struct StateTransition {
316    /// The 1-indexed line number where this state takes effect
317    line: usize,
318    /// The set of disabled rules at this point ("*" means all rules disabled)
319    disabled: HashSet<String>,
320    /// The set of explicitly enabled rules (only meaningful when disabled contains "*")
321    enabled: HashSet<String>,
322}
323
324/// The kind of directive a rule's disabled state at a line comes from.
325///
326/// The three layers are resolved independently of each other, so removing a
327/// comment from one of them leaves the other two answering exactly as before.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum DisableLayer {
330    /// `disable-file`, or a `configure-file` entry set to `false`
331    File,
332    /// `disable`, up to the `enable` that closes it
333    Block,
334    /// `disable-line`, `disable-next-line` or `prettier-ignore`
335    Line,
336}
337
338#[derive(Debug, Clone)]
339pub struct InlineConfig {
340    /// State transitions for persistent disable/enable directives, sorted by line number.
341    /// Only stores entries where the state actually changes, not for every line.
342    transitions: Vec<StateTransition>,
343    /// Rules disabled for specific lines via disable-line (1-indexed)
344    line_disabled_rules: HashMap<usize, HashSet<String>>,
345    /// Rules disabled for the entire file
346    file_disabled_rules: HashSet<String>,
347    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
348    file_enabled_rules: HashSet<String>,
349    /// Configuration overrides for specific rules from configure-file comments
350    /// Maps rule name to configuration JSON value
351    file_rule_config: HashMap<String, JsonValue>,
352}
353
354impl Default for InlineConfig {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360impl InlineConfig {
361    pub fn new() -> Self {
362        Self {
363            transitions: Vec::new(),
364            line_disabled_rules: HashMap::new(),
365            file_disabled_rules: HashSet::new(),
366            file_enabled_rules: HashSet::new(),
367            file_rule_config: HashMap::new(),
368        }
369    }
370
371    /// Find the state transition that applies to the given line number.
372    /// Uses binary search to find the last transition at or before the given line.
373    fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
374        if self.transitions.is_empty() {
375            return None;
376        }
377        // Binary search for the rightmost transition with line <= line_number
378        match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
379            Ok(idx) => Some(&self.transitions[idx]),
380            Err(idx) => {
381                if idx > 0 {
382                    Some(&self.transitions[idx - 1])
383                } else {
384                    None
385                }
386            }
387        }
388    }
389
390    /// Process all inline comments in the content and return the configuration state
391    pub fn from_content(content: &str) -> Self {
392        if !has_inline_config_markers(content) {
393            return Self::new();
394        }
395
396        // One parse answers for both: a directive inside a code block, and one
397        // inside an inline code span, each configure nothing.
398        let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
399        Self::from_content_with_code_blocks_internal(content, &parsed.code_blocks, &parsed.code_spans)
400    }
401
402    /// Process all inline comments in the content with precomputed code ranges.
403    pub fn from_content_with_code_blocks(
404        content: &str,
405        code_blocks: &[(usize, usize)],
406        code_spans: &[(usize, usize)],
407    ) -> Self {
408        if !has_inline_config_markers(content) {
409            return Self::new();
410        }
411
412        Self::from_content_with_code_blocks_internal(content, code_blocks, code_spans)
413    }
414
415    fn from_content_with_code_blocks_internal(
416        content: &str,
417        code_blocks: &[(usize, usize)],
418        code_spans: &[(usize, usize)],
419    ) -> Self {
420        let mut config = Self::new();
421        let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
422        // Lines measured over `split('\n')`, whose pieces keep any `\r`, so the
423        // offsets below match the code ranges for a CRLF document too.
424
425        // configure-file is scanned over the whole document rather than per
426        // line, because it is the one directive allowed to span lines, and it
427        // applies before any enable/disable directive regardless of where it
428        // sits. Comments inside fenced code blocks are skipped.
429        for (offset, json_config) in scan_configure_file_comments(content) {
430            if in_code.code_blocks.contains(offset) || in_code.code_spans.contains(offset) {
431                continue;
432            }
433            let Some(obj) = json_config.as_object() else {
434                continue;
435            };
436            for (rule_name, rule_config) in obj {
437                // A boolean turns a rule off or back on, e.g.
438                // `{ "no-trailing-spaces": false }`, so route those to the
439                // disable set instead of storing them as rule options.
440                let normalized = normalize_rule_name(rule_name);
441                if let Some(enabled) = rule_config.as_bool() {
442                    if enabled {
443                        config.file_disabled_rules.remove(&normalized);
444                    } else {
445                        config.file_disabled_rules.insert(normalized);
446                    }
447                    continue;
448                }
449                // Store under the canonical rule id so lookups by `MDxxx` also
450                // find configs written with an alias, e.g.
451                // `{ "line-length": { "line_length": 70 } }`.
452                config.file_rule_config.insert(normalized, rule_config.clone());
453            }
454        }
455
456        // Track current state of disabled rules
457        let mut currently_disabled: HashSet<String> = HashSet::new();
458        let mut currently_enabled: HashSet<String> = HashSet::new();
459        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
460
461        // Track the previously recorded transition state to detect changes
462        let mut prev_disabled: HashSet<String> = HashSet::new();
463        let mut prev_enabled: HashSet<String> = HashSet::new();
464
465        // Record initial state (line 1: nothing disabled)
466        config.transitions.push(StateTransition {
467            line: 1,
468            disabled: HashSet::new(),
469            enabled: HashSet::new(),
470        });
471
472        // Directive comments that span lines take effect on the line holding their
473        // `-->`, ahead of any comment later on that line.
474        let mut multiline_by_end_line: HashMap<usize, Vec<MultilineDirective<'_>>> = HashMap::new();
475        for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
476            multiline_by_end_line
477                .entry(directive.end_line)
478                .or_default()
479                .push(directive);
480        }
481
482        let mut position = 0;
483        let mut line_count = 0;
484        for (idx, line) in content.split('\n').enumerate() {
485            let line_num = idx + 1; // 1-indexed
486            line_count = line_num;
487            let line_position = position;
488            position += line.len() + 1;
489
490            // Record a transition only if state changed since last recorded transition.
491            // State for this line is the state BEFORE processing comments on this line.
492            if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
493                config.transitions.push(StateTransition {
494                    line: line_num,
495                    disabled: currently_disabled.clone(),
496                    enabled: currently_enabled.clone(),
497                });
498                prev_disabled.clone_from(&currently_disabled);
499                prev_enabled.clone_from(&currently_enabled);
500            }
501
502            // A line inside a code block holds no single-line directive
503            let in_code_block = line_in_code_block(line_position, line, &in_code.code_blocks);
504
505            // Parse all directives on this line once via the unified parser.
506            // Directives come back in left-to-right order with correct disambiguation.
507            let ending_here = multiline_by_end_line.remove(&line_num).unwrap_or_default();
508            let single_line = if in_code_block {
509                Vec::new()
510            } else {
511                directives_outside_code_spans(line, line_position, &in_code.code_spans)
512            };
513            let directives: Vec<InlineDirective<'_>> = ending_here
514                .into_iter()
515                .map(|multiline| InlineDirective {
516                    kind: multiline.kind,
517                    rules: multiline.rules,
518                    span: 0..0,
519                })
520                .chain(single_line)
521                .collect();
522
523            // Also check for prettier-ignore (not part of the rumdl/markdownlint format)
524            let has_prettier_ignore = !in_code_block
525                && line.match_indices(COMMENT_OPEN).any(|(offset, _)| {
526                    let body = comment_body(&line[offset + COMMENT_OPEN.len()..], 0);
527                    prettier_ignore_len(body).is_some() && !in_code.code_spans.contains(line_position + offset)
528                });
529
530            // Pass 1: file-wide directives (affect the entire file, not state-tracked)
531            for directive in &directives {
532                match directive.kind {
533                    DirectiveKind::DisableFile => {
534                        if directive.rules.is_empty() {
535                            config.file_disabled_rules.clear();
536                            config.file_disabled_rules.insert("*".to_string());
537                        } else if config.file_disabled_rules.contains("*") {
538                            for rule in &directive.rules {
539                                config.file_enabled_rules.remove(&normalize_rule_name(rule));
540                            }
541                        } else {
542                            for rule in &directive.rules {
543                                config.file_disabled_rules.insert(normalize_rule_name(rule));
544                            }
545                        }
546                    }
547                    DirectiveKind::EnableFile => {
548                        if directive.rules.is_empty() {
549                            config.file_disabled_rules.clear();
550                            config.file_enabled_rules.clear();
551                        } else if config.file_disabled_rules.contains("*") {
552                            for rule in &directive.rules {
553                                config.file_enabled_rules.insert(normalize_rule_name(rule));
554                            }
555                        } else {
556                            for rule in &directive.rules {
557                                config.file_disabled_rules.remove(&normalize_rule_name(rule));
558                            }
559                        }
560                    }
561                    // configure-file is handled document-wide before this loop.
562                    _ => {}
563                }
564            }
565
566            // Pass 2: line-specific and state-changing directives (in document order)
567            for directive in &directives {
568                match directive.kind {
569                    DirectiveKind::DisableNextLine => {
570                        let next_line = line_num + 1;
571                        let line_rules = config.line_disabled_rules.entry(next_line).or_default();
572                        if directive.rules.is_empty() {
573                            line_rules.insert("*".to_string());
574                        } else {
575                            for rule in &directive.rules {
576                                line_rules.insert(normalize_rule_name(rule));
577                            }
578                        }
579                    }
580                    DirectiveKind::DisableLine => {
581                        let line_rules = config.line_disabled_rules.entry(line_num).or_default();
582                        if directive.rules.is_empty() {
583                            line_rules.insert("*".to_string());
584                        } else {
585                            for rule in &directive.rules {
586                                line_rules.insert(normalize_rule_name(rule));
587                            }
588                        }
589                    }
590                    DirectiveKind::Disable => {
591                        if directive.rules.is_empty() {
592                            currently_disabled.clear();
593                            currently_disabled.insert("*".to_string());
594                            currently_enabled.clear();
595                        } else if currently_disabled.contains("*") {
596                            for rule in &directive.rules {
597                                currently_enabled.remove(&normalize_rule_name(rule));
598                            }
599                        } else {
600                            for rule in &directive.rules {
601                                currently_disabled.insert(normalize_rule_name(rule));
602                            }
603                        }
604                    }
605                    DirectiveKind::Enable => {
606                        if directive.rules.is_empty() {
607                            currently_disabled.clear();
608                            currently_enabled.clear();
609                        } else if currently_disabled.contains("*") {
610                            for rule in &directive.rules {
611                                currently_enabled.insert(normalize_rule_name(rule));
612                            }
613                        } else {
614                            for rule in &directive.rules {
615                                currently_disabled.remove(&normalize_rule_name(rule));
616                            }
617                        }
618                    }
619                    DirectiveKind::Capture => {
620                        capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
621                    }
622                    DirectiveKind::Restore => {
623                        if let Some((disabled, enabled)) = capture_stack.pop() {
624                            currently_disabled = disabled;
625                            currently_enabled = enabled;
626                        }
627                    }
628                    // File-wide directives already handled in pass 1
629                    DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
630                }
631            }
632
633            // prettier-ignore: disables all rules for next line
634            if has_prettier_ignore {
635                let next_line = line_num + 1;
636                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
637                line_rules.insert("*".to_string());
638            }
639        }
640
641        // Record final transition if state changed after the last line was processed
642        if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
643            config.transitions.push(StateTransition {
644                line: line_count + 1,
645                disabled: currently_disabled,
646                enabled: currently_enabled,
647            });
648        }
649
650        config
651    }
652
653    /// Check if a rule is disabled at a specific line
654    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
655        self.disabling_layer(rule_name, line_number).is_some()
656    }
657
658    /// Which kind of directive keeps `rule_name` off at `line_number`, if any.
659    ///
660    /// A file-wide disable answers first: it decides on its own, so a rule enabled
661    /// for the file stays enabled however the line below is written. The remaining
662    /// two layers both merely disable, so the wider one answers, naming the
663    /// directive a caller would have to remove to see the rule report again.
664    pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
665        if self.file_disabled_rules.contains("*") {
666            // All rules are disabled for the file, check if this rule is explicitly enabled
667            return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
668        } else if self.file_disabled_rules.contains(rule_name) {
669            return Some(DisableLayer::File);
670        }
671
672        // Persistent disables via state transitions (binary search)
673        if let Some(transition) = self.find_transition(line_number) {
674            let disabled = if transition.disabled.contains("*") {
675                !transition.enabled.contains(rule_name)
676            } else {
677                transition.disabled.contains(rule_name)
678            };
679            if disabled {
680                return Some(DisableLayer::Block);
681            }
682        }
683
684        // Line-specific disables (disable-line, disable-next-line, prettier-ignore)
685        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
686            && (line_rules.contains("*") || line_rules.contains(rule_name))
687        {
688            return Some(DisableLayer::Line);
689        }
690
691        None
692    }
693
694    /// Get all disabled rules at a specific line
695    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
696        let mut disabled = HashSet::new();
697
698        // Add persistent disables via state transitions (binary search)
699        if let Some(transition) = self.find_transition(line_number) {
700            if transition.disabled.contains("*") {
701                disabled.insert("*".to_string());
702            } else {
703                for rule in &transition.disabled {
704                    disabled.insert(rule.clone());
705                }
706            }
707        }
708
709        // Add line-specific disables
710        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
711            for rule in line_rules {
712                disabled.insert(rule.clone());
713            }
714        }
715
716        disabled
717    }
718
719    /// Get configuration overrides for a specific rule from configure-file comments
720    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
721        self.file_rule_config.get(rule_name)
722    }
723
724    /// Get all configuration overrides from configure-file comments
725    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
726        &self.file_rule_config
727    }
728
729    /// Export the disabled rules data for storage in FileIndex.
730    ///
731    /// Returns (file_disabled_rules, persistent_transitions, line_disabled_rules).
732    pub fn export_for_file_index(&self) -> FileIndexExport {
733        let file_disabled = self.file_disabled_rules.clone();
734
735        let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
736            .transitions
737            .iter()
738            .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
739            .collect();
740
741        let line_disabled = self.line_disabled_rules.clone();
742
743        (file_disabled, persistent_transitions, line_disabled)
744    }
745}
746
747// ── Unified inline directive parser ──────────────────────────────────────────
748//
749// All inline config comments follow one pattern:
750//   <!-- (rumdl|markdownlint)-KEYWORD [RULES...] -->
751//
752// Disambiguation (e.g., "disable" vs "disable-line" vs "disable-next-line")
753// is handled ONCE here by matching the longest keyword first.
754
755/// The type of an inline configuration directive.
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum DirectiveKind {
758    Disable,
759    DisableLine,
760    DisableNextLine,
761    DisableFile,
762    Enable,
763    EnableFile,
764    Capture,
765    Restore,
766    ConfigureFile,
767}
768
769/// A parsed inline configuration directive.
770#[derive(Debug, Clone, PartialEq)]
771pub struct InlineDirective<'a> {
772    pub kind: DirectiveKind,
773    pub rules: Vec<&'a str>,
774    /// Byte range of the whole comment, `<!--` through `-->`, within its line
775    pub span: std::ops::Range<usize>,
776}
777
778/// Tool prefixes recognized in inline config comments.
779const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
780
781/// Directive keywords ordered so that more-specific prefixes come first.
782/// "disable-next-line" before "disable-line" before "disable-file" before "disable";
783/// "enable-file" before "enable". This ensures longest-match-first disambiguation.
784const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
785    (DirectiveKind::DisableNextLine, "disable-next-line"),
786    (DirectiveKind::DisableLine, "disable-line"),
787    (DirectiveKind::DisableFile, "disable-file"),
788    (DirectiveKind::Disable, "disable"),
789    (DirectiveKind::EnableFile, "enable-file"),
790    (DirectiveKind::Enable, "enable"),
791    (DirectiveKind::ConfigureFile, "configure-file"),
792    (DirectiveKind::Capture, "capture"),
793    (DirectiveKind::Restore, "restore"),
794];
795
796/// Try to parse a single directive from the text after `<!--` and its whitespace.
797/// Returns the kind, its rule list, and the number of bytes consumed (from `s`
798/// onward) so the caller can advance past `-->` and place the comment's span.
799fn try_parse_directive(s: &str, depth: usize) -> Option<(DirectiveKind, Vec<&str>, usize)> {
800    for tool in TOOL_PREFIXES {
801        if !s.starts_with(tool) {
802            continue;
803        }
804        let after_tool = &s[tool.len()..];
805
806        for &(kind, keyword) in DIRECTIVE_KEYWORDS {
807            if !after_tool.starts_with(keyword) {
808                continue;
809            }
810            let after_kw = &after_tool[keyword.len()..];
811
812            // Word boundary: the keyword must be followed by whitespace, `-->`, or end-of-string.
813            // This prevents "disablefoo" from matching "disable".
814            if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
815                continue;
816            }
817
818            // Find closing -->
819            let close_offset = after_kw.find("-->")?;
820
821            let mut lines = after_kw[..close_offset].split('\n');
822            let first_line = lines.next().unwrap_or("");
823            let rules: Vec<&str> = std::iter::once(first_line)
824                .chain(lines.map(|line| comment_continuation(line, depth)))
825                .flat_map(|line| line.split(is_comment_space))
826                .filter(|rule| !rule.is_empty())
827                .collect();
828
829            let consumed = tool.len() + keyword.len() + close_offset + 3; // 3 for "-->"
830            return Some((kind, rules, consumed));
831        }
832
833        // Tool prefix matched but no keyword — not a directive we recognize.
834        return None;
835    }
836    None
837}
838
839/// Parse all inline configuration directives from a line, in left-to-right order.
840///
841/// Each directive is a typed `InlineDirective` with its kind and rule list.
842/// Disambiguation between overlapping prefixes (e.g., `disable` vs `disable-line`)
843/// is handled by matching the longest keyword first — no ad-hoc guards needed.
844pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
845    let mut results = Vec::new();
846    let mut pos = 0;
847
848    while pos < line.len() {
849        let remaining = &line[pos..];
850        let Some(open_offset) = remaining.find(COMMENT_OPEN) else {
851            break;
852        };
853        let comment_start = pos + open_offset;
854        let after_open = &line[comment_start + COMMENT_OPEN.len()..];
855        let body = comment_body(after_open, 0);
856        let body_start = line.len() - body.len();
857
858        if let Some((kind, rules, consumed)) = try_parse_directive(body, 0) {
859            let comment_end = body_start + consumed;
860            results.push(InlineDirective {
861                kind,
862                rules,
863                span: comment_start..comment_end,
864            });
865            pos = comment_end;
866        } else {
867            pos = comment_start + COMMENT_OPEN.len();
868        }
869    }
870
871    results
872}
873
874/// A directive comment whose text spans lines: `<!--` and the keyword, or the
875/// rule list and the `-->`, sit on different lines.
876///
877/// An HTML comment is one block, so its text, line breaks included, is the
878/// directive. A comment that fits on one line is left to `parse_inline_directives`.
879#[derive(Debug, Clone, PartialEq)]
880struct MultilineDirective<'a> {
881    kind: DirectiveKind,
882    rules: Vec<&'a str>,
883    /// Byte offset of the opening `<!--`
884    open: usize,
885    /// Byte offset just past the closing `-->`
886    end: usize,
887    /// 1-indexed line the comment opens on
888    start_line: usize,
889    /// 1-indexed line holding the `-->`: the line the directive takes effect on
890    end_line: usize,
891}
892
893/// Find every directive comment of `text` that spans lines, in document order.
894///
895/// Walks the openers the way `parse_inline_directives` does (an opener that is
896/// not a directive is stepped over by its four bytes), so a comment is a directive
897/// here exactly when it would be on one line. A directive takes effect where its
898/// comment ends: `disable` and `enable` from the line after the `-->` line,
899/// `disable-line` on it, `disable-next-line` on the one after.
900///
901/// An opener `in_code` calls code is stepped over by its four bytes like any other
902/// non-directive, so an unterminated example in code cannot swallow the real
903/// directive that follows it. `in_code` is asked only about comments that span
904/// lines, since only those are kept.
905fn scan_multiline_directives(text: &str, in_code: impl Fn(usize) -> bool) -> Vec<MultilineDirective<'_>> {
906    let mut found = Vec::new();
907    let mut pos = 0;
908    // Newlines before `counted_to`, so each opener's line is found by counting
909    // only the text since the previous one.
910    let mut counted_to = 0;
911    let mut lines_before = 0;
912    // The first `-->` at or after `pos`, found again only once `pos` passes it, so
913    // a run of openers that share one closer costs one search.
914    let mut next_close: Option<usize> = None;
915    // The first newline at or after `pos` (`text.len()` when none), kept the same way.
916    let mut next_newline = 0;
917    let mut quotes = BlockquoteTracker::new();
918
919    while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
920        let open = pos + open_offset;
921        pos = open + COMMENT_OPEN.len();
922        let close = match next_close.filter(|&close| close >= pos) {
923            Some(close) => close,
924            None => match text[pos..].find("-->") {
925                Some(found) => *next_close.insert(pos + found),
926                // No comment can close, so no directive follows.
927                None => break,
928            },
929        };
930        // A comment in code is ruled out before its text is parsed, so an example
931        // of many openers over one closer is not parsed once per opener.
932        if next_newline < pos {
933            next_newline = text[pos..].find('\n').map_or(text.len(), |newline| pos + newline);
934        }
935        let spans_lines = next_newline < close;
936        if spans_lines && in_code(open) {
937            continue;
938        }
939        let depth = quotes.quote_depth_at(text, open);
940        let body = comment_body(&text[pos..], depth);
941        let body_start = text.len() - body.len();
942
943        let Some((kind, rules, consumed)) = try_parse_directive(body, depth) else {
944            continue;
945        };
946        let end = body_start + consumed;
947        let newlines = text[open..end].matches('\n').count();
948        if newlines == 0 {
949            pos = end;
950            continue;
951        }
952        pos = end;
953        // configure-file has its own document-wide scan.
954        if kind == DirectiveKind::ConfigureFile {
955            continue;
956        }
957        lines_before += text[counted_to..open].matches('\n').count();
958        counted_to = open;
959        let start_line = lines_before + 1;
960        found.push(MultilineDirective {
961            kind,
962            rules,
963            open,
964            end,
965            start_line,
966            end_line: start_line + newlines,
967        });
968    }
969
970    found
971}
972
973/// Decides whether a comment opening at a byte offset is code: inside a code block,
974/// inside a code span, or on a line an indented code block holds.
975///
976/// The line answer is kept for the line last asked about, so many openers on one
977/// long line cost one look at it between them.
978struct CommentCodeTest<'a> {
979    content: &'a str,
980    code_blocks: CodeRangeIndex<'a>,
981    code_spans: CodeRangeIndex<'a>,
982    /// (start, end, in code) of the line last asked about
983    line: std::cell::Cell<Option<(usize, usize, bool)>>,
984}
985
986impl<'a> CommentCodeTest<'a> {
987    fn new(content: &'a str, code_blocks: &'a [(usize, usize)], code_spans: &'a [(usize, usize)]) -> Self {
988        Self {
989            content,
990            code_blocks: CodeRangeIndex::new(code_blocks),
991            code_spans: CodeRangeIndex::new(code_spans),
992            line: std::cell::Cell::new(None),
993        }
994    }
995
996    fn contains(&self, open: usize) -> bool {
997        if self.code_blocks.contains(open) || self.code_spans.contains(open) {
998            return true;
999        }
1000        if let Some((start, end, in_code)) = self.line.get()
1001            && (start..end).contains(&open)
1002        {
1003            return in_code;
1004        }
1005        let line_start = self.content[..open].rfind('\n').map_or(0, |newline| newline + 1);
1006        let line_end = self.content[line_start..]
1007            .find('\n')
1008            .map_or(self.content.len(), |newline| line_start + newline);
1009        let in_code = line_in_code_block(line_start, &self.content[line_start..line_end], &self.code_blocks);
1010        self.line.set(Some((line_start, line_end.max(line_start + 1), in_code)));
1011        in_code
1012    }
1013}
1014
1015/// The directive-kind name a validation warning reports, for the kinds that name
1016/// rules.
1017fn directive_comment_type(kind: DirectiveKind) -> Option<&'static str> {
1018    match kind {
1019        DirectiveKind::Disable => Some("disable"),
1020        DirectiveKind::Enable => Some("enable"),
1021        DirectiveKind::DisableLine => Some("disable-line"),
1022        DirectiveKind::DisableNextLine => Some("disable-next-line"),
1023        DirectiveKind::DisableFile => Some("disable-file"),
1024        DirectiveKind::EnableFile => Some("enable-file"),
1025        DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => None,
1026    }
1027}
1028
1029// ── Backward-compatible wrapper functions ────────────────────────────────────
1030//
1031// These delegate to parse_inline_directives and filter by DirectiveKind.
1032// External callers (e.g., MD040) use these; internal code uses the unified parser.
1033
1034fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
1035    parse_inline_directives(line)
1036        .into_iter()
1037        .find(|d| d.kind == kind)
1038        .map(|d| d.rules)
1039}
1040
1041/// Parse a disable comment and return the list of rules (empty vec means all rules)
1042pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
1043    find_directive_rules(line, DirectiveKind::Disable)
1044}
1045
1046/// Parse an enable comment and return the list of rules (empty vec means all rules)
1047pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
1048    find_directive_rules(line, DirectiveKind::Enable)
1049}
1050
1051/// Parse a disable-line comment
1052pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
1053    find_directive_rules(line, DirectiveKind::DisableLine)
1054}
1055
1056/// Parse a disable-next-line comment
1057pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
1058    find_directive_rules(line, DirectiveKind::DisableNextLine)
1059}
1060
1061/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
1062pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
1063    find_directive_rules(line, DirectiveKind::DisableFile)
1064}
1065
1066/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
1067pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
1068    find_directive_rules(line, DirectiveKind::EnableFile)
1069}
1070
1071/// Check if line contains a capture comment
1072pub fn is_capture_comment(line: &str) -> bool {
1073    parse_inline_directives(line)
1074        .iter()
1075        .any(|d| d.kind == DirectiveKind::Capture)
1076}
1077
1078/// Check if line contains a restore comment
1079pub fn is_restore_comment(line: &str) -> bool {
1080    parse_inline_directives(line)
1081        .iter()
1082        .any(|d| d.kind == DirectiveKind::Restore)
1083}
1084
1085const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
1086
1087/// Half-open range membership shared by every directive in a source operation.
1088/// Parser ranges normally need no allocation. Public callers may supply
1089/// overlapping or unsorted ranges; normalize those without changing membership.
1090struct CodeRangeIndex<'a>(std::borrow::Cow<'a, [(usize, usize)]>);
1091
1092impl<'a> CodeRangeIndex<'a> {
1093    fn new(ranges: &'a [(usize, usize)]) -> Self {
1094        if ranges.iter().all(|&(start, end)| start < end) && ranges.windows(2).all(|pair| pair[0].1 <= pair[1].0) {
1095            return Self(std::borrow::Cow::Borrowed(ranges));
1096        }
1097        let mut sorted: Vec<_> = ranges.iter().copied().filter(|&(start, end)| start < end).collect();
1098        sorted.sort_unstable_by_key(|&(start, _)| start);
1099        let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
1100        for (start, end) in sorted {
1101            if let Some(last) = merged.last_mut()
1102                && start <= last.1
1103            {
1104                last.1 = last.1.max(end);
1105            } else {
1106                merged.push((start, end));
1107            }
1108        }
1109        Self(std::borrow::Cow::Owned(merged))
1110    }
1111
1112    fn contains(&self, offset: usize) -> bool {
1113        let after = self.0.partition_point(|&(_, end)| end <= offset);
1114        self.0.get(after).is_some_and(|&(start, _)| start <= offset)
1115    }
1116}
1117
1118/// The directives on a line that are not inside an inline code span.
1119///
1120/// `line_start` is the line's byte offset in the document, which is what makes a
1121/// directive's span comparable with the document-wide code span ranges. Check
1122/// each directive's own offset: a live directive beside a code span still applies.
1123fn directives_outside_code_spans<'a>(
1124    line: &'a str,
1125    line_start: usize,
1126    code_spans: &CodeRangeIndex<'_>,
1127) -> Vec<InlineDirective<'a>> {
1128    let mut directives = parse_inline_directives(line);
1129    if !code_spans.0.is_empty() {
1130        directives.retain(|directive| !code_spans.contains(line_start + directive.span.start));
1131    }
1132    directives
1133}
1134
1135/// Whether a directive written on this line sits inside a code block.
1136///
1137/// The line is probed at its first non-whitespace byte, because an indented code
1138/// block's range starts at the indented content rather than at the start of the
1139/// line. Asking whether the whole line span is contained would answer "no" for
1140/// every indented block and let a directive written in a code sample configure
1141/// the document.
1142fn line_in_code_block(line_start: usize, line: &str, code_blocks: &CodeRangeIndex<'_>) -> bool {
1143    let probe = line
1144        .find(|c: char| !c.is_whitespace())
1145        .map_or(line_start, |indent| line_start + indent);
1146    code_blocks.contains(probe)
1147}
1148
1149/// A warning together with the byte offset of the directive that raised it.
1150///
1151/// The offset is what lets a warning be tested against inline code spans, which
1152/// a line number cannot answer: a span covers part of a line.
1153type LocatedWarning = (InlineConfigWarning, usize);
1154
1155/// Drop warnings raised by a directive that code covers, and shed the offsets.
1156///
1157/// `InlineConfig` skips directives inside code blocks and inline code spans, so
1158/// a fenced example or a backticked mention documents a directive rather than
1159/// writing one, and there is nothing to report about it. The ranges are the
1160/// flavor's own, so an indented container body a directive does configure keeps
1161/// its warning. They cost a parse of the document, so they are computed only
1162/// once there is a warning to filter.
1163fn drop_warnings_inside_code(
1164    content: &str,
1165    flavor: MarkdownFlavor,
1166    mut warnings: Vec<LocatedWarning>,
1167) -> Vec<InlineConfigWarning> {
1168    if warnings.is_empty() {
1169        return Vec::new();
1170    }
1171    let code = crate::lint_context::code_ranges(content, flavor);
1172    if !code.blocks.is_empty() {
1173        let blocks = CodeRangeIndex::new(&code.blocks);
1174        // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a
1175        // CRLF document's offsets match the ranges above. A code block is judged
1176        // by the line, since an indented block's range starts at the indented
1177        // content rather than at the start of the line.
1178        let line_spans: Vec<(usize, &str)> = {
1179            let mut spans = Vec::new();
1180            let mut start = 0;
1181            for line in content.split('\n') {
1182                spans.push((start, line));
1183                start += line.len() + 1;
1184            }
1185            spans
1186        };
1187
1188        warnings.retain(|(warning, _)| {
1189            let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
1190                return true;
1191            };
1192            !line_in_code_block(line_start, line, &blocks)
1193        });
1194    }
1195    if !code.spans.is_empty() {
1196        let spans = CodeRangeIndex::new(&code.spans);
1197        warnings.retain(|&(_, offset)| !spans.contains(offset));
1198    }
1199
1200    warnings.into_iter().map(|(warning, _)| warning).collect()
1201}
1202
1203/// Find every configure-file comment in `text`, returning each JSON payload
1204/// with the byte offset of its opening `<!--`.
1205///
1206/// `text` is normally the whole document: unlike every other directive, a
1207/// configure-file comment may span lines, so its `-->` is searched for without
1208/// regard to line boundaries. The offset lets callers map a payload back to a
1209/// line number or test it against code block ranges.
1210///
1211/// Payloads that are empty or not valid JSON are skipped, and scanning
1212/// continues past them.
1213fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
1214    let mut found = Vec::new();
1215    let mut pos = 0;
1216    let mut quotes = BlockquoteTracker::new();
1217
1218    while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
1219        let comment_start = pos + open_offset;
1220        let after_open = comment_body(
1221            &text[comment_start + COMMENT_OPEN.len()..],
1222            quotes.quote_depth_at(text, comment_start),
1223        );
1224        let body_start = text.len() - after_open.len();
1225        // Advance past this opener by default, so an unrecognized or malformed
1226        // comment cannot stall the scan.
1227        pos = comment_start + COMMENT_OPEN.len();
1228
1229        for tool in TOOL_PREFIXES {
1230            let Some(after_tool) = after_open.strip_prefix(tool) else {
1231                continue;
1232            };
1233            let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
1234                break;
1235            };
1236            // Word boundary: the keyword must be followed by whitespace or `-->`,
1237            // so `configure-files` does not match.
1238            if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
1239                break;
1240            }
1241            let Some(close_offset) = after_kw.find("-->") else {
1242                break;
1243            };
1244
1245            let json_str = after_kw[..close_offset].trim_matches(is_comment_space);
1246            if !json_str.is_empty()
1247                && let Ok(value) = serde_json::from_str(json_str)
1248            {
1249                found.push((comment_start, value));
1250            }
1251            pos = body_start + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
1252            break;
1253        }
1254    }
1255
1256    found
1257}
1258
1259/// Parse a configure-file comment and return the JSON configuration.
1260///
1261/// Returns the first payload found. The text may span lines.
1262pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
1263    scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
1264}
1265
1266// ── Disable-comment inventory ────────────────────────────────────────────────
1267
1268/// The lines an inline disable comment can suppress a finding on.
1269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270pub enum DisableScope {
1271    /// Exactly this 1-indexed line
1272    Line(usize),
1273    /// The lines below this 1-indexed one, up to the end of the document
1274    Block(usize),
1275    /// Every line of the document
1276    File,
1277}
1278
1279impl DisableScope {
1280    /// Whether this comment is one of those keeping a rule off at `line`, given the
1281    /// layer that answered for it there.
1282    ///
1283    /// Answering for a line credits every comment of that layer reaching it, so no
1284    /// comment loses its credit to another. A comment left uncredited is one whose
1285    /// every line is spoken for by a layer it is not in, and removing it therefore
1286    /// changes nothing the run reports.
1287    ///
1288    /// A block `disable` is treated as reaching the end of the document even when a
1289    /// later `enable` closes it. Crediting a comment for more than it holds can only
1290    /// leave a stale comment unreported.
1291    pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
1292        match (self, layer) {
1293            (Self::Line(target), DisableLayer::Line) => target == line,
1294            // A block disable takes effect on the line after the comment.
1295            (Self::Block(start), DisableLayer::Block) => start < line,
1296            (Self::File, DisableLayer::File) => true,
1297            _ => false,
1298        }
1299    }
1300}
1301
1302/// An inline comment that disables rules, and the lines it can act on.
1303#[derive(Debug, Clone, PartialEq, Eq)]
1304pub struct DisableSite {
1305    /// 1-indexed line the comment opens on
1306    pub line: usize,
1307    /// Byte range of the comment within that line, clipped to the line's end
1308    pub span: std::ops::Range<usize>,
1309    /// The directive as written, e.g. `disable-line`
1310    pub kind: &'static str,
1311    /// The rule names the comment carries, as written; empty means every rule
1312    pub rules: Vec<String>,
1313    /// The lines the comment can suppress a finding on
1314    pub scope: DisableScope,
1315}
1316
1317/// Every inline comment that disables rules, in document order.
1318///
1319/// Comments inside code blocks or inline code spans configure nothing, so they
1320/// are left out, matching what `InlineConfig` applies. `prettier-ignore` belongs
1321/// to another formatter and is left out as well.
1322///
1323/// `code_blocks` and `code_spans` are the ranges the document's flavor really
1324/// holds as code, the same ones `InlineConfig` was built from. Recomputing them
1325/// from the text alone would read an indented container body (a MkDocs
1326/// admonition, a MyST directive) as an indented code block and leave out
1327/// comments that do configure the document.
1328pub fn collect_disable_sites(
1329    content: &str,
1330    code_blocks: &[(usize, usize)],
1331    code_spans: &[(usize, usize)],
1332) -> Vec<DisableSite> {
1333    if !has_inline_config_markers(content) {
1334        return Vec::new();
1335    }
1336
1337    let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
1338    // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a CRLF
1339    // document's offsets match the code block ranges.
1340    let line_spans: Vec<(usize, &str)> = {
1341        let mut spans = Vec::new();
1342        let mut start = 0;
1343        for line in content.split('\n') {
1344            spans.push((start, line));
1345            start += line.len() + 1;
1346        }
1347        spans
1348    };
1349
1350    let mut sites = Vec::new();
1351
1352    for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
1353        if line_in_code_block(line_start, line, &in_code.code_blocks) {
1354            continue;
1355        }
1356        let line_num = idx + 1;
1357        for directive in directives_outside_code_spans(line, line_start, &in_code.code_spans) {
1358            let (kind, scope) = match directive.kind {
1359                DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
1360                DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
1361                DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
1362                DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1363                DirectiveKind::Enable
1364                | DirectiveKind::EnableFile
1365                | DirectiveKind::Capture
1366                | DirectiveKind::Restore
1367                | DirectiveKind::ConfigureFile => continue,
1368            };
1369            sites.push(DisableSite {
1370                line: line_num,
1371                span: directive.span,
1372                kind,
1373                rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1374                scope,
1375            });
1376        }
1377    }
1378
1379    // A directive comment spanning lines opens on one line and takes effect where
1380    // it ends. The site is reported on the opening line with its span clipped to it.
1381    for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
1382        let (kind, scope) = match directive.kind {
1383            DirectiveKind::Disable => ("disable", DisableScope::Block(directive.end_line)),
1384            DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(directive.end_line)),
1385            DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(directive.end_line + 1)),
1386            DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1387            _ => continue,
1388        };
1389        let Some(&(line_start, line)) = line_spans.get(directive.start_line - 1) else {
1390            continue;
1391        };
1392        sites.push(DisableSite {
1393            line: directive.start_line,
1394            span: (directive.open - line_start)..(directive.end - line_start).min(line.len()),
1395            kind,
1396            rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1397            scope,
1398        });
1399    }
1400
1401    // A configure-file entry given `false` turns its rule off for the whole file,
1402    // exactly as disable-file does. The comment may span lines, so it is scanned
1403    // over the document and its span is clipped to the line it opens on.
1404    for (offset, json_config) in scan_configure_file_comments(content) {
1405        if in_code.code_blocks.contains(offset) || in_code.code_spans.contains(offset) {
1406            continue;
1407        }
1408        let Some(obj) = json_config.as_object() else {
1409            continue;
1410        };
1411        let rules: Vec<String> = obj
1412            .iter()
1413            .filter(|(_, value)| value.as_bool() == Some(false))
1414            .map(|(name, _)| name.clone())
1415            .collect();
1416        if rules.is_empty() {
1417            continue;
1418        }
1419        let line_num = line_spans.partition_point(|&(start, _)| start <= offset);
1420        let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
1421            continue;
1422        };
1423        let comment_end = content[offset..]
1424            .find("-->")
1425            .map_or(content.len(), |close| offset + close + 3);
1426        sites.push(DisableSite {
1427            line: line_num,
1428            span: (offset - line_start)..(comment_end - line_start).min(line.len()),
1429            kind: "configure-file",
1430            rules,
1431            scope: DisableScope::File,
1432        });
1433    }
1434
1435    sites.sort_by_key(|site| (site.line, site.span.start));
1436    sites
1437}
1438
1439/// What is wrong with an inline config comment.
1440#[derive(Debug, Clone, PartialEq, Eq)]
1441pub enum InlineConfigProblem {
1442    /// A rule name that is not recognized.
1443    UnknownRule,
1444    /// A recognized rule carrying an unrecognized option key inside a
1445    /// configure-file config object.
1446    UnknownOption { key: String },
1447    /// An inline directive tries to enable a rule that will not run over this
1448    /// document. rumdl treats config-level rule selection as final, so the
1449    /// enable has no effect; this makes that silent no-op visible.
1450    EnableHasNoEffect { reason: EnableNoEffectReason },
1451}
1452
1453/// Why an inline enable cannot take effect.
1454///
1455/// The two are separate settings, so the message names the one actually in play
1456/// rather than sending the reader to a knob their config does not have set.
1457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458pub enum EnableNoEffectReason {
1459    /// Configuration did not enable the rule for this run at all.
1460    NotEnabled,
1461    /// Configuration enabled the rule, but `per-file-ignores` suppresses it for
1462    /// this particular file.
1463    IgnoredForFile,
1464}
1465
1466/// Warning about an inline config comment.
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct InlineConfigWarning {
1469    /// The line number where the warning occurred (1-indexed)
1470    pub line_number: usize,
1471    /// The rule the warning concerns
1472    pub rule_name: String,
1473    /// The type of inline config comment (e.g. "disable", "configure-file")
1474    pub comment_type: String,
1475    /// Suggestion for a similar rule name or option key, when one is close
1476    pub suggestion: Option<String>,
1477    /// What is wrong
1478    pub problem: InlineConfigProblem,
1479}
1480
1481impl InlineConfigWarning {
1482    /// Format the warning message
1483    pub fn format_message(&self) -> String {
1484        // Wording for unknown rules/options matches the config-file validator so
1485        // the same mistake reads the same way wherever it is written.
1486        match &self.problem {
1487            InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1488                Some(ref suggestion) => format!(
1489                    "Unknown option for rule {}: {} (did you mean: {}?)",
1490                    self.rule_name, key, suggestion
1491                ),
1492                None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1493            },
1494            InlineConfigProblem::UnknownRule => match self.suggestion {
1495                Some(ref suggestion) => format!(
1496                    "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1497                    self.comment_type, self.rule_name, suggestion
1498                ),
1499                None => format!(
1500                    "Unknown rule in inline {} comment: {}",
1501                    self.comment_type, self.rule_name
1502                ),
1503            },
1504            InlineConfigProblem::EnableHasNoEffect {
1505                reason: EnableNoEffectReason::NotEnabled,
1506            } => format!(
1507                "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1508                self.rule_name, self.comment_type
1509            ),
1510            InlineConfigProblem::EnableHasNoEffect {
1511                reason: EnableNoEffectReason::IgnoredForFile,
1512            } => format!(
1513                "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1514                self.rule_name, self.comment_type
1515            ),
1516        }
1517    }
1518
1519    /// Print the warning to stderr with file context
1520    pub fn print_warning(&self, file_path: &str) {
1521        eprintln!(
1522            "{} {}:{}: {}",
1523            crate::utils::warning_label("inline config warning"),
1524            file_path,
1525            self.line_number,
1526            self.format_message()
1527        );
1528    }
1529
1530    /// The warning as a lint diagnostic anchored to the comment that raised it.
1531    ///
1532    /// A directive naming a rule that does not exist silently does nothing, and on
1533    /// the CLI that is a line of stderr next to the findings. Editors show
1534    /// diagnostics, so the language server reports it as one and the mistake is
1535    /// visible where it was written. The span covers the whole line: the validator
1536    /// locates the comment, not the rule name inside it.
1537    pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1538        let line_width = content
1539            .lines()
1540            .nth(self.line_number.saturating_sub(1))
1541            .map_or(0, |line| line.chars().count());
1542        crate::rule::LintWarning {
1543            message: self.format_message(),
1544            line: self.line_number,
1545            column: 1,
1546            end_line: self.line_number,
1547            end_column: line_width + 1,
1548            severity: crate::rule::Severity::Warning,
1549            fix: None,
1550            rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1551        }
1552    }
1553}
1554
1555/// The diagnostic code inline config warnings carry.
1556///
1557/// Not a rule name: it names the class of problem so an editor can tell these
1558/// apart from rule violations, and so nothing looks it up in the rule registry.
1559pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1560
1561/// Validate all inline config comments in content and return warnings for unknown rules.
1562///
1563/// This function extracts rule names from all types of inline config comments
1564/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
1565/// and validates them against the known rule alias map. Comments inside code
1566/// blocks or inline code spans are documentation rather than configuration, so
1567/// they are left alone, matching what `InlineConfig` applies. `flavor` is the
1568/// document's own, which decides what its indentation means.
1569pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1570    let (warnings, _) = collect_inline_config_warnings(content, flavor, true, None);
1571    drop_warnings_inside_code(content, flavor, warnings)
1572}
1573
1574/// Warn when an inline enable names a valid rule excluded by the active rule
1575/// set or by per-file ignores. Unknown names are reported by
1576/// [`validate_inline_config_rules`]. Bare enables target no specific rule, and
1577/// directives in code configure nothing and produce no warning.
1578pub fn validate_inline_enables_against_active_rules(
1579    content: &str,
1580    flavor: MarkdownFlavor,
1581    active_rules: &HashSet<String>,
1582    ignored_for_file: &HashSet<String>,
1583) -> Vec<InlineConfigWarning> {
1584    let (_, warnings) = collect_inline_config_warnings(content, flavor, false, Some((active_rules, ignored_for_file)));
1585    drop_warnings_inside_code(content, flavor, warnings)
1586}
1587
1588/// Validate names, options, and ineffective enables in one source scan and one
1589/// flavor-aware code reading. Name/option warnings precede enable warnings,
1590/// matching the separate validators' concatenated reports.
1591pub fn validate_inline_configuration(
1592    content: &str,
1593    flavor: MarkdownFlavor,
1594    active_rules: &HashSet<String>,
1595    ignored_for_file: &HashSet<String>,
1596) -> Vec<InlineConfigWarning> {
1597    let (mut warnings, enables) =
1598        collect_inline_config_warnings(content, flavor, true, Some((active_rules, ignored_for_file)));
1599    warnings.extend(enables);
1600    drop_warnings_inside_code(content, flavor, warnings)
1601}
1602
1603/// Keep the original offsets until all warnings can share code filtering.
1604/// Each directive and multiline JSON payload is parsed only once.
1605fn collect_inline_config_warnings(
1606    content: &str,
1607    flavor: MarkdownFlavor,
1608    validate_names: bool,
1609    active: Option<(&HashSet<String>, &HashSet<String>)>,
1610) -> (Vec<LocatedWarning>, Vec<LocatedWarning>) {
1611    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1612
1613    if !has_inline_config_markers(content) {
1614        return (Vec::new(), Vec::new());
1615    }
1616    let mut warnings = Vec::new();
1617    let mut enables = Vec::new();
1618    // Most directives name valid rules and need no spelling suggestions.
1619    let all_rule_names = std::cell::OnceCell::<Vec<String>>::new();
1620    let suggest = |name: &str| {
1621        let names =
1622            all_rule_names.get_or_init(|| RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect());
1623        suggest_similar_key(name, names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1624    };
1625    let mut flag_enable = |name: &str, comment_type: &str, line: usize, offset: usize| {
1626        let Some((active_rules, ignored_for_file)) = active else {
1627            return;
1628        };
1629        if !is_valid_rule_name(name) {
1630            return;
1631        }
1632        let canonical = normalize_rule_name(name);
1633        let reason = if !active_rules.contains(&canonical) {
1634            EnableNoEffectReason::NotEnabled
1635        } else if ignored_for_file.contains(&canonical) {
1636            EnableNoEffectReason::IgnoredForFile
1637        } else {
1638            return;
1639        };
1640        enables.push((
1641            InlineConfigWarning {
1642                line_number: line,
1643                rule_name: canonical,
1644                comment_type: comment_type.to_string(),
1645                suggestion: None,
1646                problem: InlineConfigProblem::EnableHasNoEffect { reason },
1647            },
1648            offset,
1649        ));
1650    };
1651
1652    // The scanner emits increasing offsets. Count only the new prefix between
1653    // comments instead of recounting the document for every configuration.
1654    let mut previous_offset = 0;
1655    let mut line_number = 1;
1656    for (offset, json_config) in scan_configure_file_comments(content) {
1657        let Some(obj) = json_config.as_object() else {
1658            continue;
1659        };
1660        line_number += content[previous_offset..offset].bytes().filter(|&b| b == b'\n').count();
1661        previous_offset = offset;
1662        for (rule_name, rule_config) in obj {
1663            if validate_names {
1664                if !is_valid_rule_name(rule_name) {
1665                    warnings.push((
1666                        InlineConfigWarning {
1667                            line_number,
1668                            rule_name: rule_name.clone(),
1669                            comment_type: "configure-file".to_string(),
1670                            suggestion: suggest(rule_name),
1671                            problem: InlineConfigProblem::UnknownRule,
1672                        },
1673                        offset,
1674                    ));
1675                    continue;
1676                }
1677                if let Some(options) = rule_config.as_object() {
1678                    let canonical = normalize_rule_name(rule_name);
1679                    // Do not initialize the schema registry for enable/disable
1680                    // comments or boolean configure-file entries.
1681                    if let Some(valid_keys) = crate::config::default_registry().config_keys_for(&canonical) {
1682                        let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1683                        for key in options.keys().filter(|key| !valid_keys.contains(*key)) {
1684                            warnings.push((
1685                                InlineConfigWarning {
1686                                    line_number,
1687                                    rule_name: canonical.clone(),
1688                                    comment_type: "configure-file".to_string(),
1689                                    suggestion: suggest_similar_key(key, &valid_keys_vec),
1690                                    problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1691                                },
1692                                offset,
1693                            ));
1694                        }
1695                    }
1696                }
1697            }
1698            if rule_config.as_bool() == Some(true) {
1699                flag_enable(rule_name, "configure-file", line_number, offset);
1700            }
1701        }
1702    }
1703
1704    // Every directive that names rules, as (kind, rules, reported line, offset).
1705    // split keeps CR bytes, so document offsets match the code ranges for both
1706    // LF and CRLF input. configure-file is handled document-wide above, and a
1707    // comment spanning lines is reported on the line it opens on.
1708    let mut named: Vec<(DirectiveKind, Vec<&str>, usize, usize)> = Vec::new();
1709    let mut line_start = 0;
1710    for (idx, line) in content.split('\n').enumerate() {
1711        for directive in parse_inline_directives(line) {
1712            named.push((
1713                directive.kind,
1714                directive.rules,
1715                idx + 1,
1716                line_start + directive.span.start,
1717            ));
1718        }
1719        line_start += line.len() + 1;
1720    }
1721    // The document's code ranges cost a parse, so they are read only once a
1722    // directive spanning lines needs them.
1723    let code = std::cell::OnceCell::new();
1724    let code_test = std::cell::OnceCell::new();
1725    let multiline_in_code = |open: usize| {
1726        code_test
1727            .get_or_init(|| {
1728                let ranges = code.get_or_init(|| crate::lint_context::code_ranges(content, flavor));
1729                CommentCodeTest::new(content, &ranges.blocks, &ranges.spans)
1730            })
1731            .contains(open)
1732    };
1733    for directive in scan_multiline_directives(content, multiline_in_code) {
1734        named.push((directive.kind, directive.rules, directive.start_line, directive.open));
1735    }
1736    for (kind, rules, line_number, offset) in named {
1737        let Some(comment_type) = directive_comment_type(kind) else {
1738            continue;
1739        };
1740        for rule_name in rules {
1741            if validate_names && !is_valid_rule_name(rule_name) {
1742                warnings.push((
1743                    InlineConfigWarning {
1744                        line_number,
1745                        rule_name: rule_name.to_string(),
1746                        comment_type: comment_type.to_string(),
1747                        suggestion: suggest(rule_name),
1748                        problem: InlineConfigProblem::UnknownRule,
1749                    },
1750                    offset,
1751                ));
1752            }
1753            if matches!(kind, DirectiveKind::Enable | DirectiveKind::EnableFile) {
1754                flag_enable(rule_name, comment_type, line_number, offset);
1755            }
1756        }
1757    }
1758    warnings.sort_by_key(|(warning, _)| warning.line_number);
1759    enables.sort_by_key(|(warning, _)| warning.line_number);
1760    (warnings, enables)
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765    use super::*;
1766
1767    #[test]
1768    fn code_range_index_preserves_arbitrary_range_membership() {
1769        let mut cases = vec![
1770            vec![],
1771            vec![(0, 2), (2, 5), (8, 10)],
1772            vec![(8, 20), (0, 12), (4, 6), (3, 3), (9, 1)],
1773            vec![(0, usize::MAX), (usize::MAX, usize::MAX)],
1774            vec![(usize::MAX - 1, usize::MAX), (5, 2)],
1775        ];
1776        for seed in 0..32 {
1777            cases.push(
1778                (0..16)
1779                    .map(|i| ((i * 7 + seed * 11) % 61, (i * 13 + seed * 17) % 61))
1780                    .collect(),
1781            );
1782        }
1783        for mut ranges in cases {
1784            for reverse in [false, true] {
1785                if reverse {
1786                    ranges.reverse();
1787                }
1788                let index = CodeRangeIndex::new(&ranges);
1789                for offset in (0..64).chain([usize::MAX - 1, usize::MAX]) {
1790                    let expected = ranges.iter().any(|&(start, end)| start <= offset && offset < end);
1791                    assert_eq!(index.contains(offset), expected, "{ranges:?}, byte {offset}");
1792                }
1793            }
1794        }
1795    }
1796
1797    #[test]
1798    fn configure_file_positions_match_source_lines() {
1799        for separator in [" ", "\n", "\r\n"] {
1800            let source = [
1801                "é日本🙂",
1802                "<!-- rumdl-configure-file [] -->",
1803                "<!-- rumdl-configure-file {\"MD013\":false,\"unknown-rule\":false} -->",
1804                "<!-- rumdl-configure-file broken -->",
1805                "<!-- rumdl-configure-file\n{\"MD013\":false,\"unknown-rule\":false}\n-->",
1806                "<!-- rumdl-configure-file null -->",
1807                "<!-- rumdl-configure-file {\"MD013\":false,\"unknown-rule\":false} -->",
1808            ]
1809            .join(if separator == "\r\n" { "\n" } else { separator })
1810            .replace('\n', if separator == "\r\n" { "\r\n" } else { "\n" });
1811            let expected: Vec<_> = scan_configure_file_comments(&source)
1812                .into_iter()
1813                .filter(|(_, value)| value.is_object())
1814                .map(|(offset, _)| source[..offset].bytes().filter(|&byte| byte == b'\n').count() + 1)
1815                .collect();
1816            assert_eq!(expected.len(), 3);
1817            let sites = collect_disable_sites(&source, &[], &[]);
1818            assert_eq!(sites.iter().map(|site| site.line).collect::<Vec<_>>(), expected);
1819            let warnings = validate_inline_config_rules(&source, MarkdownFlavor::Standard);
1820            assert_eq!(
1821                warnings.iter().map(|warning| warning.line_number).collect::<Vec<_>>(),
1822                expected
1823            );
1824        }
1825    }
1826
1827    #[test]
1828    fn combined_validation_preserves_warning_order_and_literal_code() {
1829        let source = concat!(
1830            "<!-- rumdl-enable MD009 -->\n",
1831            "<!-- rumdl-disable MD999 -->\n",
1832            "<!-- rumdl-configure-file {\"MD011\":true,\"MD013\":{\"line-lenght\":40}} -->\n",
1833            "<!-- rumdl-enable MD013 -->\n",
1834            "```markdown\n",
1835            "<!-- rumdl-enable MD010 -->\n",
1836            "<!-- rumdl-disable MD998 -->\n",
1837            "```\n",
1838            "`<!-- rumdl-enable MD012 -->`\n",
1839        );
1840        let active = HashSet::from(["MD013".to_string()]);
1841        let ignored = active.clone();
1842        for ending in ["\n", "\r\n"] {
1843            let content = source.replace('\n', ending);
1844            for flavor in [
1845                MarkdownFlavor::Standard,
1846                MarkdownFlavor::MkDocs,
1847                MarkdownFlavor::MDX,
1848                MarkdownFlavor::Pandoc,
1849                MarkdownFlavor::Quarto,
1850                MarkdownFlavor::Obsidian,
1851                MarkdownFlavor::Kramdown,
1852                MarkdownFlavor::AzureDevOps,
1853                MarkdownFlavor::MyST,
1854                MarkdownFlavor::Hugo,
1855                MarkdownFlavor::MDG,
1856                MarkdownFlavor::GhAw,
1857            ] {
1858                let mut separate = validate_inline_config_rules(&content, flavor);
1859                separate.extend(validate_inline_enables_against_active_rules(
1860                    &content, flavor, &active, &ignored,
1861                ));
1862                let combined = validate_inline_configuration(&content, flavor, &active, &ignored);
1863                assert_eq!(format!("{combined:?}"), format!("{separate:?}"), "{flavor:?}");
1864                assert_eq!(
1865                    combined
1866                        .iter()
1867                        .map(|warning| (warning.rule_name.as_str(), warning.line_number))
1868                        .collect::<Vec<_>>(),
1869                    vec![("MD999", 2), ("MD013", 3), ("MD009", 1), ("MD011", 3), ("MD013", 4)],
1870                    "{flavor:?}",
1871                );
1872            }
1873        }
1874    }
1875
1876    // ── Whitespace around the comment delimiters is optional ─────────────
1877
1878    const COMPACT_OPENERS: &[&str] = &["<!--", "<!-- ", "<!--  ", "<!--\t", "<!-- \t "];
1879    const COMPACT_CLOSERS: &[&str] = &["-->", " -->", "  -->", "\t-->"];
1880
1881    #[test]
1882    fn test_parse_inline_directives_whitespace_around_delimiters_is_optional() {
1883        let cases: &[(&str, DirectiveKind, &[&str])] = &[
1884            ("rumdl-disable MD013", DirectiveKind::Disable, &["MD013"]),
1885            (
1886                "markdownlint-disable MD013 MD009",
1887                DirectiveKind::Disable,
1888                &["MD013", "MD009"],
1889            ),
1890            ("markdownlint-disable", DirectiveKind::Disable, &[]),
1891            ("rumdl-enable", DirectiveKind::Enable, &[]),
1892            ("markdownlint-enable MD013", DirectiveKind::Enable, &["MD013"]),
1893            ("rumdl-disable-line MD013", DirectiveKind::DisableLine, &["MD013"]),
1894            (
1895                "markdownlint-disable-next-line MD013",
1896                DirectiveKind::DisableNextLine,
1897                &["MD013"],
1898            ),
1899            (
1900                "markdownlint-disable-file MD013",
1901                DirectiveKind::DisableFile,
1902                &["MD013"],
1903            ),
1904            ("rumdl-enable-file", DirectiveKind::EnableFile, &[]),
1905            ("markdownlint-capture", DirectiveKind::Capture, &[]),
1906            ("rumdl-restore", DirectiveKind::Restore, &[]),
1907            ("markdownlint-configure-file {}", DirectiveKind::ConfigureFile, &["{}"]),
1908        ];
1909        for &(body, kind, rules) in cases {
1910            for open in COMPACT_OPENERS {
1911                for close in COMPACT_CLOSERS {
1912                    let comment = format!("{open}{body}{close}");
1913                    // Embedded in a line, so the span offsets are exercised too.
1914                    let line = format!("text {comment} tail");
1915                    let directives = parse_inline_directives(&line);
1916                    assert_eq!(directives.len(), 1, "{line:?}");
1917                    assert_eq!(directives[0].kind, kind, "{line:?}");
1918                    assert_eq!(directives[0].rules, rules, "{line:?}");
1919                    assert_eq!(&line[directives[0].span.clone()], comment, "{line:?}");
1920                }
1921            }
1922        }
1923    }
1924
1925    #[test]
1926    fn test_parse_inline_directives_compact_not_a_directive() {
1927        for line in [
1928            "<!--markdownlint-disablefoo -->",
1929            "<!--rumdl-disableMD013-->",
1930            "<!--rumdl-enablebar-->",
1931            "<!--rumdl-captures-->",
1932            "<!--RUMDL-disable MD009 -->",
1933            "<!--Markdownlint-Disable-->",
1934            "<!--note rumdl-disable MD009 -->",
1935            "<!---- rumdl-disable MD009 -->",
1936            "<!--rumdl-disable MD009",
1937            "<!--rumdl-",
1938            "<!--",
1939        ] {
1940            assert!(parse_inline_directives(line).is_empty(), "{line:?}");
1941        }
1942    }
1943
1944    #[test]
1945    fn test_parse_inline_directives_finds_compact_directive_after_other_comment() {
1946        let line = "<!-- other <!--rumdl-disable MD009--> <!--markdownlint-enable MD009-->";
1947        let directives = parse_inline_directives(line);
1948        assert_eq!(directives.len(), 2, "{line:?}");
1949        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1950        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1951    }
1952
1953    #[test]
1954    fn test_scan_configure_file_compact_and_multiline() {
1955        for text in [
1956            "<!--rumdl-configure-file {\"MD013\":false}-->",
1957            "<!--markdownlint-configure-file {\"MD013\":false} -->",
1958            "<!--\trumdl-configure-file\n{\n\"MD013\":false\n}\n-->",
1959            "<!--\nrumdl-configure-file {\"MD013\":false}-->",
1960            "x <!-- other --> <!--rumdl-configure-file {\"MD013\":false}-->",
1961        ] {
1962            let found = scan_configure_file_comments(text);
1963            assert_eq!(found.len(), 1, "{text:?}");
1964            assert_eq!(found[0].1, serde_json::json!({"MD013": false}), "{text:?}");
1965            assert_eq!(&text[found[0].0..found[0].0 + 4], "<!--", "{text:?}");
1966        }
1967        for text in [
1968            "<!--rumdl-configure-files {\"MD013\":false}-->",
1969            "<!--rumdl-configure-file{\"MD013\":false}-->",
1970            "<!--RUMDL-configure-file {\"MD013\":false}-->",
1971            "<!--rumdl-configure-file {\"MD013\":false}",
1972        ] {
1973            assert!(scan_configure_file_comments(text).is_empty(), "{text:?}");
1974        }
1975        // Two comments in a row are both found, the second after the first's end.
1976        let two = "<!--rumdl-configure-file {\"MD013\":false}--><!--rumdl-configure-file {\"MD009\":false}-->";
1977        assert_eq!(scan_configure_file_comments(two).len(), 2);
1978    }
1979
1980    #[test]
1981    fn test_from_content_compact_directives_match_spaced_form() {
1982        // Each pair is (compact, spaced); the resulting state must be identical.
1983        let pairs: &[(&str, &str)] = &[
1984            (
1985                "<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n",
1986                "<!-- rumdl-disable MD013 -->\nA\n<!-- rumdl-enable MD013 -->\nB\n",
1987            ),
1988            (
1989                "A <!--markdownlint-disable-line MD013-->\nB\n",
1990                "A <!-- markdownlint-disable-line MD013 -->\nB\n",
1991            ),
1992            (
1993                "<!--markdownlint-disable-next-line MD013-->\nA\nB\n",
1994                "<!-- markdownlint-disable-next-line MD013 -->\nA\nB\n",
1995            ),
1996            (
1997                "<!--rumdl-disable-file MD013-->\nA\n",
1998                "<!-- rumdl-disable-file MD013 -->\nA\n",
1999            ),
2000            (
2001                "<!--rumdl-disable-file-->\n<!--rumdl-enable-file MD013-->\nA\n",
2002                "<!-- rumdl-disable-file -->\n<!-- rumdl-enable-file MD013 -->\nA\n",
2003            ),
2004            (
2005                "<!--markdownlint-disable MD013-->\n<!--markdownlint-capture-->\n<!--markdownlint-enable-->\nA\n<!--markdownlint-restore-->\nB\n",
2006                "<!-- markdownlint-disable MD013 -->\n<!-- markdownlint-capture -->\n<!-- markdownlint-enable -->\nA\n<!-- markdownlint-restore -->\nB\n",
2007            ),
2008            ("<!--prettier-ignore-->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
2009            ("<!--prettier-ignore -->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
2010            (
2011                "<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n",
2012                "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":10}} -->\nA\n",
2013            ),
2014            (
2015                "<!--markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
2016                "<!-- markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
2017            ),
2018            (
2019                "> <!--rumdl-disable-next-line MD013-->\n> A\n",
2020                "> <!-- rumdl-disable-next-line MD013 -->\n> A\n",
2021            ),
2022            (
2023                "- item\n\n  <!--rumdl-disable-next-line MD013-->\n  A\n",
2024                "- item\n\n  <!-- rumdl-disable-next-line MD013 -->\n  A\n",
2025            ),
2026        ];
2027        for (compact, spaced) in pairs {
2028            let a = InlineConfig::from_content(compact);
2029            let b = InlineConfig::from_content(spaced);
2030            let lines = compact.split('\n').count() + 2;
2031            for line in 1..=lines {
2032                for rule in ["MD013", "MD009"] {
2033                    assert_eq!(
2034                        a.disabling_layer(rule, line),
2035                        b.disabling_layer(rule, line),
2036                        "{compact:?} vs {spaced:?}, {rule} at line {line}"
2037                    );
2038                }
2039            }
2040            assert_eq!(a.get_all_rule_configs(), b.get_all_rule_configs(), "{compact:?}");
2041        }
2042        // Absolute expectations, so a pair that is wrong the same way cannot pass.
2043        let c = InlineConfig::from_content("<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n");
2044        assert!(c.is_rule_disabled("MD013", 2));
2045        assert!(!c.is_rule_disabled("MD013", 4));
2046        let c = InlineConfig::from_content("<!--prettier-ignore-->\nA\nB\n");
2047        assert!(c.is_rule_disabled("MD013", 2));
2048        assert!(!c.is_rule_disabled("MD013", 3));
2049        let c = InlineConfig::from_content("<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n");
2050        assert!(c.get_rule_config("MD013").is_some());
2051    }
2052
2053    #[test]
2054    fn test_from_content_compact_directive_in_code_configures_nothing() {
2055        for content in [
2056            "`<!--rumdl-disable MD013-->`\nA\n",
2057            "```\n<!--rumdl-disable MD013-->\n```\nA\n",
2058            "    <!--rumdl-disable MD013-->\n\nA\n",
2059            "`<!--rumdl-disable-file MD013-->`\nA\n",
2060            "`<!--prettier-ignore-->`\nA\n",
2061            "`<!--rumdl-configure-file {\"MD013\":false}-->`\nA\n",
2062        ] {
2063            let config = InlineConfig::from_content(content);
2064            for line in 1..=6 {
2065                assert!(!config.is_rule_disabled("MD013", line), "{content:?} line {line}");
2066            }
2067            assert!(config.get_all_rule_configs().is_empty(), "{content:?}");
2068        }
2069    }
2070
2071    #[test]
2072    fn test_validate_inline_config_reports_unknown_rule_in_compact_directive() {
2073        let warnings = validate_inline_config_rules("<!--rumdl-disable MD998-->\n", MarkdownFlavor::Standard);
2074        assert_eq!(warnings.len(), 1, "{warnings:?}");
2075        assert_eq!(warnings[0].line_number, 1);
2076    }
2077
2078    // ── Unified parser tests ─────────────────────────────────────────────
2079
2080    #[test]
2081    fn candidate_scan_keeps_nested_and_multiline_directives() {
2082        for source in [
2083            "<!-- rumdl-disable MD009 -->",
2084            "prose <!-- markdownlint-enable-file MD013 -->",
2085            "<!-- rumdl-configure-file {\n\"MD013\":false\n} -->",
2086            "<!-- other <!-- rumdl-disable-next-line MD009 -->",
2087            "<!-- prettier-ignore -->",
2088            "<!--rumdl-disable MD009 -->",
2089            "<!--markdownlint-disable-->",
2090            "<!--\trumdl-enable-->",
2091            "<!--prettier-ignore-->",
2092            "<!-- prettier-ignore-->",
2093            "<!--prettier-ignore -->",
2094            "<!--\nrumdl-configure-file {} -->",
2095        ] {
2096            assert!(has_inline_config_markers(source), "{source:?}");
2097        }
2098        for source in [
2099            "rumdl prose",
2100            "<!-- unrelated --> rumdl",
2101            "<!-- RUMDL-disable MD009 -->",
2102            "<!--RUMDL-disable MD009 -->",
2103            "<!---- rumdl-disable MD009 -->",
2104            "<!--prettier-ignore-start-->",
2105            "<!-- prettier-ignore-end -->",
2106            "<!--x--> rumdl-disable",
2107        ] {
2108            assert!(!has_inline_config_markers(source), "{source:?}");
2109            assert!(validate_inline_config_rules(source, MarkdownFlavor::Standard).is_empty());
2110        }
2111    }
2112
2113    #[test]
2114    fn test_parse_inline_directives_all_kinds() {
2115        // Every directive kind is correctly identified
2116        let cases: &[(&str, DirectiveKind)] = &[
2117            ("<!-- rumdl-disable -->", DirectiveKind::Disable),
2118            ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
2119            ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
2120            ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
2121            ("<!-- rumdl-enable -->", DirectiveKind::Enable),
2122            ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
2123            ("<!-- rumdl-capture -->", DirectiveKind::Capture),
2124            ("<!-- rumdl-restore -->", DirectiveKind::Restore),
2125            ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
2126            // markdownlint variants
2127            ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
2128            ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
2129            (
2130                "<!-- markdownlint-disable-next-line -->",
2131                DirectiveKind::DisableNextLine,
2132            ),
2133            ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
2134            ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
2135            ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
2136        ];
2137        for (input, expected_kind) in cases {
2138            let directives = parse_inline_directives(input);
2139            assert_eq!(
2140                directives.len(),
2141                1,
2142                "Expected 1 directive for {input:?}, got {directives:?}"
2143            );
2144            assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
2145        }
2146    }
2147
2148    #[test]
2149    fn test_parse_inline_directives_disambiguation() {
2150        // The core property: "disable" must NOT match "disable-line" etc.
2151        let line = "<!-- rumdl-disable-line MD001 -->";
2152        let directives = parse_inline_directives(line);
2153        assert_eq!(directives.len(), 1);
2154        assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
2155
2156        let line = "<!-- rumdl-disable-next-line -->";
2157        let directives = parse_inline_directives(line);
2158        assert_eq!(directives.len(), 1);
2159        assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
2160
2161        let line = "<!-- rumdl-disable-file MD001 -->";
2162        let directives = parse_inline_directives(line);
2163        assert_eq!(directives.len(), 1);
2164        assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
2165
2166        let line = "<!-- rumdl-enable-file -->";
2167        let directives = parse_inline_directives(line);
2168        assert_eq!(directives.len(), 1);
2169        assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
2170    }
2171
2172    #[test]
2173    fn test_parse_inline_directives_no_space_before_close() {
2174        // <!-- rumdl-disable--> must parse as Disable (the bug that started this refactor)
2175        let directives = parse_inline_directives("<!-- rumdl-disable-->");
2176        assert_eq!(directives.len(), 1);
2177        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2178        assert!(directives[0].rules.is_empty());
2179
2180        let directives = parse_inline_directives("<!-- rumdl-enable-->");
2181        assert_eq!(directives.len(), 1);
2182        assert_eq!(directives[0].kind, DirectiveKind::Enable);
2183    }
2184
2185    #[test]
2186    fn test_parse_inline_directives_multiple_on_one_line() {
2187        let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
2188        let directives = parse_inline_directives(line);
2189        assert_eq!(directives.len(), 2);
2190        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2191        assert_eq!(directives[0].rules, vec!["MD001"]);
2192        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2193        assert_eq!(directives[1].rules, vec!["MD001"]);
2194    }
2195
2196    #[test]
2197    fn test_parse_inline_directives_global_disable_then_specific_enable() {
2198        let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
2199        let directives = parse_inline_directives(line);
2200        assert_eq!(directives.len(), 2);
2201        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2202        assert!(directives[0].rules.is_empty());
2203        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2204        assert_eq!(directives[1].rules, vec!["MD001"]);
2205    }
2206
2207    #[test]
2208    fn test_parse_inline_directives_word_boundary() {
2209        // "disablefoo" should NOT match "disable"
2210        assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
2211        // "enablebar" should NOT match "enable"
2212        assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
2213        // "captures" should NOT match "capture"
2214        assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
2215    }
2216
2217    #[test]
2218    fn test_parse_inline_directives_no_closing_tag() {
2219        // Missing --> means no directive
2220        assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
2221        assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
2222    }
2223
2224    #[test]
2225    fn test_parse_inline_directives_not_a_comment() {
2226        assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
2227        assert!(parse_inline_directives("Some regular text").is_empty());
2228        assert!(parse_inline_directives("").is_empty());
2229    }
2230
2231    #[test]
2232    fn test_parse_inline_directives_case_sensitive() {
2233        assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
2234        assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
2235    }
2236
2237    #[test]
2238    fn test_parse_inline_directives_rules_extraction() {
2239        let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
2240        assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
2241
2242        // Tabs between rules
2243        let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
2244        assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
2245
2246        // Extra whitespace
2247        let directives = parse_inline_directives("<!-- rumdl-disable   MD001   -->");
2248        assert_eq!(directives[0].rules, vec!["MD001"]);
2249    }
2250
2251    #[test]
2252    fn test_parse_inline_directives_embedded_in_text() {
2253        let line = "Some text <!-- rumdl-disable MD001 --> more text";
2254        let directives = parse_inline_directives(line);
2255        assert_eq!(directives.len(), 1);
2256        assert_eq!(directives[0].rules, vec!["MD001"]);
2257
2258        let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
2259        let directives = parse_inline_directives(line);
2260        assert_eq!(directives.len(), 1);
2261        assert_eq!(directives[0].rules, vec!["MD001"]);
2262    }
2263
2264    #[test]
2265    fn test_parse_inline_directives_mixed_tools_same_line() {
2266        let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
2267        let directives = parse_inline_directives(line);
2268        assert_eq!(directives.len(), 2);
2269        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2270        assert_eq!(directives[0].rules, vec!["MD001"]);
2271        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2272        assert_eq!(directives[1].rules, vec!["MD002"]);
2273    }
2274
2275    // ── Backward-compatible wrapper tests ────────────────────────────────
2276
2277    #[test]
2278    fn test_parse_disable_comment() {
2279        // Global disable
2280        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
2281        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2282
2283        // Specific rules
2284        assert_eq!(
2285            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
2286            Some(vec!["MD001", "MD002"])
2287        );
2288
2289        // No comment
2290        assert_eq!(parse_disable_comment("Some regular text"), None);
2291    }
2292
2293    #[test]
2294    fn test_parse_disable_line_comment() {
2295        // Global disable-line
2296        assert_eq!(
2297            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
2298            Some(vec![])
2299        );
2300
2301        // Specific rules
2302        assert_eq!(
2303            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
2304            Some(vec!["MD013"])
2305        );
2306
2307        // No comment
2308        assert_eq!(parse_disable_line_comment("Some regular text"), None);
2309    }
2310
2311    #[test]
2312    fn test_inline_config_from_content() {
2313        let content = r#"# Test Document
2314
2315<!-- markdownlint-disable MD013 -->
2316This is a very long line that would normally trigger MD013 but it's disabled
2317
2318<!-- markdownlint-enable MD013 -->
2319This line will be checked again
2320
2321<!-- markdownlint-disable-next-line MD001 -->
2322# This heading will not be checked for MD001
2323## But this one will
2324
2325Some text <!-- markdownlint-disable-line MD013 -->
2326
2327<!-- markdownlint-capture -->
2328<!-- markdownlint-disable MD001 MD002 -->
2329# Heading with MD001 disabled
2330<!-- markdownlint-restore -->
2331# Heading with MD001 enabled again
2332"#;
2333
2334        let config = InlineConfig::from_content(content);
2335
2336        // Line 4 should have MD013 disabled (line after disable comment on line 3)
2337        assert!(config.is_rule_disabled("MD013", 4));
2338
2339        // Line 7 should have MD013 enabled (line after enable comment on line 6)
2340        assert!(!config.is_rule_disabled("MD013", 7));
2341
2342        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
2343        assert!(config.is_rule_disabled("MD001", 10));
2344
2345        // Line 11 should not have MD001 disabled
2346        assert!(!config.is_rule_disabled("MD001", 11));
2347
2348        // Line 13 should have MD013 disabled (from disable-line)
2349        assert!(config.is_rule_disabled("MD013", 13));
2350
2351        // After restore (line 18), MD001 should be enabled again on line 19
2352        assert!(!config.is_rule_disabled("MD001", 19));
2353    }
2354
2355    #[test]
2356    fn test_capture_restore() {
2357        let content = r#"<!-- markdownlint-disable MD001 -->
2358<!-- markdownlint-capture -->
2359<!-- markdownlint-disable MD002 MD003 -->
2360<!-- markdownlint-restore -->
2361Some content after restore
2362"#;
2363
2364        let config = InlineConfig::from_content(content);
2365
2366        // After restore (line 4), line 5 should only have MD001 disabled
2367        assert!(config.is_rule_disabled("MD001", 5));
2368        assert!(!config.is_rule_disabled("MD002", 5));
2369        assert!(!config.is_rule_disabled("MD003", 5));
2370    }
2371
2372    #[test]
2373    fn test_validate_inline_config_rules_unknown_rule() {
2374        let content = "<!-- rumdl-disable abc -->\nSome content";
2375        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2376        assert_eq!(warnings.len(), 1);
2377        assert_eq!(warnings[0].line_number, 1);
2378        assert_eq!(warnings[0].rule_name, "abc");
2379        assert_eq!(warnings[0].comment_type, "disable");
2380    }
2381
2382    #[test]
2383    fn test_validate_inline_config_rules_valid_rule() {
2384        let content = "<!-- rumdl-disable MD001 -->\nSome content";
2385        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2386        assert!(
2387            warnings.is_empty(),
2388            "MD001 is a valid rule, should not produce warnings"
2389        );
2390    }
2391
2392    #[test]
2393    fn test_validate_inline_config_rules_alias() {
2394        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
2395        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2396        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
2397    }
2398
2399    #[test]
2400    fn test_validate_inline_config_rules_multiple_unknown() {
2401        let content = r#"<!-- rumdl-disable abc xyz -->
2402<!-- rumdl-disable-line foo -->
2403<!-- markdownlint-disable-next-line bar -->
2404"#;
2405        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2406        assert_eq!(warnings.len(), 4);
2407        assert_eq!(warnings[0].rule_name, "abc");
2408        assert_eq!(warnings[1].rule_name, "xyz");
2409        assert_eq!(warnings[2].rule_name, "foo");
2410        assert_eq!(warnings[3].rule_name, "bar");
2411    }
2412
2413    #[test]
2414    fn test_validate_inline_config_rules_suggestion() {
2415        // "MD00" should suggest "MD001" (or similar)
2416        let content = "<!-- rumdl-disable MD00 -->\n";
2417        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2418        assert_eq!(warnings.len(), 1);
2419        // Should have a suggestion since "MD00" is close to "MD001"
2420        assert!(warnings[0].suggestion.is_some());
2421    }
2422
2423    #[test]
2424    fn test_validate_inline_config_rules_file_comments() {
2425        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
2426        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2427        assert_eq!(warnings.len(), 2);
2428        assert_eq!(warnings[0].comment_type, "disable-file");
2429        assert_eq!(warnings[1].comment_type, "enable-file");
2430    }
2431
2432    #[test]
2433    fn test_validate_inline_config_rules_global_disable() {
2434        // Global disable (no specific rules) should not produce warnings
2435        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
2436        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2437        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
2438    }
2439
2440    #[test]
2441    fn test_validate_inline_config_rules_mixed_valid_invalid() {
2442        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
2443        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
2444        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2445        assert_eq!(warnings.len(), 2);
2446        assert_eq!(warnings[0].rule_name, "abc");
2447        assert_eq!(warnings[1].rule_name, "xyz");
2448    }
2449
2450    #[test]
2451    fn test_validate_inline_config_rules_configure_file() {
2452        // configure-file comments contain rule names as JSON keys
2453        let content =
2454            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
2455        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2456        assert_eq!(warnings.len(), 1);
2457        assert_eq!(warnings[0].rule_name, "nonexistent");
2458        assert_eq!(warnings[0].comment_type, "configure-file");
2459    }
2460
2461    #[test]
2462    fn test_validate_inline_config_rules_markdownlint_variants() {
2463        // Test markdownlint-* variants (not just rumdl-*)
2464        let content = r#"<!-- markdownlint-disable unknown_rule -->
2465<!-- markdownlint-enable another_fake -->
2466<!-- markdownlint-disable-line bad_rule -->
2467<!-- markdownlint-disable-next-line fake_rule -->
2468<!-- markdownlint-disable-file missing_rule -->
2469<!-- markdownlint-enable-file nonexistent -->
2470"#;
2471        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2472        assert_eq!(warnings.len(), 6);
2473        assert_eq!(warnings[0].rule_name, "unknown_rule");
2474        assert_eq!(warnings[1].rule_name, "another_fake");
2475        assert_eq!(warnings[2].rule_name, "bad_rule");
2476        assert_eq!(warnings[3].rule_name, "fake_rule");
2477        assert_eq!(warnings[4].rule_name, "missing_rule");
2478        assert_eq!(warnings[5].rule_name, "nonexistent");
2479    }
2480
2481    /// A directive inside a code block configures nothing, so documenting one in
2482    /// a fenced example must not be reported. The control outside the fence
2483    /// proves the same directive is still validated where it takes effect.
2484    #[test]
2485    fn test_validate_inline_config_rules_ignores_code_blocks() {
2486        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
2487        assert!(
2488            validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
2489            "a directive inside a fence is documentation, not configuration"
2490        );
2491
2492        let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
2493        assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
2494
2495        let indented = "# Doc\n\n    <!-- rumdl-disable made_up_rule -->\n";
2496        assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
2497
2498        let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2499        assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2500    }
2501
2502    /// An indented container body is structure rather than code, so a directive
2503    /// written there does configure the document and its problems are reported.
2504    /// The same lines under a flavor with no admonitions really are an indented
2505    /// code block, which is the control on the exemption above.
2506    #[test]
2507    fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
2508        let admonition = "# Doc\n\n!!! example\n\n    <!-- rumdl-disable made_up_rule -->\n";
2509        assert_eq!(
2510            validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
2511            1,
2512            "an admonition holds its content at an indent that is not code"
2513        );
2514        assert!(
2515            validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
2516            "without admonitions the same lines are an indented code block"
2517        );
2518
2519        let tab = "# Doc\n\n=== \"Tab\"\n\n    <!-- rumdl-disable made_up_rule -->\n";
2520        assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
2521        assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
2522    }
2523
2524    /// An inline enable that can do nothing is reported wherever the directive
2525    /// takes effect, which includes an indented container body.
2526    #[test]
2527    fn test_validate_inline_enables_reports_inside_an_indented_container() {
2528        let admonition = "# Doc\n\n!!! example\n\n    <!-- rumdl-enable MD013 -->\n";
2529        let active = active_set(&["MD009"]);
2530        assert_eq!(
2531            validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
2532                .len(),
2533            1,
2534            "the enable is live in an admonition body, and configuration never enabled MD013"
2535        );
2536        assert!(
2537            validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
2538                .is_empty(),
2539            "without admonitions the same lines are an indented code block"
2540        );
2541    }
2542
2543    /// configure-file is scanned over the whole document, so its code-block
2544    /// exemption is checked separately from the per-line directives.
2545    #[test]
2546    fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
2547        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
2548        assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
2549
2550        let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
2551        assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
2552
2553        let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
2554        assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2555    }
2556
2557    #[test]
2558    fn test_validate_inline_config_rules_markdownlint_configure_file() {
2559        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
2560        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2561        assert_eq!(warnings.len(), 1);
2562        assert_eq!(warnings[0].rule_name, "fake_rule");
2563        assert_eq!(warnings[0].comment_type, "configure-file");
2564    }
2565
2566    #[test]
2567    fn test_get_rule_config_from_configure_file() {
2568        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
2569
2570This is a test line."#;
2571
2572        let inline_config = InlineConfig::from_content(content);
2573        let config_override = inline_config.get_rule_config("MD013");
2574
2575        assert!(config_override.is_some(), "MD013 config should be found");
2576        let json = config_override.unwrap();
2577        assert!(json.is_object(), "Config should be an object");
2578        let obj = json.as_object().unwrap();
2579        assert!(obj.contains_key("line_length"), "Should have line_length key");
2580        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2581    }
2582
2583    #[test]
2584    fn test_get_rule_config_tables_false() {
2585        // Test that tables=false inline config is correctly parsed
2586        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
2587
2588        let inline_config = InlineConfig::from_content(content);
2589        let config_override = inline_config.get_rule_config("MD013");
2590
2591        assert!(config_override.is_some(), "MD013 config should be found");
2592        let json = config_override.unwrap();
2593        let obj = json.as_object().unwrap();
2594        assert!(obj.contains_key("tables"), "Should have tables key");
2595        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
2596    }
2597
2598    // ── multi-line configure-file ────────────────────────────────────────
2599    //
2600    // markdownlint scans configure-file over the whole document rather than
2601    // per line, so the comment may span lines. Every other directive stays
2602    // line-scoped in both tools.
2603
2604    // ── inline enable of a rule that will not run ────────────────────────
2605    //
2606    // rumdl treats rule selection as final: a rule configuration disabled is
2607    // never instantiated, and one per-file-ignores excludes is dropped before
2608    // the file is linted, so an inline enable of either does nothing. These
2609    // warnings make that silent no-op visible.
2610
2611    fn active_set(names: &[&str]) -> HashSet<String> {
2612        names.iter().map(|s| (*s).to_string()).collect()
2613    }
2614
2615    fn no_ignores() -> HashSet<String> {
2616        HashSet::new()
2617    }
2618
2619    #[test]
2620    fn test_inline_enable_of_inactive_rule_warns() {
2621        let active = active_set(&["MD013", "MD022"]);
2622        let content = "<!-- rumdl-enable MD012 -->\n";
2623
2624        let warnings =
2625            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2626
2627        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2628        assert_eq!(warnings[0].rule_name, "MD012");
2629        assert_eq!(warnings[0].comment_type, "enable");
2630        assert_eq!(
2631            warnings[0].problem,
2632            InlineConfigProblem::EnableHasNoEffect {
2633                reason: EnableNoEffectReason::NotEnabled
2634            }
2635        );
2636        assert_eq!(warnings[0].line_number, 1);
2637    }
2638
2639    #[test]
2640    fn test_inline_enable_of_active_rule_does_not_warn() {
2641        // The load-bearing false-positive guard: enabling a rule that IS active
2642        // must stay silent.
2643        let active = active_set(&["MD012", "MD013"]);
2644        let content = "<!-- rumdl-enable MD012 -->\n";
2645
2646        let warnings =
2647            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2648
2649        assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
2650    }
2651
2652    #[test]
2653    fn test_inline_enable_of_per_file_ignored_rule_warns() {
2654        // The rule is enabled in configuration, so nothing about the config
2655        // explains the silence; per-file-ignores is what drops it here.
2656        let active = active_set(&["MD012", "MD013"]);
2657        let ignored = active_set(&["MD012"]);
2658        let content = "<!-- rumdl-enable MD012 -->\n";
2659
2660        let warnings =
2661            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2662
2663        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2664        assert_eq!(warnings[0].rule_name, "MD012");
2665        assert_eq!(
2666            warnings[0].problem,
2667            InlineConfigProblem::EnableHasNoEffect {
2668                reason: EnableNoEffectReason::IgnoredForFile
2669            }
2670        );
2671        assert!(
2672            warnings[0].format_message().contains("per-file-ignores"),
2673            "the message must name the setting in play: {}",
2674            warnings[0].format_message()
2675        );
2676    }
2677
2678    #[test]
2679    fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
2680        // A per-file-ignores entry for a rule configuration never enabled is
2681        // redundant; the setting standing in the way is the config one.
2682        let active = active_set(&["MD013"]);
2683        let ignored = active_set(&["MD012"]);
2684        let content = "<!-- rumdl-enable MD012 -->\n";
2685
2686        let warnings =
2687            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2688
2689        assert_eq!(warnings.len(), 1, "{warnings:?}");
2690        assert_eq!(
2691            warnings[0].problem,
2692            InlineConfigProblem::EnableHasNoEffect {
2693                reason: EnableNoEffectReason::NotEnabled
2694            }
2695        );
2696    }
2697
2698    #[test]
2699    fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
2700        // The negative control for the per-file-ignores arm: an ignore entry
2701        // naming a different rule must leave this enable alone.
2702        let active = active_set(&["MD012", "MD013"]);
2703        let ignored = active_set(&["MD013"]);
2704        let content = "<!-- rumdl-enable MD012 -->\n";
2705
2706        let warnings =
2707            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2708
2709        assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
2710    }
2711
2712    #[test]
2713    fn test_enable_inside_code_block_does_not_warn() {
2714        // A fenced example enables nothing, so neither reason applies to it.
2715        // The same document with the fence removed is the positive control.
2716        let active = active_set(&["MD012", "MD013"]);
2717        let ignored = active_set(&["MD012"]);
2718
2719        for content in [
2720            "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
2721            "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
2722            "# Doc\n\n    <!-- rumdl-enable MD012 -->\n",
2723        ] {
2724            let warnings =
2725                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2726            assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
2727        }
2728
2729        for content in [
2730            "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
2731            "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
2732        ] {
2733            let warnings =
2734                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2735            assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
2736        }
2737    }
2738
2739    #[test]
2740    fn test_bare_enable_all_does_not_warn() {
2741        // `enable` with no rule list means "all"; it targets no specific rule.
2742        let active = active_set(&["MD013"]);
2743        for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
2744            let warnings = validate_inline_enables_against_active_rules(
2745                content,
2746                MarkdownFlavor::Standard,
2747                &active,
2748                &active_set(&["MD013"]),
2749            );
2750            assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
2751        }
2752    }
2753
2754    #[test]
2755    fn test_enable_file_and_alias_of_inactive_rule_warn() {
2756        let active = active_set(&["MD013"]);
2757        // enable-file, plus an alias for an inactive rule, both flagged.
2758        let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
2759
2760        let warnings =
2761            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2762
2763        assert_eq!(warnings.len(), 1, "{warnings:?}");
2764        assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
2765        assert_eq!(warnings[0].comment_type, "enable-file");
2766    }
2767
2768    #[test]
2769    fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
2770        let active = active_set(&["MD013"]);
2771        let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2772        let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2773
2774        let warn_true =
2775            validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
2776        let warn_false =
2777            validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
2778
2779        assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
2780        assert_eq!(warn_true[0].rule_name, "MD012");
2781        assert!(
2782            warn_false.is_empty(),
2783            "configure-file false is a disable, not an ignored enable: {warn_false:?}"
2784        );
2785    }
2786
2787    #[test]
2788    fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
2789        // An unrecognized name is reported by validate_inline_config_rules; it
2790        // must not also be flagged here (it is not a known-but-inactive rule).
2791        let active = active_set(&["MD013"]);
2792        let content = "<!-- rumdl-enable NotARule -->\n";
2793
2794        let warnings =
2795            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2796
2797        assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
2798    }
2799
2800    #[test]
2801    fn test_disable_directive_never_warns_as_no_effect() {
2802        // Only enables are no-ops against a rule that will not run; a disable of
2803        // one is meaningless but harmless and must stay silent.
2804        let active = active_set(&["MD012", "MD013"]);
2805        let ignored = active_set(&["MD012"]);
2806        let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
2807
2808        let warnings =
2809            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2810
2811        assert!(warnings.is_empty(), "disable warned: {warnings:?}");
2812    }
2813
2814    // ── unknown option keys inside configure-file ────────────────────────
2815    //
2816    // A typo'd option key used to be dropped in silence, while the same typo
2817    // in a config file was reported with a suggestion.
2818
2819    #[test]
2820    fn test_unknown_option_key_in_configure_file_warns() {
2821        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2822
2823        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2824
2825        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2826        assert_eq!(warnings[0].rule_name, "MD013");
2827        assert_eq!(
2828            warnings[0].problem,
2829            InlineConfigProblem::UnknownOption {
2830                key: "line_lenght".to_string()
2831            }
2832        );
2833        assert!(
2834            warnings[0].suggestion.is_some(),
2835            "a near-miss key should suggest the real one"
2836        );
2837        assert!(
2838            warnings[0].format_message().contains("Unknown option for rule MD013"),
2839            "message was: {}",
2840            warnings[0].format_message()
2841        );
2842    }
2843
2844    #[test]
2845    fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2846        // The false-positive guard that matters: both spellings are legal, and
2847        // warning on them would be worse than the silence this replaces.
2848        for content in [
2849            r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2850            r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2851        ] {
2852            let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2853            assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2854        }
2855    }
2856
2857    #[test]
2858    fn test_unknown_rule_does_not_also_warn_about_its_options() {
2859        // The rule name is already reported; validating options of a rule that
2860        // does not exist would just be noise.
2861        let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2862
2863        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2864
2865        assert_eq!(
2866            warnings.len(),
2867            1,
2868            "expected only the unknown-rule warning: {warnings:?}"
2869        );
2870        assert_eq!(warnings[0].rule_name, "nonexistent");
2871        assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2872    }
2873
2874    #[test]
2875    fn test_boolean_rule_value_has_no_option_warnings() {
2876        let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2877
2878        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2879
2880        assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2881    }
2882
2883    #[test]
2884    fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2885        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2886
2887        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2888
2889        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2890        assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2891    }
2892
2893    #[test]
2894    fn test_configure_file_spanning_multiple_lines_applies() {
2895        let content = "<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2896
2897        let inline_config = InlineConfig::from_content(content);
2898        let config_override = inline_config.get_rule_config("MD013");
2899
2900        assert!(config_override.is_some(), "a multi-line configure-file must apply");
2901        let obj = config_override.unwrap().as_object().unwrap();
2902        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2903    }
2904
2905    #[test]
2906    fn test_every_configure_file_comment_applies_not_just_the_first() {
2907        // Scanning the whole document must not collapse to the first match:
2908        // both comments configure a different rule and both must land.
2909        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";
2910
2911        let inline_config = InlineConfig::from_content(content);
2912
2913        assert!(
2914            inline_config.get_rule_config("MD013").is_some(),
2915            "first (single-line) configure-file dropped"
2916        );
2917        assert!(
2918            inline_config.get_rule_config("MD007").is_some(),
2919            "second (multi-line) configure-file dropped"
2920        );
2921    }
2922
2923    #[test]
2924    fn test_multiline_configure_file_in_code_block_is_ignored() {
2925        // rumdl ignores inline config inside fences; markdownlint does not.
2926        // Widening to a whole-document scan must not lose that.
2927        let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2928
2929        let inline_config = InlineConfig::from_content(content);
2930
2931        assert!(
2932            inline_config.get_rule_config("MD013").is_none(),
2933            "configure-file inside a fenced code block must not apply"
2934        );
2935    }
2936
2937    #[test]
2938    fn test_multiline_configure_file_bool_and_alias_still_honored() {
2939        // The boolean and alias handling must survive the move off the
2940        // per-line path, in the multi-line form too.
2941        let content = "<!-- markdownlint-configure-file\n{\n  \"no-multiple-blanks\": false,\n  \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2942
2943        let inline_config = InlineConfig::from_content(content);
2944
2945        assert!(
2946            inline_config.is_rule_disabled("MD012", 1),
2947            "boolean alias key must disable the rule"
2948        );
2949        let obj = inline_config
2950            .get_rule_config("MD013")
2951            .expect("alias-keyed config should resolve to MD013")
2952            .as_object()
2953            .unwrap();
2954        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2955    }
2956
2957    #[test]
2958    fn test_multiline_configure_file_warning_reports_start_line() {
2959        // An unknown rule inside a multi-line comment is reported at the line
2960        // the comment opens on, not line 1 and not the closing line.
2961        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2962
2963        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2964
2965        assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2966        assert_eq!(warnings[0].rule_name, "nonexistent");
2967        assert_eq!(warnings[0].comment_type, "configure-file");
2968        assert_eq!(
2969            warnings[0].line_number, 3,
2970            "warning must point at the line the comment starts on"
2971        );
2972    }
2973
2974    #[test]
2975    fn test_configure_file_bool_false_disables_rule() {
2976        // markdownlint documents a boolean as a way to turn a rule off for the
2977        // whole file, e.g. `{ "no-trailing-spaces": false }`.
2978        let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2979
2980        let inline_config = InlineConfig::from_content(content);
2981
2982        assert!(inline_config.is_rule_disabled("MD012", 1));
2983        assert!(
2984            inline_config.get_rule_config("MD012").is_none(),
2985            "a boolean should not be stored as rule options"
2986        );
2987    }
2988
2989    #[test]
2990    fn test_configure_file_bool_false_disables_rule_by_alias() {
2991        let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2992
2993        let inline_config = InlineConfig::from_content(content);
2994
2995        assert!(inline_config.is_rule_disabled("MD012", 1));
2996    }
2997
2998    #[test]
2999    fn test_configure_file_bool_true_leaves_rule_enabled() {
3000        let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
3001
3002        let inline_config = InlineConfig::from_content(content);
3003
3004        assert!(!inline_config.is_rule_disabled("MD012", 1));
3005    }
3006
3007    #[test]
3008    fn test_get_rule_config_from_configure_file_alias_key() {
3009        // A config written with the rule's alias must be reachable by its id.
3010        let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
3011
3012        let inline_config = InlineConfig::from_content(content);
3013        let config_override = inline_config.get_rule_config("MD013");
3014
3015        assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
3016        let obj = config_override.unwrap().as_object().unwrap();
3017        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
3018    }
3019
3020    // ── parse_disable_comment / parse_enable_comment edge cases ──────────
3021
3022    #[test]
3023    fn test_parse_disable_does_not_match_disable_line() {
3024        // parse_disable_comment must NOT match disable-line or disable-next-line
3025        assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
3026        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
3027        assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
3028        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
3029        assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
3030        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
3031    }
3032
3033    #[test]
3034    fn test_parse_enable_does_not_match_enable_file() {
3035        assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
3036        assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
3037    }
3038
3039    #[test]
3040    fn test_parse_disable_comment_edge_cases() {
3041        // No space before closing
3042        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
3043
3044        // Tabs between rules
3045        assert_eq!(
3046            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
3047            Some(vec!["MD001", "MD002"])
3048        );
3049
3050        // Comment not at start of line
3051        assert_eq!(
3052            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
3053            Some(vec!["MD001"])
3054        );
3055
3056        // Malformed: no closing
3057        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
3058
3059        // Malformed: no opening
3060        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
3061
3062        // Case sensitive: uppercase should not match
3063        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
3064
3065        // Empty rule list with whitespace
3066        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
3067
3068        // Duplicate rules preserved (caller may deduplicate)
3069        assert_eq!(
3070            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
3071            Some(vec!["MD001", "MD001", "MD002"])
3072        );
3073
3074        // Unicode around the comment
3075        assert_eq!(
3076            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
3077            Some(vec!["MD001"])
3078        );
3079
3080        // 100 rules
3081        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
3082        let comment = format!("<!-- rumdl-disable {many_rules} -->");
3083        let parsed = parse_disable_comment(&comment);
3084        assert!(parsed.is_some());
3085        assert_eq!(parsed.unwrap().len(), 100);
3086
3087        // Special characters in rule names (forward compat)
3088        assert_eq!(
3089            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
3090            Some(vec!["MD001-test"])
3091        );
3092        assert_eq!(
3093            parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
3094            Some(vec!["custom_rule"])
3095        );
3096    }
3097
3098    #[test]
3099    fn test_parse_enable_comment_edge_cases() {
3100        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
3101        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
3102        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
3103        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
3104    }
3105
3106    // ── InlineConfig: code blocks must be transparent ────────────────────
3107
3108    #[test]
3109    fn test_disable_inside_fenced_code_block_ignored() {
3110        let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
3111        let config = InlineConfig::from_content(content);
3112        // The disable comment is inside a code block — must have no effect
3113        assert!(!config.is_rule_disabled("MD001", 6));
3114    }
3115
3116    #[test]
3117    fn test_disable_inside_tilde_fence_ignored() {
3118        let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
3119        let config = InlineConfig::from_content(content);
3120        assert!(!config.is_rule_disabled("MD001", 6));
3121    }
3122
3123    #[test]
3124    fn test_disable_before_code_block_persists_after() {
3125        // Disable before code block should persist through and after it
3126        let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
3127        let config = InlineConfig::from_content(content);
3128        assert!(config.is_rule_disabled("MD001", 5));
3129    }
3130
3131    #[test]
3132    fn test_enable_inside_code_block_ignored() {
3133        // Disable before, enable inside code block (should be ignored), still disabled after
3134        let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
3135        let config = InlineConfig::from_content(content);
3136        assert!(config.is_rule_disabled("MD001", 5));
3137    }
3138
3139    #[test]
3140    fn test_disable_inside_indented_code_block_ignored() {
3141        // An indented code block's range starts at the indented content rather
3142        // than at the start of the line, so containment of the whole line span
3143        // would miss it and let a code sample disable a rule.
3144        let content = "# Document\n\n    <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
3145        let config = InlineConfig::from_content(content);
3146        assert!(!config.is_rule_disabled("MD001", 5));
3147
3148        // Control: the same comment at column 1 is a directive and applies.
3149        let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
3150        let config = InlineConfig::from_content(unindented);
3151        assert!(config.is_rule_disabled("MD001", 5));
3152    }
3153
3154    #[test]
3155    fn test_configure_file_inside_indented_code_block_ignored() {
3156        // configure-file is scanned over the whole document, so it reaches the
3157        // same conclusion by its own path; both must agree.
3158        let content = "# Document\n\n    <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3159        let config = InlineConfig::from_content(content);
3160        assert!(config.get_rule_config("MD013").is_none());
3161
3162        let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3163        let config = InlineConfig::from_content(unindented);
3164        assert!(config.get_rule_config("MD013").is_some());
3165    }
3166
3167    // ── InlineConfig: directives inside inline code spans ────────────────
3168
3169    #[test]
3170    fn test_disable_inside_code_span_ignored() {
3171        // A code span holds literal text, so a directive written between
3172        // backticks documents one rather than writing one.
3173        let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
3174        let config = InlineConfig::from_content(content);
3175        assert!(!config.is_rule_disabled("MD001", 5));
3176
3177        // Control: the same comment without backticks is a directive and applies.
3178        let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3179        let config = InlineConfig::from_content(bare);
3180        assert!(config.is_rule_disabled("MD001", 5));
3181    }
3182
3183    #[test]
3184    fn test_directive_beside_one_in_a_code_span_still_applies() {
3185        // A span silences the directive it holds, not the line it sits on, so
3186        // filtering is per directive rather than per line.
3187        let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
3188        let config = InlineConfig::from_content(content);
3189        assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
3190        assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
3191    }
3192
3193    #[test]
3194    fn test_disable_file_inside_code_span_ignored() {
3195        let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
3196        let config = InlineConfig::from_content(content);
3197        assert!(!config.is_rule_disabled("MD013", 3));
3198
3199        let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
3200        let config = InlineConfig::from_content(bare);
3201        assert!(config.is_rule_disabled("MD013", 3));
3202    }
3203
3204    #[test]
3205    fn test_configure_file_inside_code_span_ignored() {
3206        // configure-file is scanned over the whole document, so it reaches the
3207        // same conclusion by its own path; both must agree.
3208        let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
3209        let config = InlineConfig::from_content(content);
3210        assert!(config.get_rule_config("MD013").is_none());
3211
3212        let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
3213        let config = InlineConfig::from_content(disabling);
3214        assert!(!config.is_rule_disabled("MD013", 3));
3215    }
3216
3217    #[test]
3218    fn test_disable_inside_multi_line_code_span_ignored() {
3219        // A code span may cross a line break, and what it holds is still text.
3220        let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
3221        let config = InlineConfig::from_content(content);
3222        assert!(!config.is_rule_disabled("MD001", 5));
3223    }
3224
3225    #[test]
3226    fn test_comment_opening_a_line_inside_backticks_still_applies() {
3227        // An HTML comment at the start of a line interrupts the paragraph, so the
3228        // opening backtick never finds a partner and no code span exists. The
3229        // comment is a real one and its directive is live.
3230        let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
3231        let config = InlineConfig::from_content(content);
3232        assert!(config.is_rule_disabled("MD001", 5));
3233    }
3234
3235    #[test]
3236    fn test_disable_inside_double_backtick_code_span_ignored() {
3237        let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
3238        let config = InlineConfig::from_content(content);
3239        assert!(!config.is_rule_disabled("MD001", 3));
3240    }
3241
3242    #[test]
3243    fn test_code_ranges_line_up_on_a_crlf_document() {
3244        // Line offsets are measured over pieces that keep their `\r`, so they
3245        // match the byte ranges the parser reports. Measuring them without it
3246        // drifts a byte per line, and the drift is what lets a directive held
3247        // by a code span read as sitting outside it.
3248        let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
3249        let config = InlineConfig::from_content(fenced);
3250        assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
3251
3252        let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
3253        let config = InlineConfig::from_content(spanned);
3254        assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
3255
3256        // Control: the same document with the directive in prose still applies.
3257        let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
3258        let config = InlineConfig::from_content(bare);
3259        assert!(config.is_rule_disabled("MD001", 5));
3260    }
3261
3262    #[test]
3263    fn test_unmatched_backtick_does_not_open_a_code_span() {
3264        // A lone backtick is literal text, so the directive after it is live.
3265        let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3266        let config = InlineConfig::from_content(content);
3267        assert!(config.is_rule_disabled("MD001", 3));
3268    }
3269
3270    #[test]
3271    fn test_validate_inline_config_rules_ignores_code_spans() {
3272        // An unknown rule name shown in prose names nothing this run would have
3273        // applied, so there is nothing to warn about.
3274        let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
3275        assert!(
3276            validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
3277            "a directive shown in a code span warned"
3278        );
3279
3280        let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
3281        assert_eq!(
3282            validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
3283            1,
3284            "the same name outside a span is still validated"
3285        );
3286    }
3287
3288    #[test]
3289    fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
3290        let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
3291        assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
3292    }
3293
3294    #[test]
3295    fn test_enable_inside_code_span_does_not_warn() {
3296        // A directive shown in prose enables nothing, so neither reason applies.
3297        let active = active_set(&["MD012", "MD013"]);
3298        let ignored = active_set(&["MD012"]);
3299
3300        for content in [
3301            "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
3302            "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
3303        ] {
3304            let warnings =
3305                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
3306            assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
3307        }
3308    }
3309
3310    // ── InlineConfig: mixed comment styles ───────────────────────────────
3311
3312    #[test]
3313    fn test_markdownlint_disable_rumdl_enable_interop() {
3314        let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
3315        let config = InlineConfig::from_content(content);
3316        assert!(config.is_rule_disabled("MD001", 2));
3317        assert!(!config.is_rule_disabled("MD001", 4));
3318    }
3319
3320    #[test]
3321    fn test_rumdl_disable_markdownlint_enable_interop() {
3322        let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
3323        let config = InlineConfig::from_content(content);
3324        assert!(config.is_rule_disabled("MD013", 2));
3325        assert!(!config.is_rule_disabled("MD013", 4));
3326    }
3327
3328    // ── InlineConfig: nested/overlapping disable/enable ──────────────────
3329
3330    #[test]
3331    fn test_global_disable_then_specific_enable() {
3332        let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
3333        let config = InlineConfig::from_content(content);
3334        assert!(!config.is_rule_disabled("MD001", 4));
3335        assert!(config.is_rule_disabled("MD002", 4));
3336        assert!(config.is_rule_disabled("MD013", 4));
3337    }
3338
3339    #[test]
3340    fn test_specific_disable_then_global_enable() {
3341        let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
3342        let config = InlineConfig::from_content(content);
3343        assert!(config.is_rule_disabled("MD001", 2));
3344        assert!(config.is_rule_disabled("MD002", 2));
3345        assert!(!config.is_rule_disabled("MD001", 4));
3346        assert!(!config.is_rule_disabled("MD002", 4));
3347    }
3348
3349    #[test]
3350    fn test_multiple_rules_disable_enable_independently() {
3351        let content = "\
3352Line 1\n\
3353<!-- rumdl-disable MD001 MD002 -->\n\
3354Line 3\n\
3355<!-- rumdl-enable MD001 -->\n\
3356Line 5\n\
3357<!-- rumdl-disable -->\n\
3358Line 7\n\
3359<!-- rumdl-enable MD002 -->\n\
3360Line 9\n";
3361        let config = InlineConfig::from_content(content);
3362
3363        // Line 1: nothing disabled
3364        assert!(!config.is_rule_disabled("MD001", 1));
3365        assert!(!config.is_rule_disabled("MD002", 1));
3366
3367        // Line 3: both disabled
3368        assert!(config.is_rule_disabled("MD001", 3));
3369        assert!(config.is_rule_disabled("MD002", 3));
3370
3371        // Line 5: MD001 enabled, MD002 still disabled
3372        assert!(!config.is_rule_disabled("MD001", 5));
3373        assert!(config.is_rule_disabled("MD002", 5));
3374
3375        // Line 7: all disabled
3376        assert!(config.is_rule_disabled("MD001", 7));
3377        assert!(config.is_rule_disabled("MD002", 7));
3378
3379        // Line 9: MD002 enabled, MD001 still disabled
3380        assert!(config.is_rule_disabled("MD001", 9));
3381        assert!(!config.is_rule_disabled("MD002", 9));
3382    }
3383
3384    // ── InlineConfig: empty/minimal content ──────────────────────────────
3385
3386    #[test]
3387    fn test_empty_content() {
3388        let config = InlineConfig::from_content("");
3389        assert!(!config.is_rule_disabled("MD001", 1));
3390    }
3391
3392    #[test]
3393    fn test_single_disable_comment_only() {
3394        // Persistent disable takes effect from the NEXT line, not the current line.
3395        // For a single-line document, the disable on line 1 takes effect at line 2+.
3396        let config = InlineConfig::from_content("<!-- rumdl-disable -->");
3397        assert!(!config.is_rule_disabled("MD001", 1));
3398        assert!(config.is_rule_disabled("MD001", 2));
3399        assert!(config.is_rule_disabled("MD999", 2));
3400
3401        // With content after the disable, rules are disabled from line 2 onward
3402        let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
3403        assert!(!config.is_rule_disabled("MD001", 1));
3404        assert!(config.is_rule_disabled("MD001", 2));
3405        assert!(config.is_rule_disabled("MD001", 3));
3406    }
3407
3408    #[test]
3409    fn test_no_inline_markers() {
3410        let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
3411        assert!(!config.is_rule_disabled("MD001", 1));
3412        assert!(!config.is_rule_disabled("MD001", 5));
3413    }
3414
3415    // ── InlineConfig: export_for_file_index correctness ──────────────────
3416
3417    #[test]
3418    fn test_export_for_file_index_persistent_transitions() {
3419        let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
3420        let config = InlineConfig::from_content(content);
3421        let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
3422
3423        assert!(file_disabled.is_empty());
3424        // Should have transitions for the disable and enable
3425        assert!(
3426            persistent.len() >= 2,
3427            "Expected at least 2 transitions, got {}",
3428            persistent.len()
3429        );
3430    }
3431
3432    #[test]
3433    fn test_export_for_file_index_disable_file() {
3434        let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
3435        let config = InlineConfig::from_content(content);
3436        let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
3437
3438        assert!(file_disabled.contains("MD001"));
3439    }
3440
3441    #[test]
3442    fn test_export_for_file_index_disable_line() {
3443        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
3444        let config = InlineConfig::from_content(content);
3445        let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
3446
3447        assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
3448        assert!(line_disabled[&2].contains("MD001"));
3449        assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
3450    }
3451
3452    // ── multi-line directive comments ───────────────────────────────────────
3453
3454    fn disabled_lines(content: &str, rule: &str) -> Vec<usize> {
3455        let config = InlineConfig::from_content(content);
3456        let lines = content.split('\n').count();
3457        (1..=lines)
3458            .filter(|&line| config.is_rule_disabled(rule, line))
3459            .collect()
3460    }
3461
3462    #[test]
3463    fn multi_line_disable_takes_effect_after_the_closing_line() {
3464        // The comment ends on line 3, so line 4 is the first disabled one.
3465        let content = "<!--\nrumdl-disable MD013\n-->\nlong\nlong\n";
3466        assert_eq!(disabled_lines(content, "MD013"), [4, 5, 6]);
3467    }
3468
3469    #[test]
3470    fn multi_line_markdownlint_forms_and_rule_lists_split_over_lines() {
3471        let content = "<!--\nmarkdownlint-disable MD013\nMD001\n  MD009\n-->\nx\n";
3472        for rule in ["MD013", "MD001", "MD009"] {
3473            assert_eq!(disabled_lines(content, rule), [6, 7], "{rule}");
3474        }
3475        let all = "<!-- rumdl-disable\n-->\nx\n";
3476        assert_eq!(disabled_lines(all, "MD013"), [3, 4]);
3477    }
3478
3479    #[test]
3480    fn multi_line_enable_restores_and_capture_restore_nest() {
3481        let content = "<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-enable MD013\n-->\nb\n";
3482        assert_eq!(disabled_lines(content, "MD013"), [2, 3, 4, 5]);
3483        let content = "<!--\nrumdl-capture\n-->\n<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-restore\n-->\nb\n";
3484        assert_eq!(disabled_lines(content, "MD013"), [5, 6, 7, 8]);
3485    }
3486
3487    #[test]
3488    fn multi_line_line_and_next_line_forms_use_the_closing_line() {
3489        let content = "<!--\nrumdl-disable-line MD013\n-->\nx\n";
3490        assert_eq!(disabled_lines(content, "MD013"), [3]);
3491        let content = "<!--\nrumdl-disable-next-line MD013\n-->\nx\ny\n";
3492        assert_eq!(disabled_lines(content, "MD013"), [4]);
3493    }
3494
3495    #[test]
3496    fn multi_line_file_forms_apply_to_the_whole_document() {
3497        let content = "x\n<!--\nrumdl-disable-file MD013\n-->\ny\n";
3498        assert_eq!(disabled_lines(content, "MD013"), [1, 2, 3, 4, 5, 6]);
3499    }
3500
3501    #[test]
3502    fn single_line_comment_after_a_multi_line_one_on_its_closing_line_applies_in_order() {
3503        // Disable (multi-line) then enable (single-line) on the closing line: net enabled.
3504        let content = "<!--\nrumdl-disable MD013\n--> <!-- rumdl-enable MD013 -->\nx\n";
3505        assert!(disabled_lines(content, "MD013").is_empty());
3506        // Enable first, then disable: net disabled from the next line.
3507        let content = "<!--\nrumdl-enable MD013\n--> <!-- rumdl-disable MD013 -->\nx\n";
3508        assert_eq!(disabled_lines(content, "MD013"), [4, 5]);
3509    }
3510
3511    #[test]
3512    fn multi_line_directive_inside_a_code_block_or_span_configures_nothing() {
3513        let fenced = "```\n<!--\nrumdl-disable MD013\n-->\n```\nx\n";
3514        assert!(disabled_lines(fenced, "MD013").is_empty());
3515        let span = "`<!--\nrumdl-disable MD013\n-->`\nx\n";
3516        assert!(disabled_lines(span, "MD013").is_empty());
3517    }
3518
3519    #[test]
3520    fn multi_line_comment_with_other_leading_text_is_not_a_directive() {
3521        let content = "<!--\nnote\nrumdl-disable MD013\n-->\nx\n";
3522        assert!(disabled_lines(content, "MD013").is_empty());
3523    }
3524
3525    #[test]
3526    fn single_line_directives_are_not_counted_twice() {
3527        let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-disable-next-line MD001 -->\nx\n";
3528        let config = InlineConfig::from_content(content);
3529        let (_, transitions, line_disabled) = config.export_for_file_index();
3530        // The initial state plus the one disable; a double count would add a third.
3531        assert_eq!(transitions.len(), 2, "{transitions:?}");
3532        assert_eq!(line_disabled.len(), 1, "{line_disabled:?}");
3533    }
3534
3535    #[test]
3536    fn multi_line_disable_site_reports_the_opening_line_and_the_closing_line_scope() {
3537        let content = "x\n<!--\nrumdl-disable-next-line MD013\n-->\ny\n";
3538        let sites = collect_disable_sites(content, &[], &[]);
3539        assert_eq!(sites.len(), 1, "{sites:?}");
3540        assert_eq!(sites[0].line, 2);
3541        assert_eq!(sites[0].kind, "disable-next-line");
3542        assert_eq!(sites[0].scope, DisableScope::Line(5));
3543        assert_eq!(sites[0].span.start, 0);
3544        assert_eq!(sites[0].span.end, 4, "clipped to the opening line");
3545    }
3546
3547    #[test]
3548    fn multi_line_directive_unknown_rule_is_reported_on_the_opening_line() {
3549        let content = "x\n<!--\nrumdl-disable MD9999\n-->\n";
3550        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
3551        assert_eq!(warnings.len(), 1, "{warnings:?}");
3552        assert_eq!(warnings[0].line_number, 2);
3553        assert_eq!(warnings[0].rule_name, "MD9999");
3554    }
3555
3556    // ── comment whitespace ──────────────────────────────────────────────────
3557
3558    #[test]
3559    fn comment_whitespace_is_unicode_whitespace_plus_bom() {
3560        for ch in [
3561            '\u{20}', '\t', '\n', '\u{a0}', '\u{3000}', '\u{85}', '\u{2028}', '\u{feff}',
3562        ] {
3563            assert!(is_comment_space(ch), "{ch:?}");
3564        }
3565        for ch in ['\u{200b}', 'a', '-'] {
3566            assert!(!is_comment_space(ch), "{ch:?}");
3567        }
3568    }
3569
3570    #[test]
3571    fn bom_counts_as_space_in_every_part_of_a_directive() {
3572        let content = "<!--\u{feff}rumdl-disable\u{feff}MD013\u{feff}MD001\u{feff}-->\nx\n";
3573        assert_eq!(disabled_lines(content, "MD013"), [2, 3]);
3574        assert_eq!(disabled_lines(content, "MD001"), [2, 3]);
3575        let zero_width = "<!--\u{200b}rumdl-disable MD013 -->\nx\n";
3576        assert!(disabled_lines(zero_width, "MD013").is_empty());
3577    }
3578
3579    #[test]
3580    fn prettier_ignore_accepts_the_same_whitespace() {
3581        assert!(prettier_ignore_len("prettier-ignore\u{feff}-->").is_some());
3582        assert!(prettier_ignore_len("prettier-ignore\u{200b}-->").is_none());
3583    }
3584
3585    // ── tool comment recogniser ─────────────────────────────────────────────
3586
3587    fn is_tool_comment(text: &str) -> bool {
3588        !config_comment_ranges(text, |_| false).is_empty()
3589    }
3590
3591    #[test]
3592    fn tool_comment_recognises_every_tool_in_any_spelling() {
3593        for text in [
3594            "<!-- rumdl-disable MD013 -->",
3595            "<!--markdownlint-disable-->",
3596            "<!-- vale off -->",
3597            "<!--vale off-->",
3598            "<!--  vale on  -->",
3599            "<!-- vale Style.Rule = NO -->",
3600            "<!-- vale style = X -->",
3601            "<!-- lint disable no-javascript -->",
3602            "<!--lint disable x-->",
3603            "<!--  lint enable x -->",
3604            "<!--\tlint ignore x\t-->",
3605            "<!--\u{feff}lint disable x-->",
3606            "<!--\nlint disable x\n-->",
3607        ] {
3608            assert!(is_tool_comment(text), "{text:?}");
3609        }
3610        for text in [
3611            "<!-- vale offset javascript -->",
3612            "<!-- vale onboarding javascript -->",
3613            "<!-- vale style guide javascript -->",
3614            "<!-- lint disabled x -->",
3615            "<!-- linting disable x -->",
3616            "<!-- lint -->",
3617            "<!-- valet off -->",
3618            "<!-- vale -->",
3619            "<!-- plain comment -->",
3620        ] {
3621            assert!(!is_tool_comment(text), "{text:?}");
3622        }
3623    }
3624
3625    #[test]
3626    fn config_comment_ranges_cover_tool_comments_only() {
3627        let content = "a\n<!--\nlint disable x\n-->\nb\n<!-- vale off -->\n<!--\nplain\n-->\n";
3628        let ranges = config_comment_ranges(content, |_| false);
3629        let texts: Vec<&str> = ranges.iter().map(|range| &content[range.clone()]).collect();
3630        assert_eq!(texts, ["<!--\nlint disable x\n-->", "<!-- vale off -->"]);
3631    }
3632
3633    #[test]
3634    fn config_comment_ranges_stop_at_the_comment_edges() {
3635        let content = "prose <!--vale off--> tail\n";
3636        let ranges = config_comment_ranges(content, |_| false);
3637        assert_eq!(&content[ranges[0].clone()], "<!--vale off-->");
3638    }
3639
3640    #[test]
3641    fn config_comment_ranges_skip_code_openers_and_end_an_unclosed_one_at_its_line() {
3642        let content = "<!-- vale off\nprose\n";
3643        assert_eq!(config_comment_ranges(content, |_| false), vec![0..13]);
3644        assert!(config_comment_ranges(content, |_| true).is_empty());
3645    }
3646
3647    #[test]
3648    fn blockquote_tracker_classifies_each_line_once_in_order() {
3649        let text = "<!-- a --> <!-- b -->\n> q <!-- c --> <!-- d -->\n- > l <!-- e -->\nx > y <!-- f -->\n1 > 0 <!-- g -->\n\n> <!-- h -->";
3650        let mut tracker = BlockquoteTracker::new();
3651        let got: Vec<usize> = text
3652            .match_indices(COMMENT_OPEN)
3653            .map(|(open, _)| tracker.quote_depth_at(text, open))
3654            .collect();
3655        assert_eq!(got, [0, 0, 1, 1, 1, 0, 0, 1]);
3656    }
3657
3658    #[test]
3659    fn quote_prefix_needs_real_container_syntax() {
3660        for (line, depth) in [
3661            ("> x", 1),
3662            (">> x", 2),
3663            ("  > x", 1),
3664            ("- > x", 1),
3665            ("1. > x", 1),
3666            ("10) - > x", 1),
3667            ("> - > x", 2),
3668            (">x", 1),
3669            ("x", 0),
3670            ("1 > 0", 0),
3671            ("a > b", 0),
3672            ("-> x", 0),
3673            ("1.> x", 0),
3674            ("-x > y", 0),
3675            ("- x > y", 0),
3676            ("1234567890. > x", 0),
3677            ("", 0),
3678            ("- x", 0),
3679        ] {
3680            assert_eq!(line_quote_depth(line), depth, "{line:?}");
3681        }
3682    }
3683
3684    #[test]
3685    fn config_comment_ranges_unterminated_openers_linear_complexity() {
3686        // Every opener is an unterminated tool comment: none may search the rest
3687        // of the document for a closer again. Compares the cost of one doubling.
3688        let cost = |count: usize| {
3689            let content = "<!-- vale off\n".repeat(count);
3690            let mut best = std::time::Duration::MAX;
3691            for _ in 0..5 {
3692                let start = std::time::Instant::now();
3693                let ranges = config_comment_ranges(std::hint::black_box(&content), |_| false);
3694                best = best.min(start.elapsed());
3695                assert_eq!(ranges.len(), count);
3696            }
3697            best.as_secs_f64()
3698        };
3699        let (small, large) = (cost(20_000), cost(80_000));
3700        assert!(
3701            large / small < 8.0,
3702            "4x the openers took {:.1}x the time (linear is about 4x, quadratic about 16x)",
3703            large / small
3704        );
3705    }
3706
3707    #[test]
3708    fn offset_in_ranges_matches_a_linear_scan() {
3709        let ranges = vec![2..5, 5..6, 9..12, 20..21];
3710        for offset in 0..25 {
3711            assert_eq!(
3712                offset_in_ranges(&ranges, offset),
3713                ranges.iter().any(|range| range.contains(&offset)),
3714                "offset {offset}"
3715            );
3716        }
3717        assert!(!offset_in_ranges(&[], 0));
3718    }
3719
3720    #[test]
3721    fn a_code_opener_does_not_swallow_the_directive_after_it() {
3722        let content = "```\n<!-- rumdl-disable\n```\n\n<!--\nrumdl-disable MD013\n-->\n";
3723        let kept = scan_multiline_directives(content, |open| open < 20);
3724        assert_eq!(kept.len(), 1);
3725        assert_eq!(kept[0].start_line, 5);
3726    }
3727
3728    #[test]
3729    fn comment_body_skips_blockquote_markers_on_later_lines_only() {
3730        assert_eq!(comment_body("\n> rumdl-disable", 1), "rumdl-disable");
3731        assert_eq!(comment_body("\n>> \t> rumdl-disable", 3), "rumdl-disable");
3732        assert_eq!(
3733            comment_body("> rumdl-disable", 1),
3734            "> rumdl-disable",
3735            "same line: not a marker"
3736        );
3737        assert_eq!(
3738            comment_body("\n> rumdl-disable", 0),
3739            "> rumdl-disable",
3740            "outside a blockquote the `>` is comment text"
3741        );
3742        assert_eq!(
3743            comment_body("\n> > rumdl-disable", 1),
3744            "> rumdl-disable",
3745            "a marker beyond the enclosing depth is text"
3746        );
3747        assert_eq!(comment_body("  \n  ", 1), "");
3748    }
3749
3750    #[test]
3751    fn multi_line_directive_in_a_blockquote_splits_rules_across_marked_lines() {
3752        let content = "> <!-- rumdl-disable MD013\n> MD009\n> -->\n> text\n";
3753        let found = scan_multiline_directives(content, |_| false);
3754        assert_eq!(found.len(), 1);
3755        assert_eq!(found[0].rules, ["MD013", "MD009"]);
3756        assert_eq!((found[0].start_line, found[0].end_line), (1, 3));
3757    }
3758
3759    #[test]
3760    fn an_unclosed_configure_file_example_in_code_does_not_swallow_the_directive_after_it() {
3761        let content = "```\n<!-- rumdl-configure-file\n```\n\n<!--\nrumdl-disable MD013\n-->\ntext\n";
3762        let code_end = content.find("\n\n<!--\n").unwrap();
3763        let found = scan_multiline_directives(content, |open| open < code_end);
3764        assert_eq!(found.len(), 1);
3765        assert_eq!(found[0].rules, ["MD013"]);
3766    }
3767}