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        // Lines measured over `split('\n')`, whose pieces keep any `\r`, so the
422        // offsets below match the code ranges for a CRLF document too.
423
424        // configure-file is scanned over the whole document rather than per
425        // line, because it is the one directive allowed to span lines, and it
426        // applies before any enable/disable directive regardless of where it
427        // sits. Comments inside fenced code blocks are skipped.
428        for (offset, json_config) in scan_configure_file_comments(content) {
429            if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
430                continue;
431            }
432            let Some(obj) = json_config.as_object() else {
433                continue;
434            };
435            for (rule_name, rule_config) in obj {
436                // A boolean turns a rule off or back on, e.g.
437                // `{ "no-trailing-spaces": false }`, so route those to the
438                // disable set instead of storing them as rule options.
439                let normalized = normalize_rule_name(rule_name);
440                if let Some(enabled) = rule_config.as_bool() {
441                    if enabled {
442                        config.file_disabled_rules.remove(&normalized);
443                    } else {
444                        config.file_disabled_rules.insert(normalized);
445                    }
446                    continue;
447                }
448                // Store under the canonical rule id so lookups by `MDxxx` also
449                // find configs written with an alias, e.g.
450                // `{ "line-length": { "line_length": 70 } }`.
451                config.file_rule_config.insert(normalized, rule_config.clone());
452            }
453        }
454
455        // Track current state of disabled rules
456        let mut currently_disabled: HashSet<String> = HashSet::new();
457        let mut currently_enabled: HashSet<String> = HashSet::new();
458        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
459
460        // Track the previously recorded transition state to detect changes
461        let mut prev_disabled: HashSet<String> = HashSet::new();
462        let mut prev_enabled: HashSet<String> = HashSet::new();
463
464        // Record initial state (line 1: nothing disabled)
465        config.transitions.push(StateTransition {
466            line: 1,
467            disabled: HashSet::new(),
468            enabled: HashSet::new(),
469        });
470
471        // Directive comments that span lines take effect on the line holding their
472        // `-->`, ahead of any comment later on that line.
473        let mut multiline_by_end_line: HashMap<usize, Vec<MultilineDirective<'_>>> = HashMap::new();
474        let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
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, 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, 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() && !offset_in_code_span(line_position + offset, code_spans)
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: &'a [(usize, usize)],
981    code_spans: &'a [(usize, usize)],
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,
991            code_spans,
992            line: std::cell::Cell::new(None),
993        }
994    }
995
996    fn contains(&self, open: usize) -> bool {
997        if offset_in_code_block(open, self.code_blocks) || offset_in_code_span(open, self.code_spans) {
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/// Whether a byte offset falls inside one of the given ranges.
1088fn offset_within(offset: usize, ranges: &[(usize, usize)]) -> bool {
1089    ranges.iter().any(|&(start, end)| offset >= start && offset < end)
1090}
1091
1092/// Whether a byte offset falls inside one of the given code block ranges.
1093fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
1094    offset_within(offset, code_blocks)
1095}
1096
1097/// Whether a byte offset falls inside one of the given inline code spans.
1098///
1099/// A code span holds literal text, so a directive written between backticks
1100/// documents a directive rather than writing one. Unlike a code block, a span
1101/// covers part of a line, so the test is made against the directive's own offset
1102/// and a live directive beside one in a span still applies.
1103fn offset_in_code_span(offset: usize, code_spans: &[(usize, usize)]) -> bool {
1104    offset_within(offset, code_spans)
1105}
1106
1107/// The directives on a line that are not inside an inline code span.
1108///
1109/// `line_start` is the line's byte offset in the document, which is what makes a
1110/// directive's span comparable with the document-wide code span ranges.
1111fn directives_outside_code_spans<'a>(
1112    line: &'a str,
1113    line_start: usize,
1114    code_spans: &[(usize, usize)],
1115) -> Vec<InlineDirective<'a>> {
1116    let mut directives = parse_inline_directives(line);
1117    if !code_spans.is_empty() {
1118        directives.retain(|directive| !offset_in_code_span(line_start + directive.span.start, code_spans));
1119    }
1120    directives
1121}
1122
1123/// Whether a directive written on this line sits inside a code block.
1124///
1125/// The line is probed at its first non-whitespace byte, because an indented code
1126/// block's range starts at the indented content rather than at the start of the
1127/// line. Asking whether the whole line span is contained would answer "no" for
1128/// every indented block and let a directive written in a code sample configure
1129/// the document.
1130fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
1131    let probe = line
1132        .find(|c: char| !c.is_whitespace())
1133        .map_or(line_start, |indent| line_start + indent);
1134    offset_in_code_block(probe, code_blocks)
1135}
1136
1137/// The 1-indexed line a byte offset falls on.
1138fn line_of_offset(text: &str, offset: usize) -> usize {
1139    text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
1140}
1141
1142/// A warning together with the byte offset of the directive that raised it.
1143///
1144/// The offset is what lets a warning be tested against inline code spans, which
1145/// a line number cannot answer: a span covers part of a line.
1146type LocatedWarning = (InlineConfigWarning, usize);
1147
1148/// Drop warnings raised by a directive that code covers, and shed the offsets.
1149///
1150/// `InlineConfig` skips directives inside code blocks and inline code spans, so
1151/// a fenced example or a backticked mention documents a directive rather than
1152/// writing one, and there is nothing to report about it. The ranges are the
1153/// flavor's own, so an indented container body a directive does configure keeps
1154/// its warning. They cost a parse of the document, so they are computed only
1155/// once there is a warning to filter.
1156fn drop_warnings_inside_code(
1157    content: &str,
1158    flavor: MarkdownFlavor,
1159    mut warnings: Vec<LocatedWarning>,
1160) -> Vec<InlineConfigWarning> {
1161    if warnings.is_empty() {
1162        return Vec::new();
1163    }
1164    let code = crate::lint_context::code_ranges(content, flavor);
1165    if !code.blocks.is_empty() {
1166        // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a
1167        // CRLF document's offsets match the ranges above. A code block is judged
1168        // by the line, since an indented block's range starts at the indented
1169        // content rather than at the start of the line.
1170        let line_spans: Vec<(usize, &str)> = {
1171            let mut spans = Vec::new();
1172            let mut start = 0;
1173            for line in content.split('\n') {
1174                spans.push((start, line));
1175                start += line.len() + 1;
1176            }
1177            spans
1178        };
1179
1180        warnings.retain(|(warning, _)| {
1181            let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
1182                return true;
1183            };
1184            !line_in_code_block(line_start, line, &code.blocks)
1185        });
1186    }
1187    if !code.spans.is_empty() {
1188        warnings.retain(|&(_, offset)| !offset_in_code_span(offset, &code.spans));
1189    }
1190
1191    warnings.into_iter().map(|(warning, _)| warning).collect()
1192}
1193
1194/// Find every configure-file comment in `text`, returning each JSON payload
1195/// with the byte offset of its opening `<!--`.
1196///
1197/// `text` is normally the whole document: unlike every other directive, a
1198/// configure-file comment may span lines, so its `-->` is searched for without
1199/// regard to line boundaries. The offset lets callers map a payload back to a
1200/// line number or test it against code block ranges.
1201///
1202/// Payloads that are empty or not valid JSON are skipped, and scanning
1203/// continues past them.
1204fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
1205    let mut found = Vec::new();
1206    let mut pos = 0;
1207    let mut quotes = BlockquoteTracker::new();
1208
1209    while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
1210        let comment_start = pos + open_offset;
1211        let after_open = comment_body(
1212            &text[comment_start + COMMENT_OPEN.len()..],
1213            quotes.quote_depth_at(text, comment_start),
1214        );
1215        let body_start = text.len() - after_open.len();
1216        // Advance past this opener by default, so an unrecognized or malformed
1217        // comment cannot stall the scan.
1218        pos = comment_start + COMMENT_OPEN.len();
1219
1220        for tool in TOOL_PREFIXES {
1221            let Some(after_tool) = after_open.strip_prefix(tool) else {
1222                continue;
1223            };
1224            let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
1225                break;
1226            };
1227            // Word boundary: the keyword must be followed by whitespace or `-->`,
1228            // so `configure-files` does not match.
1229            if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
1230                break;
1231            }
1232            let Some(close_offset) = after_kw.find("-->") else {
1233                break;
1234            };
1235
1236            let json_str = after_kw[..close_offset].trim_matches(is_comment_space);
1237            if !json_str.is_empty()
1238                && let Ok(value) = serde_json::from_str(json_str)
1239            {
1240                found.push((comment_start, value));
1241            }
1242            pos = body_start + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
1243            break;
1244        }
1245    }
1246
1247    found
1248}
1249
1250/// Parse a configure-file comment and return the JSON configuration.
1251///
1252/// Returns the first payload found. The text may span lines.
1253pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
1254    scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
1255}
1256
1257// ── Disable-comment inventory ────────────────────────────────────────────────
1258
1259/// The lines an inline disable comment can suppress a finding on.
1260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1261pub enum DisableScope {
1262    /// Exactly this 1-indexed line
1263    Line(usize),
1264    /// The lines below this 1-indexed one, up to the end of the document
1265    Block(usize),
1266    /// Every line of the document
1267    File,
1268}
1269
1270impl DisableScope {
1271    /// Whether this comment is one of those keeping a rule off at `line`, given the
1272    /// layer that answered for it there.
1273    ///
1274    /// Answering for a line credits every comment of that layer reaching it, so no
1275    /// comment loses its credit to another. A comment left uncredited is one whose
1276    /// every line is spoken for by a layer it is not in, and removing it therefore
1277    /// changes nothing the run reports.
1278    ///
1279    /// A block `disable` is treated as reaching the end of the document even when a
1280    /// later `enable` closes it. Crediting a comment for more than it holds can only
1281    /// leave a stale comment unreported.
1282    pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
1283        match (self, layer) {
1284            (Self::Line(target), DisableLayer::Line) => target == line,
1285            // A block disable takes effect on the line after the comment.
1286            (Self::Block(start), DisableLayer::Block) => start < line,
1287            (Self::File, DisableLayer::File) => true,
1288            _ => false,
1289        }
1290    }
1291}
1292
1293/// An inline comment that disables rules, and the lines it can act on.
1294#[derive(Debug, Clone, PartialEq, Eq)]
1295pub struct DisableSite {
1296    /// 1-indexed line the comment opens on
1297    pub line: usize,
1298    /// Byte range of the comment within that line, clipped to the line's end
1299    pub span: std::ops::Range<usize>,
1300    /// The directive as written, e.g. `disable-line`
1301    pub kind: &'static str,
1302    /// The rule names the comment carries, as written; empty means every rule
1303    pub rules: Vec<String>,
1304    /// The lines the comment can suppress a finding on
1305    pub scope: DisableScope,
1306}
1307
1308/// Every inline comment that disables rules, in document order.
1309///
1310/// Comments inside code blocks or inline code spans configure nothing, so they
1311/// are left out, matching what `InlineConfig` applies. `prettier-ignore` belongs
1312/// to another formatter and is left out as well.
1313///
1314/// `code_blocks` and `code_spans` are the ranges the document's flavor really
1315/// holds as code, the same ones `InlineConfig` was built from. Recomputing them
1316/// from the text alone would read an indented container body (a MkDocs
1317/// admonition, a MyST directive) as an indented code block and leave out
1318/// comments that do configure the document.
1319pub fn collect_disable_sites(
1320    content: &str,
1321    code_blocks: &[(usize, usize)],
1322    code_spans: &[(usize, usize)],
1323) -> Vec<DisableSite> {
1324    if !has_inline_config_markers(content) {
1325        return Vec::new();
1326    }
1327
1328    // Lines measured over `split('\n')`, whose pieces keep any `\r`, so a CRLF
1329    // document's offsets match the code block ranges.
1330    let line_spans: Vec<(usize, &str)> = {
1331        let mut spans = Vec::new();
1332        let mut start = 0;
1333        for line in content.split('\n') {
1334            spans.push((start, line));
1335            start += line.len() + 1;
1336        }
1337        spans
1338    };
1339
1340    let mut sites = Vec::new();
1341
1342    for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
1343        if line_in_code_block(line_start, line, code_blocks) {
1344            continue;
1345        }
1346        let line_num = idx + 1;
1347        for directive in directives_outside_code_spans(line, line_start, code_spans) {
1348            let (kind, scope) = match directive.kind {
1349                DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
1350                DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
1351                DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
1352                DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1353                DirectiveKind::Enable
1354                | DirectiveKind::EnableFile
1355                | DirectiveKind::Capture
1356                | DirectiveKind::Restore
1357                | DirectiveKind::ConfigureFile => continue,
1358            };
1359            sites.push(DisableSite {
1360                line: line_num,
1361                span: directive.span,
1362                kind,
1363                rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1364                scope,
1365            });
1366        }
1367    }
1368
1369    // A directive comment spanning lines opens on one line and takes effect where
1370    // it ends. The site is reported on the opening line with its span clipped to it.
1371    let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
1372    for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
1373        let (kind, scope) = match directive.kind {
1374            DirectiveKind::Disable => ("disable", DisableScope::Block(directive.end_line)),
1375            DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(directive.end_line)),
1376            DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(directive.end_line + 1)),
1377            DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1378            _ => continue,
1379        };
1380        let Some(&(line_start, line)) = line_spans.get(directive.start_line - 1) else {
1381            continue;
1382        };
1383        sites.push(DisableSite {
1384            line: directive.start_line,
1385            span: (directive.open - line_start)..(directive.end - line_start).min(line.len()),
1386            kind,
1387            rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1388            scope,
1389        });
1390    }
1391
1392    // A configure-file entry given `false` turns its rule off for the whole file,
1393    // exactly as disable-file does. The comment may span lines, so it is scanned
1394    // over the document and its span is clipped to the line it opens on.
1395    for (offset, json_config) in scan_configure_file_comments(content) {
1396        if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
1397            continue;
1398        }
1399        let Some(obj) = json_config.as_object() else {
1400            continue;
1401        };
1402        let rules: Vec<String> = obj
1403            .iter()
1404            .filter(|(_, value)| value.as_bool() == Some(false))
1405            .map(|(name, _)| name.clone())
1406            .collect();
1407        if rules.is_empty() {
1408            continue;
1409        }
1410        let line_num = line_of_offset(content, offset);
1411        let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
1412            continue;
1413        };
1414        let comment_end = content[offset..]
1415            .find("-->")
1416            .map_or(content.len(), |close| offset + close + 3);
1417        sites.push(DisableSite {
1418            line: line_num,
1419            span: (offset - line_start)..(comment_end - line_start).min(line.len()),
1420            kind: "configure-file",
1421            rules,
1422            scope: DisableScope::File,
1423        });
1424    }
1425
1426    sites.sort_by_key(|site| (site.line, site.span.start));
1427    sites
1428}
1429
1430/// What is wrong with an inline config comment.
1431#[derive(Debug, Clone, PartialEq, Eq)]
1432pub enum InlineConfigProblem {
1433    /// A rule name that is not recognized.
1434    UnknownRule,
1435    /// A recognized rule carrying an unrecognized option key inside a
1436    /// configure-file config object.
1437    UnknownOption { key: String },
1438    /// An inline directive tries to enable a rule that will not run over this
1439    /// document. rumdl treats config-level rule selection as final, so the
1440    /// enable has no effect; this makes that silent no-op visible.
1441    EnableHasNoEffect { reason: EnableNoEffectReason },
1442}
1443
1444/// Why an inline enable cannot take effect.
1445///
1446/// The two are separate settings, so the message names the one actually in play
1447/// rather than sending the reader to a knob their config does not have set.
1448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1449pub enum EnableNoEffectReason {
1450    /// Configuration did not enable the rule for this run at all.
1451    NotEnabled,
1452    /// Configuration enabled the rule, but `per-file-ignores` suppresses it for
1453    /// this particular file.
1454    IgnoredForFile,
1455}
1456
1457/// Warning about an inline config comment.
1458#[derive(Debug, Clone, PartialEq, Eq)]
1459pub struct InlineConfigWarning {
1460    /// The line number where the warning occurred (1-indexed)
1461    pub line_number: usize,
1462    /// The rule the warning concerns
1463    pub rule_name: String,
1464    /// The type of inline config comment (e.g. "disable", "configure-file")
1465    pub comment_type: String,
1466    /// Suggestion for a similar rule name or option key, when one is close
1467    pub suggestion: Option<String>,
1468    /// What is wrong
1469    pub problem: InlineConfigProblem,
1470}
1471
1472impl InlineConfigWarning {
1473    /// Format the warning message
1474    pub fn format_message(&self) -> String {
1475        // Wording for unknown rules/options matches the config-file validator so
1476        // the same mistake reads the same way wherever it is written.
1477        match &self.problem {
1478            InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1479                Some(ref suggestion) => format!(
1480                    "Unknown option for rule {}: {} (did you mean: {}?)",
1481                    self.rule_name, key, suggestion
1482                ),
1483                None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1484            },
1485            InlineConfigProblem::UnknownRule => match self.suggestion {
1486                Some(ref suggestion) => format!(
1487                    "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1488                    self.comment_type, self.rule_name, suggestion
1489                ),
1490                None => format!(
1491                    "Unknown rule in inline {} comment: {}",
1492                    self.comment_type, self.rule_name
1493                ),
1494            },
1495            InlineConfigProblem::EnableHasNoEffect {
1496                reason: EnableNoEffectReason::NotEnabled,
1497            } => format!(
1498                "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1499                self.rule_name, self.comment_type
1500            ),
1501            InlineConfigProblem::EnableHasNoEffect {
1502                reason: EnableNoEffectReason::IgnoredForFile,
1503            } => format!(
1504                "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1505                self.rule_name, self.comment_type
1506            ),
1507        }
1508    }
1509
1510    /// Print the warning to stderr with file context
1511    pub fn print_warning(&self, file_path: &str) {
1512        eprintln!(
1513            "{} {}:{}: {}",
1514            crate::utils::warning_label("inline config warning"),
1515            file_path,
1516            self.line_number,
1517            self.format_message()
1518        );
1519    }
1520
1521    /// The warning as a lint diagnostic anchored to the comment that raised it.
1522    ///
1523    /// A directive naming a rule that does not exist silently does nothing, and on
1524    /// the CLI that is a line of stderr next to the findings. Editors show
1525    /// diagnostics, so the language server reports it as one and the mistake is
1526    /// visible where it was written. The span covers the whole line: the validator
1527    /// locates the comment, not the rule name inside it.
1528    pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1529        let line_width = content
1530            .lines()
1531            .nth(self.line_number.saturating_sub(1))
1532            .map_or(0, |line| line.chars().count());
1533        crate::rule::LintWarning {
1534            message: self.format_message(),
1535            line: self.line_number,
1536            column: 1,
1537            end_line: self.line_number,
1538            end_column: line_width + 1,
1539            severity: crate::rule::Severity::Warning,
1540            fix: None,
1541            rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1542        }
1543    }
1544}
1545
1546/// The diagnostic code inline config warnings carry.
1547///
1548/// Not a rule name: it names the class of problem so an editor can tell these
1549/// apart from rule violations, and so nothing looks it up in the rule registry.
1550pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1551
1552/// Validate all inline config comments in content and return warnings for unknown rules.
1553///
1554/// This function extracts rule names from all types of inline config comments
1555/// (disable, enable, disable-line, disable-next-line, disable-file, enable-file)
1556/// and validates them against the known rule alias map. Comments inside code
1557/// blocks or inline code spans are documentation rather than configuration, so
1558/// they are left alone, matching what `InlineConfig` applies. `flavor` is the
1559/// document's own, which decides what its indentation means.
1560pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1561    let (warnings, _) = collect_inline_config_warnings(content, flavor, true, None);
1562    drop_warnings_inside_code(content, flavor, warnings)
1563}
1564
1565/// Warn when an inline enable names a valid rule excluded by the active rule
1566/// set or by per-file ignores. Unknown names are reported by
1567/// [`validate_inline_config_rules`]. Bare enables target no specific rule, and
1568/// directives in code configure nothing and produce no warning.
1569pub fn validate_inline_enables_against_active_rules(
1570    content: &str,
1571    flavor: MarkdownFlavor,
1572    active_rules: &HashSet<String>,
1573    ignored_for_file: &HashSet<String>,
1574) -> Vec<InlineConfigWarning> {
1575    let (_, warnings) = collect_inline_config_warnings(content, flavor, false, Some((active_rules, ignored_for_file)));
1576    drop_warnings_inside_code(content, flavor, warnings)
1577}
1578
1579/// Validate names, options, and ineffective enables in one source scan and one
1580/// flavor-aware code reading. Name/option warnings precede enable warnings,
1581/// matching the separate validators' concatenated reports.
1582pub fn validate_inline_configuration(
1583    content: &str,
1584    flavor: MarkdownFlavor,
1585    active_rules: &HashSet<String>,
1586    ignored_for_file: &HashSet<String>,
1587) -> Vec<InlineConfigWarning> {
1588    let (mut warnings, enables) =
1589        collect_inline_config_warnings(content, flavor, true, Some((active_rules, ignored_for_file)));
1590    warnings.extend(enables);
1591    drop_warnings_inside_code(content, flavor, warnings)
1592}
1593
1594/// Keep the original offsets until all warnings can share code filtering.
1595/// Each directive and multiline JSON payload is parsed only once.
1596fn collect_inline_config_warnings(
1597    content: &str,
1598    flavor: MarkdownFlavor,
1599    validate_names: bool,
1600    active: Option<(&HashSet<String>, &HashSet<String>)>,
1601) -> (Vec<LocatedWarning>, Vec<LocatedWarning>) {
1602    use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1603
1604    if !has_inline_config_markers(content) {
1605        return (Vec::new(), Vec::new());
1606    }
1607    let mut warnings = Vec::new();
1608    let mut enables = Vec::new();
1609    // Most directives name valid rules and need no spelling suggestions.
1610    let all_rule_names = std::cell::OnceCell::<Vec<String>>::new();
1611    let suggest = |name: &str| {
1612        let names =
1613            all_rule_names.get_or_init(|| RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect());
1614        suggest_similar_key(name, names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1615    };
1616    let mut flag_enable = |name: &str, comment_type: &str, line: usize, offset: usize| {
1617        let Some((active_rules, ignored_for_file)) = active else {
1618            return;
1619        };
1620        if !is_valid_rule_name(name) {
1621            return;
1622        }
1623        let canonical = normalize_rule_name(name);
1624        let reason = if !active_rules.contains(&canonical) {
1625            EnableNoEffectReason::NotEnabled
1626        } else if ignored_for_file.contains(&canonical) {
1627            EnableNoEffectReason::IgnoredForFile
1628        } else {
1629            return;
1630        };
1631        enables.push((
1632            InlineConfigWarning {
1633                line_number: line,
1634                rule_name: canonical,
1635                comment_type: comment_type.to_string(),
1636                suggestion: None,
1637                problem: InlineConfigProblem::EnableHasNoEffect { reason },
1638            },
1639            offset,
1640        ));
1641    };
1642
1643    for (offset, json_config) in scan_configure_file_comments(content) {
1644        let Some(obj) = json_config.as_object() else {
1645            continue;
1646        };
1647        let line_number = line_of_offset(content, offset);
1648        for (rule_name, rule_config) in obj {
1649            if validate_names {
1650                if !is_valid_rule_name(rule_name) {
1651                    warnings.push((
1652                        InlineConfigWarning {
1653                            line_number,
1654                            rule_name: rule_name.clone(),
1655                            comment_type: "configure-file".to_string(),
1656                            suggestion: suggest(rule_name),
1657                            problem: InlineConfigProblem::UnknownRule,
1658                        },
1659                        offset,
1660                    ));
1661                    continue;
1662                }
1663                if let Some(options) = rule_config.as_object() {
1664                    let canonical = normalize_rule_name(rule_name);
1665                    // Do not initialize the schema registry for enable/disable
1666                    // comments or boolean configure-file entries.
1667                    if let Some(valid_keys) = crate::config::default_registry().config_keys_for(&canonical) {
1668                        let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1669                        for key in options.keys().filter(|key| !valid_keys.contains(*key)) {
1670                            warnings.push((
1671                                InlineConfigWarning {
1672                                    line_number,
1673                                    rule_name: canonical.clone(),
1674                                    comment_type: "configure-file".to_string(),
1675                                    suggestion: suggest_similar_key(key, &valid_keys_vec),
1676                                    problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1677                                },
1678                                offset,
1679                            ));
1680                        }
1681                    }
1682                }
1683            }
1684            if rule_config.as_bool() == Some(true) {
1685                flag_enable(rule_name, "configure-file", line_number, offset);
1686            }
1687        }
1688    }
1689
1690    // Every directive that names rules, as (kind, rules, reported line, offset).
1691    // split keeps CR bytes, so document offsets match the code ranges for both
1692    // LF and CRLF input. configure-file is handled document-wide above, and a
1693    // comment spanning lines is reported on the line it opens on.
1694    let mut named: Vec<(DirectiveKind, Vec<&str>, usize, usize)> = Vec::new();
1695    let mut line_start = 0;
1696    for (idx, line) in content.split('\n').enumerate() {
1697        for directive in parse_inline_directives(line) {
1698            named.push((
1699                directive.kind,
1700                directive.rules,
1701                idx + 1,
1702                line_start + directive.span.start,
1703            ));
1704        }
1705        line_start += line.len() + 1;
1706    }
1707    // The document's code ranges cost a parse, so they are read only once a
1708    // directive spanning lines needs them.
1709    let code = std::cell::OnceCell::new();
1710    let code_test = std::cell::OnceCell::new();
1711    let multiline_in_code = |open: usize| {
1712        code_test
1713            .get_or_init(|| {
1714                let ranges = code.get_or_init(|| crate::lint_context::code_ranges(content, flavor));
1715                CommentCodeTest::new(content, &ranges.blocks, &ranges.spans)
1716            })
1717            .contains(open)
1718    };
1719    for directive in scan_multiline_directives(content, multiline_in_code) {
1720        named.push((directive.kind, directive.rules, directive.start_line, directive.open));
1721    }
1722    for (kind, rules, line_number, offset) in named {
1723        let Some(comment_type) = directive_comment_type(kind) else {
1724            continue;
1725        };
1726        for rule_name in rules {
1727            if validate_names && !is_valid_rule_name(rule_name) {
1728                warnings.push((
1729                    InlineConfigWarning {
1730                        line_number,
1731                        rule_name: rule_name.to_string(),
1732                        comment_type: comment_type.to_string(),
1733                        suggestion: suggest(rule_name),
1734                        problem: InlineConfigProblem::UnknownRule,
1735                    },
1736                    offset,
1737                ));
1738            }
1739            if matches!(kind, DirectiveKind::Enable | DirectiveKind::EnableFile) {
1740                flag_enable(rule_name, comment_type, line_number, offset);
1741            }
1742        }
1743    }
1744    warnings.sort_by_key(|(warning, _)| warning.line_number);
1745    enables.sort_by_key(|(warning, _)| warning.line_number);
1746    (warnings, enables)
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751    use super::*;
1752
1753    #[test]
1754    fn combined_validation_preserves_warning_order_and_literal_code() {
1755        let source = concat!(
1756            "<!-- rumdl-enable MD009 -->\n",
1757            "<!-- rumdl-disable MD999 -->\n",
1758            "<!-- rumdl-configure-file {\"MD011\":true,\"MD013\":{\"line-lenght\":40}} -->\n",
1759            "<!-- rumdl-enable MD013 -->\n",
1760            "```markdown\n",
1761            "<!-- rumdl-enable MD010 -->\n",
1762            "<!-- rumdl-disable MD998 -->\n",
1763            "```\n",
1764            "`<!-- rumdl-enable MD012 -->`\n",
1765        );
1766        let active = HashSet::from(["MD013".to_string()]);
1767        let ignored = active.clone();
1768        for ending in ["\n", "\r\n"] {
1769            let content = source.replace('\n', ending);
1770            for flavor in [
1771                MarkdownFlavor::Standard,
1772                MarkdownFlavor::MkDocs,
1773                MarkdownFlavor::MDX,
1774                MarkdownFlavor::Pandoc,
1775                MarkdownFlavor::Quarto,
1776                MarkdownFlavor::Obsidian,
1777                MarkdownFlavor::Kramdown,
1778                MarkdownFlavor::AzureDevOps,
1779                MarkdownFlavor::MyST,
1780                MarkdownFlavor::Hugo,
1781                MarkdownFlavor::MDG,
1782                MarkdownFlavor::GhAw,
1783            ] {
1784                let mut separate = validate_inline_config_rules(&content, flavor);
1785                separate.extend(validate_inline_enables_against_active_rules(
1786                    &content, flavor, &active, &ignored,
1787                ));
1788                let combined = validate_inline_configuration(&content, flavor, &active, &ignored);
1789                assert_eq!(format!("{combined:?}"), format!("{separate:?}"), "{flavor:?}");
1790                assert_eq!(
1791                    combined
1792                        .iter()
1793                        .map(|warning| (warning.rule_name.as_str(), warning.line_number))
1794                        .collect::<Vec<_>>(),
1795                    vec![("MD999", 2), ("MD013", 3), ("MD009", 1), ("MD011", 3), ("MD013", 4)],
1796                    "{flavor:?}",
1797                );
1798            }
1799        }
1800    }
1801
1802    // ── Whitespace around the comment delimiters is optional ─────────────
1803
1804    const COMPACT_OPENERS: &[&str] = &["<!--", "<!-- ", "<!--  ", "<!--\t", "<!-- \t "];
1805    const COMPACT_CLOSERS: &[&str] = &["-->", " -->", "  -->", "\t-->"];
1806
1807    #[test]
1808    fn test_parse_inline_directives_whitespace_around_delimiters_is_optional() {
1809        let cases: &[(&str, DirectiveKind, &[&str])] = &[
1810            ("rumdl-disable MD013", DirectiveKind::Disable, &["MD013"]),
1811            (
1812                "markdownlint-disable MD013 MD009",
1813                DirectiveKind::Disable,
1814                &["MD013", "MD009"],
1815            ),
1816            ("markdownlint-disable", DirectiveKind::Disable, &[]),
1817            ("rumdl-enable", DirectiveKind::Enable, &[]),
1818            ("markdownlint-enable MD013", DirectiveKind::Enable, &["MD013"]),
1819            ("rumdl-disable-line MD013", DirectiveKind::DisableLine, &["MD013"]),
1820            (
1821                "markdownlint-disable-next-line MD013",
1822                DirectiveKind::DisableNextLine,
1823                &["MD013"],
1824            ),
1825            (
1826                "markdownlint-disable-file MD013",
1827                DirectiveKind::DisableFile,
1828                &["MD013"],
1829            ),
1830            ("rumdl-enable-file", DirectiveKind::EnableFile, &[]),
1831            ("markdownlint-capture", DirectiveKind::Capture, &[]),
1832            ("rumdl-restore", DirectiveKind::Restore, &[]),
1833            ("markdownlint-configure-file {}", DirectiveKind::ConfigureFile, &["{}"]),
1834        ];
1835        for &(body, kind, rules) in cases {
1836            for open in COMPACT_OPENERS {
1837                for close in COMPACT_CLOSERS {
1838                    let comment = format!("{open}{body}{close}");
1839                    // Embedded in a line, so the span offsets are exercised too.
1840                    let line = format!("text {comment} tail");
1841                    let directives = parse_inline_directives(&line);
1842                    assert_eq!(directives.len(), 1, "{line:?}");
1843                    assert_eq!(directives[0].kind, kind, "{line:?}");
1844                    assert_eq!(directives[0].rules, rules, "{line:?}");
1845                    assert_eq!(&line[directives[0].span.clone()], comment, "{line:?}");
1846                }
1847            }
1848        }
1849    }
1850
1851    #[test]
1852    fn test_parse_inline_directives_compact_not_a_directive() {
1853        for line in [
1854            "<!--markdownlint-disablefoo -->",
1855            "<!--rumdl-disableMD013-->",
1856            "<!--rumdl-enablebar-->",
1857            "<!--rumdl-captures-->",
1858            "<!--RUMDL-disable MD009 -->",
1859            "<!--Markdownlint-Disable-->",
1860            "<!--note rumdl-disable MD009 -->",
1861            "<!---- rumdl-disable MD009 -->",
1862            "<!--rumdl-disable MD009",
1863            "<!--rumdl-",
1864            "<!--",
1865        ] {
1866            assert!(parse_inline_directives(line).is_empty(), "{line:?}");
1867        }
1868    }
1869
1870    #[test]
1871    fn test_parse_inline_directives_finds_compact_directive_after_other_comment() {
1872        let line = "<!-- other <!--rumdl-disable MD009--> <!--markdownlint-enable MD009-->";
1873        let directives = parse_inline_directives(line);
1874        assert_eq!(directives.len(), 2, "{line:?}");
1875        assert_eq!(directives[0].kind, DirectiveKind::Disable);
1876        assert_eq!(directives[1].kind, DirectiveKind::Enable);
1877    }
1878
1879    #[test]
1880    fn test_scan_configure_file_compact_and_multiline() {
1881        for text in [
1882            "<!--rumdl-configure-file {\"MD013\":false}-->",
1883            "<!--markdownlint-configure-file {\"MD013\":false} -->",
1884            "<!--\trumdl-configure-file\n{\n\"MD013\":false\n}\n-->",
1885            "<!--\nrumdl-configure-file {\"MD013\":false}-->",
1886            "x <!-- other --> <!--rumdl-configure-file {\"MD013\":false}-->",
1887        ] {
1888            let found = scan_configure_file_comments(text);
1889            assert_eq!(found.len(), 1, "{text:?}");
1890            assert_eq!(found[0].1, serde_json::json!({"MD013": false}), "{text:?}");
1891            assert_eq!(&text[found[0].0..found[0].0 + 4], "<!--", "{text:?}");
1892        }
1893        for text in [
1894            "<!--rumdl-configure-files {\"MD013\":false}-->",
1895            "<!--rumdl-configure-file{\"MD013\":false}-->",
1896            "<!--RUMDL-configure-file {\"MD013\":false}-->",
1897            "<!--rumdl-configure-file {\"MD013\":false}",
1898        ] {
1899            assert!(scan_configure_file_comments(text).is_empty(), "{text:?}");
1900        }
1901        // Two comments in a row are both found, the second after the first's end.
1902        let two = "<!--rumdl-configure-file {\"MD013\":false}--><!--rumdl-configure-file {\"MD009\":false}-->";
1903        assert_eq!(scan_configure_file_comments(two).len(), 2);
1904    }
1905
1906    #[test]
1907    fn test_from_content_compact_directives_match_spaced_form() {
1908        // Each pair is (compact, spaced); the resulting state must be identical.
1909        let pairs: &[(&str, &str)] = &[
1910            (
1911                "<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n",
1912                "<!-- rumdl-disable MD013 -->\nA\n<!-- rumdl-enable MD013 -->\nB\n",
1913            ),
1914            (
1915                "A <!--markdownlint-disable-line MD013-->\nB\n",
1916                "A <!-- markdownlint-disable-line MD013 -->\nB\n",
1917            ),
1918            (
1919                "<!--markdownlint-disable-next-line MD013-->\nA\nB\n",
1920                "<!-- markdownlint-disable-next-line MD013 -->\nA\nB\n",
1921            ),
1922            (
1923                "<!--rumdl-disable-file MD013-->\nA\n",
1924                "<!-- rumdl-disable-file MD013 -->\nA\n",
1925            ),
1926            (
1927                "<!--rumdl-disable-file-->\n<!--rumdl-enable-file MD013-->\nA\n",
1928                "<!-- rumdl-disable-file -->\n<!-- rumdl-enable-file MD013 -->\nA\n",
1929            ),
1930            (
1931                "<!--markdownlint-disable MD013-->\n<!--markdownlint-capture-->\n<!--markdownlint-enable-->\nA\n<!--markdownlint-restore-->\nB\n",
1932                "<!-- markdownlint-disable MD013 -->\n<!-- markdownlint-capture -->\n<!-- markdownlint-enable -->\nA\n<!-- markdownlint-restore -->\nB\n",
1933            ),
1934            ("<!--prettier-ignore-->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
1935            ("<!--prettier-ignore -->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
1936            (
1937                "<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n",
1938                "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":10}} -->\nA\n",
1939            ),
1940            (
1941                "<!--markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
1942                "<!-- markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
1943            ),
1944            (
1945                "> <!--rumdl-disable-next-line MD013-->\n> A\n",
1946                "> <!-- rumdl-disable-next-line MD013 -->\n> A\n",
1947            ),
1948            (
1949                "- item\n\n  <!--rumdl-disable-next-line MD013-->\n  A\n",
1950                "- item\n\n  <!-- rumdl-disable-next-line MD013 -->\n  A\n",
1951            ),
1952        ];
1953        for (compact, spaced) in pairs {
1954            let a = InlineConfig::from_content(compact);
1955            let b = InlineConfig::from_content(spaced);
1956            let lines = compact.split('\n').count() + 2;
1957            for line in 1..=lines {
1958                for rule in ["MD013", "MD009"] {
1959                    assert_eq!(
1960                        a.disabling_layer(rule, line),
1961                        b.disabling_layer(rule, line),
1962                        "{compact:?} vs {spaced:?}, {rule} at line {line}"
1963                    );
1964                }
1965            }
1966            assert_eq!(a.get_all_rule_configs(), b.get_all_rule_configs(), "{compact:?}");
1967        }
1968        // Absolute expectations, so a pair that is wrong the same way cannot pass.
1969        let c = InlineConfig::from_content("<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n");
1970        assert!(c.is_rule_disabled("MD013", 2));
1971        assert!(!c.is_rule_disabled("MD013", 4));
1972        let c = InlineConfig::from_content("<!--prettier-ignore-->\nA\nB\n");
1973        assert!(c.is_rule_disabled("MD013", 2));
1974        assert!(!c.is_rule_disabled("MD013", 3));
1975        let c = InlineConfig::from_content("<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n");
1976        assert!(c.get_rule_config("MD013").is_some());
1977    }
1978
1979    #[test]
1980    fn test_from_content_compact_directive_in_code_configures_nothing() {
1981        for content in [
1982            "`<!--rumdl-disable MD013-->`\nA\n",
1983            "```\n<!--rumdl-disable MD013-->\n```\nA\n",
1984            "    <!--rumdl-disable MD013-->\n\nA\n",
1985            "`<!--rumdl-disable-file MD013-->`\nA\n",
1986            "`<!--prettier-ignore-->`\nA\n",
1987            "`<!--rumdl-configure-file {\"MD013\":false}-->`\nA\n",
1988        ] {
1989            let config = InlineConfig::from_content(content);
1990            for line in 1..=6 {
1991                assert!(!config.is_rule_disabled("MD013", line), "{content:?} line {line}");
1992            }
1993            assert!(config.get_all_rule_configs().is_empty(), "{content:?}");
1994        }
1995    }
1996
1997    #[test]
1998    fn test_validate_inline_config_reports_unknown_rule_in_compact_directive() {
1999        let warnings = validate_inline_config_rules("<!--rumdl-disable MD998-->\n", MarkdownFlavor::Standard);
2000        assert_eq!(warnings.len(), 1, "{warnings:?}");
2001        assert_eq!(warnings[0].line_number, 1);
2002    }
2003
2004    // ── Unified parser tests ─────────────────────────────────────────────
2005
2006    #[test]
2007    fn candidate_scan_keeps_nested_and_multiline_directives() {
2008        for source in [
2009            "<!-- rumdl-disable MD009 -->",
2010            "prose <!-- markdownlint-enable-file MD013 -->",
2011            "<!-- rumdl-configure-file {\n\"MD013\":false\n} -->",
2012            "<!-- other <!-- rumdl-disable-next-line MD009 -->",
2013            "<!-- prettier-ignore -->",
2014            "<!--rumdl-disable MD009 -->",
2015            "<!--markdownlint-disable-->",
2016            "<!--\trumdl-enable-->",
2017            "<!--prettier-ignore-->",
2018            "<!-- prettier-ignore-->",
2019            "<!--prettier-ignore -->",
2020            "<!--\nrumdl-configure-file {} -->",
2021        ] {
2022            assert!(has_inline_config_markers(source), "{source:?}");
2023        }
2024        for source in [
2025            "rumdl prose",
2026            "<!-- unrelated --> rumdl",
2027            "<!-- RUMDL-disable MD009 -->",
2028            "<!--RUMDL-disable MD009 -->",
2029            "<!---- rumdl-disable MD009 -->",
2030            "<!--prettier-ignore-start-->",
2031            "<!-- prettier-ignore-end -->",
2032            "<!--x--> rumdl-disable",
2033        ] {
2034            assert!(!has_inline_config_markers(source), "{source:?}");
2035            assert!(validate_inline_config_rules(source, MarkdownFlavor::Standard).is_empty());
2036        }
2037    }
2038
2039    #[test]
2040    fn test_parse_inline_directives_all_kinds() {
2041        // Every directive kind is correctly identified
2042        let cases: &[(&str, DirectiveKind)] = &[
2043            ("<!-- rumdl-disable -->", DirectiveKind::Disable),
2044            ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
2045            ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
2046            ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
2047            ("<!-- rumdl-enable -->", DirectiveKind::Enable),
2048            ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
2049            ("<!-- rumdl-capture -->", DirectiveKind::Capture),
2050            ("<!-- rumdl-restore -->", DirectiveKind::Restore),
2051            ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
2052            // markdownlint variants
2053            ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
2054            ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
2055            (
2056                "<!-- markdownlint-disable-next-line -->",
2057                DirectiveKind::DisableNextLine,
2058            ),
2059            ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
2060            ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
2061            ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
2062        ];
2063        for (input, expected_kind) in cases {
2064            let directives = parse_inline_directives(input);
2065            assert_eq!(
2066                directives.len(),
2067                1,
2068                "Expected 1 directive for {input:?}, got {directives:?}"
2069            );
2070            assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
2071        }
2072    }
2073
2074    #[test]
2075    fn test_parse_inline_directives_disambiguation() {
2076        // The core property: "disable" must NOT match "disable-line" etc.
2077        let line = "<!-- rumdl-disable-line MD001 -->";
2078        let directives = parse_inline_directives(line);
2079        assert_eq!(directives.len(), 1);
2080        assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
2081
2082        let line = "<!-- rumdl-disable-next-line -->";
2083        let directives = parse_inline_directives(line);
2084        assert_eq!(directives.len(), 1);
2085        assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
2086
2087        let line = "<!-- rumdl-disable-file MD001 -->";
2088        let directives = parse_inline_directives(line);
2089        assert_eq!(directives.len(), 1);
2090        assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
2091
2092        let line = "<!-- rumdl-enable-file -->";
2093        let directives = parse_inline_directives(line);
2094        assert_eq!(directives.len(), 1);
2095        assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
2096    }
2097
2098    #[test]
2099    fn test_parse_inline_directives_no_space_before_close() {
2100        // <!-- rumdl-disable--> must parse as Disable (the bug that started this refactor)
2101        let directives = parse_inline_directives("<!-- rumdl-disable-->");
2102        assert_eq!(directives.len(), 1);
2103        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2104        assert!(directives[0].rules.is_empty());
2105
2106        let directives = parse_inline_directives("<!-- rumdl-enable-->");
2107        assert_eq!(directives.len(), 1);
2108        assert_eq!(directives[0].kind, DirectiveKind::Enable);
2109    }
2110
2111    #[test]
2112    fn test_parse_inline_directives_multiple_on_one_line() {
2113        let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
2114        let directives = parse_inline_directives(line);
2115        assert_eq!(directives.len(), 2);
2116        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2117        assert_eq!(directives[0].rules, vec!["MD001"]);
2118        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2119        assert_eq!(directives[1].rules, vec!["MD001"]);
2120    }
2121
2122    #[test]
2123    fn test_parse_inline_directives_global_disable_then_specific_enable() {
2124        let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
2125        let directives = parse_inline_directives(line);
2126        assert_eq!(directives.len(), 2);
2127        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2128        assert!(directives[0].rules.is_empty());
2129        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2130        assert_eq!(directives[1].rules, vec!["MD001"]);
2131    }
2132
2133    #[test]
2134    fn test_parse_inline_directives_word_boundary() {
2135        // "disablefoo" should NOT match "disable"
2136        assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
2137        // "enablebar" should NOT match "enable"
2138        assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
2139        // "captures" should NOT match "capture"
2140        assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
2141    }
2142
2143    #[test]
2144    fn test_parse_inline_directives_no_closing_tag() {
2145        // Missing --> means no directive
2146        assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
2147        assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
2148    }
2149
2150    #[test]
2151    fn test_parse_inline_directives_not_a_comment() {
2152        assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
2153        assert!(parse_inline_directives("Some regular text").is_empty());
2154        assert!(parse_inline_directives("").is_empty());
2155    }
2156
2157    #[test]
2158    fn test_parse_inline_directives_case_sensitive() {
2159        assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
2160        assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
2161    }
2162
2163    #[test]
2164    fn test_parse_inline_directives_rules_extraction() {
2165        let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
2166        assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
2167
2168        // Tabs between rules
2169        let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
2170        assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
2171
2172        // Extra whitespace
2173        let directives = parse_inline_directives("<!-- rumdl-disable   MD001   -->");
2174        assert_eq!(directives[0].rules, vec!["MD001"]);
2175    }
2176
2177    #[test]
2178    fn test_parse_inline_directives_embedded_in_text() {
2179        let line = "Some text <!-- rumdl-disable MD001 --> more text";
2180        let directives = parse_inline_directives(line);
2181        assert_eq!(directives.len(), 1);
2182        assert_eq!(directives[0].rules, vec!["MD001"]);
2183
2184        let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
2185        let directives = parse_inline_directives(line);
2186        assert_eq!(directives.len(), 1);
2187        assert_eq!(directives[0].rules, vec!["MD001"]);
2188    }
2189
2190    #[test]
2191    fn test_parse_inline_directives_mixed_tools_same_line() {
2192        let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
2193        let directives = parse_inline_directives(line);
2194        assert_eq!(directives.len(), 2);
2195        assert_eq!(directives[0].kind, DirectiveKind::Disable);
2196        assert_eq!(directives[0].rules, vec!["MD001"]);
2197        assert_eq!(directives[1].kind, DirectiveKind::Enable);
2198        assert_eq!(directives[1].rules, vec!["MD002"]);
2199    }
2200
2201    // ── Backward-compatible wrapper tests ────────────────────────────────
2202
2203    #[test]
2204    fn test_parse_disable_comment() {
2205        // Global disable
2206        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
2207        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2208
2209        // Specific rules
2210        assert_eq!(
2211            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
2212            Some(vec!["MD001", "MD002"])
2213        );
2214
2215        // No comment
2216        assert_eq!(parse_disable_comment("Some regular text"), None);
2217    }
2218
2219    #[test]
2220    fn test_parse_disable_line_comment() {
2221        // Global disable-line
2222        assert_eq!(
2223            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
2224            Some(vec![])
2225        );
2226
2227        // Specific rules
2228        assert_eq!(
2229            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
2230            Some(vec!["MD013"])
2231        );
2232
2233        // No comment
2234        assert_eq!(parse_disable_line_comment("Some regular text"), None);
2235    }
2236
2237    #[test]
2238    fn test_inline_config_from_content() {
2239        let content = r#"# Test Document
2240
2241<!-- markdownlint-disable MD013 -->
2242This is a very long line that would normally trigger MD013 but it's disabled
2243
2244<!-- markdownlint-enable MD013 -->
2245This line will be checked again
2246
2247<!-- markdownlint-disable-next-line MD001 -->
2248# This heading will not be checked for MD001
2249## But this one will
2250
2251Some text <!-- markdownlint-disable-line MD013 -->
2252
2253<!-- markdownlint-capture -->
2254<!-- markdownlint-disable MD001 MD002 -->
2255# Heading with MD001 disabled
2256<!-- markdownlint-restore -->
2257# Heading with MD001 enabled again
2258"#;
2259
2260        let config = InlineConfig::from_content(content);
2261
2262        // Line 4 should have MD013 disabled (line after disable comment on line 3)
2263        assert!(config.is_rule_disabled("MD013", 4));
2264
2265        // Line 7 should have MD013 enabled (line after enable comment on line 6)
2266        assert!(!config.is_rule_disabled("MD013", 7));
2267
2268        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
2269        assert!(config.is_rule_disabled("MD001", 10));
2270
2271        // Line 11 should not have MD001 disabled
2272        assert!(!config.is_rule_disabled("MD001", 11));
2273
2274        // Line 13 should have MD013 disabled (from disable-line)
2275        assert!(config.is_rule_disabled("MD013", 13));
2276
2277        // After restore (line 18), MD001 should be enabled again on line 19
2278        assert!(!config.is_rule_disabled("MD001", 19));
2279    }
2280
2281    #[test]
2282    fn test_capture_restore() {
2283        let content = r#"<!-- markdownlint-disable MD001 -->
2284<!-- markdownlint-capture -->
2285<!-- markdownlint-disable MD002 MD003 -->
2286<!-- markdownlint-restore -->
2287Some content after restore
2288"#;
2289
2290        let config = InlineConfig::from_content(content);
2291
2292        // After restore (line 4), line 5 should only have MD001 disabled
2293        assert!(config.is_rule_disabled("MD001", 5));
2294        assert!(!config.is_rule_disabled("MD002", 5));
2295        assert!(!config.is_rule_disabled("MD003", 5));
2296    }
2297
2298    #[test]
2299    fn test_validate_inline_config_rules_unknown_rule() {
2300        let content = "<!-- rumdl-disable abc -->\nSome content";
2301        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2302        assert_eq!(warnings.len(), 1);
2303        assert_eq!(warnings[0].line_number, 1);
2304        assert_eq!(warnings[0].rule_name, "abc");
2305        assert_eq!(warnings[0].comment_type, "disable");
2306    }
2307
2308    #[test]
2309    fn test_validate_inline_config_rules_valid_rule() {
2310        let content = "<!-- rumdl-disable MD001 -->\nSome content";
2311        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2312        assert!(
2313            warnings.is_empty(),
2314            "MD001 is a valid rule, should not produce warnings"
2315        );
2316    }
2317
2318    #[test]
2319    fn test_validate_inline_config_rules_alias() {
2320        let content = "<!-- rumdl-disable heading-increment -->\nSome content";
2321        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2322        assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
2323    }
2324
2325    #[test]
2326    fn test_validate_inline_config_rules_multiple_unknown() {
2327        let content = r#"<!-- rumdl-disable abc xyz -->
2328<!-- rumdl-disable-line foo -->
2329<!-- markdownlint-disable-next-line bar -->
2330"#;
2331        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2332        assert_eq!(warnings.len(), 4);
2333        assert_eq!(warnings[0].rule_name, "abc");
2334        assert_eq!(warnings[1].rule_name, "xyz");
2335        assert_eq!(warnings[2].rule_name, "foo");
2336        assert_eq!(warnings[3].rule_name, "bar");
2337    }
2338
2339    #[test]
2340    fn test_validate_inline_config_rules_suggestion() {
2341        // "MD00" should suggest "MD001" (or similar)
2342        let content = "<!-- rumdl-disable MD00 -->\n";
2343        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2344        assert_eq!(warnings.len(), 1);
2345        // Should have a suggestion since "MD00" is close to "MD001"
2346        assert!(warnings[0].suggestion.is_some());
2347    }
2348
2349    #[test]
2350    fn test_validate_inline_config_rules_file_comments() {
2351        let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
2352        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2353        assert_eq!(warnings.len(), 2);
2354        assert_eq!(warnings[0].comment_type, "disable-file");
2355        assert_eq!(warnings[1].comment_type, "enable-file");
2356    }
2357
2358    #[test]
2359    fn test_validate_inline_config_rules_global_disable() {
2360        // Global disable (no specific rules) should not produce warnings
2361        let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
2362        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2363        assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
2364    }
2365
2366    #[test]
2367    fn test_validate_inline_config_rules_mixed_valid_invalid() {
2368        // Use MD001 and MD003 which are valid rules; abc and xyz are invalid
2369        let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
2370        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2371        assert_eq!(warnings.len(), 2);
2372        assert_eq!(warnings[0].rule_name, "abc");
2373        assert_eq!(warnings[1].rule_name, "xyz");
2374    }
2375
2376    #[test]
2377    fn test_validate_inline_config_rules_configure_file() {
2378        // configure-file comments contain rule names as JSON keys
2379        let content =
2380            r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
2381        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2382        assert_eq!(warnings.len(), 1);
2383        assert_eq!(warnings[0].rule_name, "nonexistent");
2384        assert_eq!(warnings[0].comment_type, "configure-file");
2385    }
2386
2387    #[test]
2388    fn test_validate_inline_config_rules_markdownlint_variants() {
2389        // Test markdownlint-* variants (not just rumdl-*)
2390        let content = r#"<!-- markdownlint-disable unknown_rule -->
2391<!-- markdownlint-enable another_fake -->
2392<!-- markdownlint-disable-line bad_rule -->
2393<!-- markdownlint-disable-next-line fake_rule -->
2394<!-- markdownlint-disable-file missing_rule -->
2395<!-- markdownlint-enable-file nonexistent -->
2396"#;
2397        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2398        assert_eq!(warnings.len(), 6);
2399        assert_eq!(warnings[0].rule_name, "unknown_rule");
2400        assert_eq!(warnings[1].rule_name, "another_fake");
2401        assert_eq!(warnings[2].rule_name, "bad_rule");
2402        assert_eq!(warnings[3].rule_name, "fake_rule");
2403        assert_eq!(warnings[4].rule_name, "missing_rule");
2404        assert_eq!(warnings[5].rule_name, "nonexistent");
2405    }
2406
2407    /// A directive inside a code block configures nothing, so documenting one in
2408    /// a fenced example must not be reported. The control outside the fence
2409    /// proves the same directive is still validated where it takes effect.
2410    #[test]
2411    fn test_validate_inline_config_rules_ignores_code_blocks() {
2412        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
2413        assert!(
2414            validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
2415            "a directive inside a fence is documentation, not configuration"
2416        );
2417
2418        let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
2419        assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
2420
2421        let indented = "# Doc\n\n    <!-- rumdl-disable made_up_rule -->\n";
2422        assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
2423
2424        let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2425        assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2426    }
2427
2428    /// An indented container body is structure rather than code, so a directive
2429    /// written there does configure the document and its problems are reported.
2430    /// The same lines under a flavor with no admonitions really are an indented
2431    /// code block, which is the control on the exemption above.
2432    #[test]
2433    fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
2434        let admonition = "# Doc\n\n!!! example\n\n    <!-- rumdl-disable made_up_rule -->\n";
2435        assert_eq!(
2436            validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
2437            1,
2438            "an admonition holds its content at an indent that is not code"
2439        );
2440        assert!(
2441            validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
2442            "without admonitions the same lines are an indented code block"
2443        );
2444
2445        let tab = "# Doc\n\n=== \"Tab\"\n\n    <!-- rumdl-disable made_up_rule -->\n";
2446        assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
2447        assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
2448    }
2449
2450    /// An inline enable that can do nothing is reported wherever the directive
2451    /// takes effect, which includes an indented container body.
2452    #[test]
2453    fn test_validate_inline_enables_reports_inside_an_indented_container() {
2454        let admonition = "# Doc\n\n!!! example\n\n    <!-- rumdl-enable MD013 -->\n";
2455        let active = active_set(&["MD009"]);
2456        assert_eq!(
2457            validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
2458                .len(),
2459            1,
2460            "the enable is live in an admonition body, and configuration never enabled MD013"
2461        );
2462        assert!(
2463            validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
2464                .is_empty(),
2465            "without admonitions the same lines are an indented code block"
2466        );
2467    }
2468
2469    /// configure-file is scanned over the whole document, so its code-block
2470    /// exemption is checked separately from the per-line directives.
2471    #[test]
2472    fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
2473        let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
2474        assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
2475
2476        let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
2477        assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
2478
2479        let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
2480        assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2481    }
2482
2483    #[test]
2484    fn test_validate_inline_config_rules_markdownlint_configure_file() {
2485        let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
2486        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2487        assert_eq!(warnings.len(), 1);
2488        assert_eq!(warnings[0].rule_name, "fake_rule");
2489        assert_eq!(warnings[0].comment_type, "configure-file");
2490    }
2491
2492    #[test]
2493    fn test_get_rule_config_from_configure_file() {
2494        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
2495
2496This is a test line."#;
2497
2498        let inline_config = InlineConfig::from_content(content);
2499        let config_override = inline_config.get_rule_config("MD013");
2500
2501        assert!(config_override.is_some(), "MD013 config should be found");
2502        let json = config_override.unwrap();
2503        assert!(json.is_object(), "Config should be an object");
2504        let obj = json.as_object().unwrap();
2505        assert!(obj.contains_key("line_length"), "Should have line_length key");
2506        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2507    }
2508
2509    #[test]
2510    fn test_get_rule_config_tables_false() {
2511        // Test that tables=false inline config is correctly parsed
2512        let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
2513
2514        let inline_config = InlineConfig::from_content(content);
2515        let config_override = inline_config.get_rule_config("MD013");
2516
2517        assert!(config_override.is_some(), "MD013 config should be found");
2518        let json = config_override.unwrap();
2519        let obj = json.as_object().unwrap();
2520        assert!(obj.contains_key("tables"), "Should have tables key");
2521        assert!(!obj.get("tables").unwrap().as_bool().unwrap());
2522    }
2523
2524    // ── multi-line configure-file ────────────────────────────────────────
2525    //
2526    // markdownlint scans configure-file over the whole document rather than
2527    // per line, so the comment may span lines. Every other directive stays
2528    // line-scoped in both tools.
2529
2530    // ── inline enable of a rule that will not run ────────────────────────
2531    //
2532    // rumdl treats rule selection as final: a rule configuration disabled is
2533    // never instantiated, and one per-file-ignores excludes is dropped before
2534    // the file is linted, so an inline enable of either does nothing. These
2535    // warnings make that silent no-op visible.
2536
2537    fn active_set(names: &[&str]) -> HashSet<String> {
2538        names.iter().map(|s| (*s).to_string()).collect()
2539    }
2540
2541    fn no_ignores() -> HashSet<String> {
2542        HashSet::new()
2543    }
2544
2545    #[test]
2546    fn test_inline_enable_of_inactive_rule_warns() {
2547        let active = active_set(&["MD013", "MD022"]);
2548        let content = "<!-- rumdl-enable MD012 -->\n";
2549
2550        let warnings =
2551            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2552
2553        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2554        assert_eq!(warnings[0].rule_name, "MD012");
2555        assert_eq!(warnings[0].comment_type, "enable");
2556        assert_eq!(
2557            warnings[0].problem,
2558            InlineConfigProblem::EnableHasNoEffect {
2559                reason: EnableNoEffectReason::NotEnabled
2560            }
2561        );
2562        assert_eq!(warnings[0].line_number, 1);
2563    }
2564
2565    #[test]
2566    fn test_inline_enable_of_active_rule_does_not_warn() {
2567        // The load-bearing false-positive guard: enabling a rule that IS active
2568        // must stay silent.
2569        let active = active_set(&["MD012", "MD013"]);
2570        let content = "<!-- rumdl-enable MD012 -->\n";
2571
2572        let warnings =
2573            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2574
2575        assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
2576    }
2577
2578    #[test]
2579    fn test_inline_enable_of_per_file_ignored_rule_warns() {
2580        // The rule is enabled in configuration, so nothing about the config
2581        // explains the silence; per-file-ignores is what drops it here.
2582        let active = active_set(&["MD012", "MD013"]);
2583        let ignored = active_set(&["MD012"]);
2584        let content = "<!-- rumdl-enable MD012 -->\n";
2585
2586        let warnings =
2587            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2588
2589        assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2590        assert_eq!(warnings[0].rule_name, "MD012");
2591        assert_eq!(
2592            warnings[0].problem,
2593            InlineConfigProblem::EnableHasNoEffect {
2594                reason: EnableNoEffectReason::IgnoredForFile
2595            }
2596        );
2597        assert!(
2598            warnings[0].format_message().contains("per-file-ignores"),
2599            "the message must name the setting in play: {}",
2600            warnings[0].format_message()
2601        );
2602    }
2603
2604    #[test]
2605    fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
2606        // A per-file-ignores entry for a rule configuration never enabled is
2607        // redundant; the setting standing in the way is the config one.
2608        let active = active_set(&["MD013"]);
2609        let ignored = active_set(&["MD012"]);
2610        let content = "<!-- rumdl-enable MD012 -->\n";
2611
2612        let warnings =
2613            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2614
2615        assert_eq!(warnings.len(), 1, "{warnings:?}");
2616        assert_eq!(
2617            warnings[0].problem,
2618            InlineConfigProblem::EnableHasNoEffect {
2619                reason: EnableNoEffectReason::NotEnabled
2620            }
2621        );
2622    }
2623
2624    #[test]
2625    fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
2626        // The negative control for the per-file-ignores arm: an ignore entry
2627        // naming a different rule must leave this enable alone.
2628        let active = active_set(&["MD012", "MD013"]);
2629        let ignored = active_set(&["MD013"]);
2630        let content = "<!-- rumdl-enable MD012 -->\n";
2631
2632        let warnings =
2633            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2634
2635        assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
2636    }
2637
2638    #[test]
2639    fn test_enable_inside_code_block_does_not_warn() {
2640        // A fenced example enables nothing, so neither reason applies to it.
2641        // The same document with the fence removed is the positive control.
2642        let active = active_set(&["MD012", "MD013"]);
2643        let ignored = active_set(&["MD012"]);
2644
2645        for content in [
2646            "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
2647            "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
2648            "# Doc\n\n    <!-- rumdl-enable MD012 -->\n",
2649        ] {
2650            let warnings =
2651                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2652            assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
2653        }
2654
2655        for content in [
2656            "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
2657            "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
2658        ] {
2659            let warnings =
2660                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2661            assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
2662        }
2663    }
2664
2665    #[test]
2666    fn test_bare_enable_all_does_not_warn() {
2667        // `enable` with no rule list means "all"; it targets no specific rule.
2668        let active = active_set(&["MD013"]);
2669        for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
2670            let warnings = validate_inline_enables_against_active_rules(
2671                content,
2672                MarkdownFlavor::Standard,
2673                &active,
2674                &active_set(&["MD013"]),
2675            );
2676            assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
2677        }
2678    }
2679
2680    #[test]
2681    fn test_enable_file_and_alias_of_inactive_rule_warn() {
2682        let active = active_set(&["MD013"]);
2683        // enable-file, plus an alias for an inactive rule, both flagged.
2684        let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
2685
2686        let warnings =
2687            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2688
2689        assert_eq!(warnings.len(), 1, "{warnings:?}");
2690        assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
2691        assert_eq!(warnings[0].comment_type, "enable-file");
2692    }
2693
2694    #[test]
2695    fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
2696        let active = active_set(&["MD013"]);
2697        let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2698        let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2699
2700        let warn_true =
2701            validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
2702        let warn_false =
2703            validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
2704
2705        assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
2706        assert_eq!(warn_true[0].rule_name, "MD012");
2707        assert!(
2708            warn_false.is_empty(),
2709            "configure-file false is a disable, not an ignored enable: {warn_false:?}"
2710        );
2711    }
2712
2713    #[test]
2714    fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
2715        // An unrecognized name is reported by validate_inline_config_rules; it
2716        // must not also be flagged here (it is not a known-but-inactive rule).
2717        let active = active_set(&["MD013"]);
2718        let content = "<!-- rumdl-enable NotARule -->\n";
2719
2720        let warnings =
2721            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2722
2723        assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
2724    }
2725
2726    #[test]
2727    fn test_disable_directive_never_warns_as_no_effect() {
2728        // Only enables are no-ops against a rule that will not run; a disable of
2729        // one is meaningless but harmless and must stay silent.
2730        let active = active_set(&["MD012", "MD013"]);
2731        let ignored = active_set(&["MD012"]);
2732        let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
2733
2734        let warnings =
2735            validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2736
2737        assert!(warnings.is_empty(), "disable warned: {warnings:?}");
2738    }
2739
2740    // ── unknown option keys inside configure-file ────────────────────────
2741    //
2742    // A typo'd option key used to be dropped in silence, while the same typo
2743    // in a config file was reported with a suggestion.
2744
2745    #[test]
2746    fn test_unknown_option_key_in_configure_file_warns() {
2747        let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2748
2749        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2750
2751        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2752        assert_eq!(warnings[0].rule_name, "MD013");
2753        assert_eq!(
2754            warnings[0].problem,
2755            InlineConfigProblem::UnknownOption {
2756                key: "line_lenght".to_string()
2757            }
2758        );
2759        assert!(
2760            warnings[0].suggestion.is_some(),
2761            "a near-miss key should suggest the real one"
2762        );
2763        assert!(
2764            warnings[0].format_message().contains("Unknown option for rule MD013"),
2765            "message was: {}",
2766            warnings[0].format_message()
2767        );
2768    }
2769
2770    #[test]
2771    fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2772        // The false-positive guard that matters: both spellings are legal, and
2773        // warning on them would be worse than the silence this replaces.
2774        for content in [
2775            r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2776            r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2777        ] {
2778            let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2779            assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2780        }
2781    }
2782
2783    #[test]
2784    fn test_unknown_rule_does_not_also_warn_about_its_options() {
2785        // The rule name is already reported; validating options of a rule that
2786        // does not exist would just be noise.
2787        let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2788
2789        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2790
2791        assert_eq!(
2792            warnings.len(),
2793            1,
2794            "expected only the unknown-rule warning: {warnings:?}"
2795        );
2796        assert_eq!(warnings[0].rule_name, "nonexistent");
2797        assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2798    }
2799
2800    #[test]
2801    fn test_boolean_rule_value_has_no_option_warnings() {
2802        let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2803
2804        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2805
2806        assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2807    }
2808
2809    #[test]
2810    fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2811        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2812
2813        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2814
2815        assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2816        assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2817    }
2818
2819    #[test]
2820    fn test_configure_file_spanning_multiple_lines_applies() {
2821        let content = "<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2822
2823        let inline_config = InlineConfig::from_content(content);
2824        let config_override = inline_config.get_rule_config("MD013");
2825
2826        assert!(config_override.is_some(), "a multi-line configure-file must apply");
2827        let obj = config_override.unwrap().as_object().unwrap();
2828        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2829    }
2830
2831    #[test]
2832    fn test_every_configure_file_comment_applies_not_just_the_first() {
2833        // Scanning the whole document must not collapse to the first match:
2834        // both comments configure a different rule and both must land.
2835        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";
2836
2837        let inline_config = InlineConfig::from_content(content);
2838
2839        assert!(
2840            inline_config.get_rule_config("MD013").is_some(),
2841            "first (single-line) configure-file dropped"
2842        );
2843        assert!(
2844            inline_config.get_rule_config("MD007").is_some(),
2845            "second (multi-line) configure-file dropped"
2846        );
2847    }
2848
2849    #[test]
2850    fn test_multiline_configure_file_in_code_block_is_ignored() {
2851        // rumdl ignores inline config inside fences; markdownlint does not.
2852        // Widening to a whole-document scan must not lose that.
2853        let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n  \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2854
2855        let inline_config = InlineConfig::from_content(content);
2856
2857        assert!(
2858            inline_config.get_rule_config("MD013").is_none(),
2859            "configure-file inside a fenced code block must not apply"
2860        );
2861    }
2862
2863    #[test]
2864    fn test_multiline_configure_file_bool_and_alias_still_honored() {
2865        // The boolean and alias handling must survive the move off the
2866        // per-line path, in the multi-line form too.
2867        let content = "<!-- markdownlint-configure-file\n{\n  \"no-multiple-blanks\": false,\n  \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2868
2869        let inline_config = InlineConfig::from_content(content);
2870
2871        assert!(
2872            inline_config.is_rule_disabled("MD012", 1),
2873            "boolean alias key must disable the rule"
2874        );
2875        let obj = inline_config
2876            .get_rule_config("MD013")
2877            .expect("alias-keyed config should resolve to MD013")
2878            .as_object()
2879            .unwrap();
2880        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2881    }
2882
2883    #[test]
2884    fn test_multiline_configure_file_warning_reports_start_line() {
2885        // An unknown rule inside a multi-line comment is reported at the line
2886        // the comment opens on, not line 1 and not the closing line.
2887        let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n  \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2888
2889        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2890
2891        assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2892        assert_eq!(warnings[0].rule_name, "nonexistent");
2893        assert_eq!(warnings[0].comment_type, "configure-file");
2894        assert_eq!(
2895            warnings[0].line_number, 3,
2896            "warning must point at the line the comment starts on"
2897        );
2898    }
2899
2900    #[test]
2901    fn test_configure_file_bool_false_disables_rule() {
2902        // markdownlint documents a boolean as a way to turn a rule off for the
2903        // whole file, e.g. `{ "no-trailing-spaces": false }`.
2904        let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2905
2906        let inline_config = InlineConfig::from_content(content);
2907
2908        assert!(inline_config.is_rule_disabled("MD012", 1));
2909        assert!(
2910            inline_config.get_rule_config("MD012").is_none(),
2911            "a boolean should not be stored as rule options"
2912        );
2913    }
2914
2915    #[test]
2916    fn test_configure_file_bool_false_disables_rule_by_alias() {
2917        let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2918
2919        let inline_config = InlineConfig::from_content(content);
2920
2921        assert!(inline_config.is_rule_disabled("MD012", 1));
2922    }
2923
2924    #[test]
2925    fn test_configure_file_bool_true_leaves_rule_enabled() {
2926        let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2927
2928        let inline_config = InlineConfig::from_content(content);
2929
2930        assert!(!inline_config.is_rule_disabled("MD012", 1));
2931    }
2932
2933    #[test]
2934    fn test_get_rule_config_from_configure_file_alias_key() {
2935        // A config written with the rule's alias must be reachable by its id.
2936        let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2937
2938        let inline_config = InlineConfig::from_content(content);
2939        let config_override = inline_config.get_rule_config("MD013");
2940
2941        assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2942        let obj = config_override.unwrap().as_object().unwrap();
2943        assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2944    }
2945
2946    // ── parse_disable_comment / parse_enable_comment edge cases ──────────
2947
2948    #[test]
2949    fn test_parse_disable_does_not_match_disable_line() {
2950        // parse_disable_comment must NOT match disable-line or disable-next-line
2951        assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2952        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2953        assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2954        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2955        assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2956        assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2957    }
2958
2959    #[test]
2960    fn test_parse_enable_does_not_match_enable_file() {
2961        assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2962        assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2963    }
2964
2965    #[test]
2966    fn test_parse_disable_comment_edge_cases() {
2967        // No space before closing
2968        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2969
2970        // Tabs between rules
2971        assert_eq!(
2972            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2973            Some(vec!["MD001", "MD002"])
2974        );
2975
2976        // Comment not at start of line
2977        assert_eq!(
2978            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2979            Some(vec!["MD001"])
2980        );
2981
2982        // Malformed: no closing
2983        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2984
2985        // Malformed: no opening
2986        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2987
2988        // Case sensitive: uppercase should not match
2989        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2990
2991        // Empty rule list with whitespace
2992        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
2993
2994        // Duplicate rules preserved (caller may deduplicate)
2995        assert_eq!(
2996            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2997            Some(vec!["MD001", "MD001", "MD002"])
2998        );
2999
3000        // Unicode around the comment
3001        assert_eq!(
3002            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
3003            Some(vec!["MD001"])
3004        );
3005
3006        // 100 rules
3007        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
3008        let comment = format!("<!-- rumdl-disable {many_rules} -->");
3009        let parsed = parse_disable_comment(&comment);
3010        assert!(parsed.is_some());
3011        assert_eq!(parsed.unwrap().len(), 100);
3012
3013        // Special characters in rule names (forward compat)
3014        assert_eq!(
3015            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
3016            Some(vec!["MD001-test"])
3017        );
3018        assert_eq!(
3019            parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
3020            Some(vec!["custom_rule"])
3021        );
3022    }
3023
3024    #[test]
3025    fn test_parse_enable_comment_edge_cases() {
3026        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
3027        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
3028        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
3029        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
3030    }
3031
3032    // ── InlineConfig: code blocks must be transparent ────────────────────
3033
3034    #[test]
3035    fn test_disable_inside_fenced_code_block_ignored() {
3036        let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
3037        let config = InlineConfig::from_content(content);
3038        // The disable comment is inside a code block — must have no effect
3039        assert!(!config.is_rule_disabled("MD001", 6));
3040    }
3041
3042    #[test]
3043    fn test_disable_inside_tilde_fence_ignored() {
3044        let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
3045        let config = InlineConfig::from_content(content);
3046        assert!(!config.is_rule_disabled("MD001", 6));
3047    }
3048
3049    #[test]
3050    fn test_disable_before_code_block_persists_after() {
3051        // Disable before code block should persist through and after it
3052        let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
3053        let config = InlineConfig::from_content(content);
3054        assert!(config.is_rule_disabled("MD001", 5));
3055    }
3056
3057    #[test]
3058    fn test_enable_inside_code_block_ignored() {
3059        // Disable before, enable inside code block (should be ignored), still disabled after
3060        let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
3061        let config = InlineConfig::from_content(content);
3062        assert!(config.is_rule_disabled("MD001", 5));
3063    }
3064
3065    #[test]
3066    fn test_disable_inside_indented_code_block_ignored() {
3067        // An indented code block's range starts at the indented content rather
3068        // than at the start of the line, so containment of the whole line span
3069        // would miss it and let a code sample disable a rule.
3070        let content = "# Document\n\n    <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
3071        let config = InlineConfig::from_content(content);
3072        assert!(!config.is_rule_disabled("MD001", 5));
3073
3074        // Control: the same comment at column 1 is a directive and applies.
3075        let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
3076        let config = InlineConfig::from_content(unindented);
3077        assert!(config.is_rule_disabled("MD001", 5));
3078    }
3079
3080    #[test]
3081    fn test_configure_file_inside_indented_code_block_ignored() {
3082        // configure-file is scanned over the whole document, so it reaches the
3083        // same conclusion by its own path; both must agree.
3084        let content = "# Document\n\n    <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3085        let config = InlineConfig::from_content(content);
3086        assert!(config.get_rule_config("MD013").is_none());
3087
3088        let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3089        let config = InlineConfig::from_content(unindented);
3090        assert!(config.get_rule_config("MD013").is_some());
3091    }
3092
3093    // ── InlineConfig: directives inside inline code spans ────────────────
3094
3095    #[test]
3096    fn test_disable_inside_code_span_ignored() {
3097        // A code span holds literal text, so a directive written between
3098        // backticks documents one rather than writing one.
3099        let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
3100        let config = InlineConfig::from_content(content);
3101        assert!(!config.is_rule_disabled("MD001", 5));
3102
3103        // Control: the same comment without backticks is a directive and applies.
3104        let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3105        let config = InlineConfig::from_content(bare);
3106        assert!(config.is_rule_disabled("MD001", 5));
3107    }
3108
3109    #[test]
3110    fn test_directive_beside_one_in_a_code_span_still_applies() {
3111        // A span silences the directive it holds, not the line it sits on, so
3112        // filtering is per directive rather than per line.
3113        let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
3114        let config = InlineConfig::from_content(content);
3115        assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
3116        assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
3117    }
3118
3119    #[test]
3120    fn test_disable_file_inside_code_span_ignored() {
3121        let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
3122        let config = InlineConfig::from_content(content);
3123        assert!(!config.is_rule_disabled("MD013", 3));
3124
3125        let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
3126        let config = InlineConfig::from_content(bare);
3127        assert!(config.is_rule_disabled("MD013", 3));
3128    }
3129
3130    #[test]
3131    fn test_configure_file_inside_code_span_ignored() {
3132        // configure-file is scanned over the whole document, so it reaches the
3133        // same conclusion by its own path; both must agree.
3134        let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
3135        let config = InlineConfig::from_content(content);
3136        assert!(config.get_rule_config("MD013").is_none());
3137
3138        let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
3139        let config = InlineConfig::from_content(disabling);
3140        assert!(!config.is_rule_disabled("MD013", 3));
3141    }
3142
3143    #[test]
3144    fn test_disable_inside_multi_line_code_span_ignored() {
3145        // A code span may cross a line break, and what it holds is still text.
3146        let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
3147        let config = InlineConfig::from_content(content);
3148        assert!(!config.is_rule_disabled("MD001", 5));
3149    }
3150
3151    #[test]
3152    fn test_comment_opening_a_line_inside_backticks_still_applies() {
3153        // An HTML comment at the start of a line interrupts the paragraph, so the
3154        // opening backtick never finds a partner and no code span exists. The
3155        // comment is a real one and its directive is live.
3156        let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
3157        let config = InlineConfig::from_content(content);
3158        assert!(config.is_rule_disabled("MD001", 5));
3159    }
3160
3161    #[test]
3162    fn test_disable_inside_double_backtick_code_span_ignored() {
3163        let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
3164        let config = InlineConfig::from_content(content);
3165        assert!(!config.is_rule_disabled("MD001", 3));
3166    }
3167
3168    #[test]
3169    fn test_code_ranges_line_up_on_a_crlf_document() {
3170        // Line offsets are measured over pieces that keep their `\r`, so they
3171        // match the byte ranges the parser reports. Measuring them without it
3172        // drifts a byte per line, and the drift is what lets a directive held
3173        // by a code span read as sitting outside it.
3174        let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
3175        let config = InlineConfig::from_content(fenced);
3176        assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
3177
3178        let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
3179        let config = InlineConfig::from_content(spanned);
3180        assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
3181
3182        // Control: the same document with the directive in prose still applies.
3183        let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
3184        let config = InlineConfig::from_content(bare);
3185        assert!(config.is_rule_disabled("MD001", 5));
3186    }
3187
3188    #[test]
3189    fn test_unmatched_backtick_does_not_open_a_code_span() {
3190        // A lone backtick is literal text, so the directive after it is live.
3191        let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3192        let config = InlineConfig::from_content(content);
3193        assert!(config.is_rule_disabled("MD001", 3));
3194    }
3195
3196    #[test]
3197    fn test_validate_inline_config_rules_ignores_code_spans() {
3198        // An unknown rule name shown in prose names nothing this run would have
3199        // applied, so there is nothing to warn about.
3200        let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
3201        assert!(
3202            validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
3203            "a directive shown in a code span warned"
3204        );
3205
3206        let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
3207        assert_eq!(
3208            validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
3209            1,
3210            "the same name outside a span is still validated"
3211        );
3212    }
3213
3214    #[test]
3215    fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
3216        let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
3217        assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
3218    }
3219
3220    #[test]
3221    fn test_enable_inside_code_span_does_not_warn() {
3222        // A directive shown in prose enables nothing, so neither reason applies.
3223        let active = active_set(&["MD012", "MD013"]);
3224        let ignored = active_set(&["MD012"]);
3225
3226        for content in [
3227            "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
3228            "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
3229        ] {
3230            let warnings =
3231                validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
3232            assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
3233        }
3234    }
3235
3236    // ── InlineConfig: mixed comment styles ───────────────────────────────
3237
3238    #[test]
3239    fn test_markdownlint_disable_rumdl_enable_interop() {
3240        let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
3241        let config = InlineConfig::from_content(content);
3242        assert!(config.is_rule_disabled("MD001", 2));
3243        assert!(!config.is_rule_disabled("MD001", 4));
3244    }
3245
3246    #[test]
3247    fn test_rumdl_disable_markdownlint_enable_interop() {
3248        let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
3249        let config = InlineConfig::from_content(content);
3250        assert!(config.is_rule_disabled("MD013", 2));
3251        assert!(!config.is_rule_disabled("MD013", 4));
3252    }
3253
3254    // ── InlineConfig: nested/overlapping disable/enable ──────────────────
3255
3256    #[test]
3257    fn test_global_disable_then_specific_enable() {
3258        let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
3259        let config = InlineConfig::from_content(content);
3260        assert!(!config.is_rule_disabled("MD001", 4));
3261        assert!(config.is_rule_disabled("MD002", 4));
3262        assert!(config.is_rule_disabled("MD013", 4));
3263    }
3264
3265    #[test]
3266    fn test_specific_disable_then_global_enable() {
3267        let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
3268        let config = InlineConfig::from_content(content);
3269        assert!(config.is_rule_disabled("MD001", 2));
3270        assert!(config.is_rule_disabled("MD002", 2));
3271        assert!(!config.is_rule_disabled("MD001", 4));
3272        assert!(!config.is_rule_disabled("MD002", 4));
3273    }
3274
3275    #[test]
3276    fn test_multiple_rules_disable_enable_independently() {
3277        let content = "\
3278Line 1\n\
3279<!-- rumdl-disable MD001 MD002 -->\n\
3280Line 3\n\
3281<!-- rumdl-enable MD001 -->\n\
3282Line 5\n\
3283<!-- rumdl-disable -->\n\
3284Line 7\n\
3285<!-- rumdl-enable MD002 -->\n\
3286Line 9\n";
3287        let config = InlineConfig::from_content(content);
3288
3289        // Line 1: nothing disabled
3290        assert!(!config.is_rule_disabled("MD001", 1));
3291        assert!(!config.is_rule_disabled("MD002", 1));
3292
3293        // Line 3: both disabled
3294        assert!(config.is_rule_disabled("MD001", 3));
3295        assert!(config.is_rule_disabled("MD002", 3));
3296
3297        // Line 5: MD001 enabled, MD002 still disabled
3298        assert!(!config.is_rule_disabled("MD001", 5));
3299        assert!(config.is_rule_disabled("MD002", 5));
3300
3301        // Line 7: all disabled
3302        assert!(config.is_rule_disabled("MD001", 7));
3303        assert!(config.is_rule_disabled("MD002", 7));
3304
3305        // Line 9: MD002 enabled, MD001 still disabled
3306        assert!(config.is_rule_disabled("MD001", 9));
3307        assert!(!config.is_rule_disabled("MD002", 9));
3308    }
3309
3310    // ── InlineConfig: empty/minimal content ──────────────────────────────
3311
3312    #[test]
3313    fn test_empty_content() {
3314        let config = InlineConfig::from_content("");
3315        assert!(!config.is_rule_disabled("MD001", 1));
3316    }
3317
3318    #[test]
3319    fn test_single_disable_comment_only() {
3320        // Persistent disable takes effect from the NEXT line, not the current line.
3321        // For a single-line document, the disable on line 1 takes effect at line 2+.
3322        let config = InlineConfig::from_content("<!-- rumdl-disable -->");
3323        assert!(!config.is_rule_disabled("MD001", 1));
3324        assert!(config.is_rule_disabled("MD001", 2));
3325        assert!(config.is_rule_disabled("MD999", 2));
3326
3327        // With content after the disable, rules are disabled from line 2 onward
3328        let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
3329        assert!(!config.is_rule_disabled("MD001", 1));
3330        assert!(config.is_rule_disabled("MD001", 2));
3331        assert!(config.is_rule_disabled("MD001", 3));
3332    }
3333
3334    #[test]
3335    fn test_no_inline_markers() {
3336        let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
3337        assert!(!config.is_rule_disabled("MD001", 1));
3338        assert!(!config.is_rule_disabled("MD001", 5));
3339    }
3340
3341    // ── InlineConfig: export_for_file_index correctness ──────────────────
3342
3343    #[test]
3344    fn test_export_for_file_index_persistent_transitions() {
3345        let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
3346        let config = InlineConfig::from_content(content);
3347        let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
3348
3349        assert!(file_disabled.is_empty());
3350        // Should have transitions for the disable and enable
3351        assert!(
3352            persistent.len() >= 2,
3353            "Expected at least 2 transitions, got {}",
3354            persistent.len()
3355        );
3356    }
3357
3358    #[test]
3359    fn test_export_for_file_index_disable_file() {
3360        let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
3361        let config = InlineConfig::from_content(content);
3362        let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
3363
3364        assert!(file_disabled.contains("MD001"));
3365    }
3366
3367    #[test]
3368    fn test_export_for_file_index_disable_line() {
3369        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
3370        let config = InlineConfig::from_content(content);
3371        let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
3372
3373        assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
3374        assert!(line_disabled[&2].contains("MD001"));
3375        assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
3376    }
3377
3378    // ── multi-line directive comments ───────────────────────────────────────
3379
3380    fn disabled_lines(content: &str, rule: &str) -> Vec<usize> {
3381        let config = InlineConfig::from_content(content);
3382        let lines = content.split('\n').count();
3383        (1..=lines)
3384            .filter(|&line| config.is_rule_disabled(rule, line))
3385            .collect()
3386    }
3387
3388    #[test]
3389    fn multi_line_disable_takes_effect_after_the_closing_line() {
3390        // The comment ends on line 3, so line 4 is the first disabled one.
3391        let content = "<!--\nrumdl-disable MD013\n-->\nlong\nlong\n";
3392        assert_eq!(disabled_lines(content, "MD013"), [4, 5, 6]);
3393    }
3394
3395    #[test]
3396    fn multi_line_markdownlint_forms_and_rule_lists_split_over_lines() {
3397        let content = "<!--\nmarkdownlint-disable MD013\nMD001\n  MD009\n-->\nx\n";
3398        for rule in ["MD013", "MD001", "MD009"] {
3399            assert_eq!(disabled_lines(content, rule), [6, 7], "{rule}");
3400        }
3401        let all = "<!-- rumdl-disable\n-->\nx\n";
3402        assert_eq!(disabled_lines(all, "MD013"), [3, 4]);
3403    }
3404
3405    #[test]
3406    fn multi_line_enable_restores_and_capture_restore_nest() {
3407        let content = "<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-enable MD013\n-->\nb\n";
3408        assert_eq!(disabled_lines(content, "MD013"), [2, 3, 4, 5]);
3409        let content = "<!--\nrumdl-capture\n-->\n<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-restore\n-->\nb\n";
3410        assert_eq!(disabled_lines(content, "MD013"), [5, 6, 7, 8]);
3411    }
3412
3413    #[test]
3414    fn multi_line_line_and_next_line_forms_use_the_closing_line() {
3415        let content = "<!--\nrumdl-disable-line MD013\n-->\nx\n";
3416        assert_eq!(disabled_lines(content, "MD013"), [3]);
3417        let content = "<!--\nrumdl-disable-next-line MD013\n-->\nx\ny\n";
3418        assert_eq!(disabled_lines(content, "MD013"), [4]);
3419    }
3420
3421    #[test]
3422    fn multi_line_file_forms_apply_to_the_whole_document() {
3423        let content = "x\n<!--\nrumdl-disable-file MD013\n-->\ny\n";
3424        assert_eq!(disabled_lines(content, "MD013"), [1, 2, 3, 4, 5, 6]);
3425    }
3426
3427    #[test]
3428    fn single_line_comment_after_a_multi_line_one_on_its_closing_line_applies_in_order() {
3429        // Disable (multi-line) then enable (single-line) on the closing line: net enabled.
3430        let content = "<!--\nrumdl-disable MD013\n--> <!-- rumdl-enable MD013 -->\nx\n";
3431        assert!(disabled_lines(content, "MD013").is_empty());
3432        // Enable first, then disable: net disabled from the next line.
3433        let content = "<!--\nrumdl-enable MD013\n--> <!-- rumdl-disable MD013 -->\nx\n";
3434        assert_eq!(disabled_lines(content, "MD013"), [4, 5]);
3435    }
3436
3437    #[test]
3438    fn multi_line_directive_inside_a_code_block_or_span_configures_nothing() {
3439        let fenced = "```\n<!--\nrumdl-disable MD013\n-->\n```\nx\n";
3440        assert!(disabled_lines(fenced, "MD013").is_empty());
3441        let span = "`<!--\nrumdl-disable MD013\n-->`\nx\n";
3442        assert!(disabled_lines(span, "MD013").is_empty());
3443    }
3444
3445    #[test]
3446    fn multi_line_comment_with_other_leading_text_is_not_a_directive() {
3447        let content = "<!--\nnote\nrumdl-disable MD013\n-->\nx\n";
3448        assert!(disabled_lines(content, "MD013").is_empty());
3449    }
3450
3451    #[test]
3452    fn single_line_directives_are_not_counted_twice() {
3453        let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-disable-next-line MD001 -->\nx\n";
3454        let config = InlineConfig::from_content(content);
3455        let (_, transitions, line_disabled) = config.export_for_file_index();
3456        // The initial state plus the one disable; a double count would add a third.
3457        assert_eq!(transitions.len(), 2, "{transitions:?}");
3458        assert_eq!(line_disabled.len(), 1, "{line_disabled:?}");
3459    }
3460
3461    #[test]
3462    fn multi_line_disable_site_reports_the_opening_line_and_the_closing_line_scope() {
3463        let content = "x\n<!--\nrumdl-disable-next-line MD013\n-->\ny\n";
3464        let sites = collect_disable_sites(content, &[], &[]);
3465        assert_eq!(sites.len(), 1, "{sites:?}");
3466        assert_eq!(sites[0].line, 2);
3467        assert_eq!(sites[0].kind, "disable-next-line");
3468        assert_eq!(sites[0].scope, DisableScope::Line(5));
3469        assert_eq!(sites[0].span.start, 0);
3470        assert_eq!(sites[0].span.end, 4, "clipped to the opening line");
3471    }
3472
3473    #[test]
3474    fn multi_line_directive_unknown_rule_is_reported_on_the_opening_line() {
3475        let content = "x\n<!--\nrumdl-disable MD9999\n-->\n";
3476        let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
3477        assert_eq!(warnings.len(), 1, "{warnings:?}");
3478        assert_eq!(warnings[0].line_number, 2);
3479        assert_eq!(warnings[0].rule_name, "MD9999");
3480    }
3481
3482    // ── comment whitespace ──────────────────────────────────────────────────
3483
3484    #[test]
3485    fn comment_whitespace_is_unicode_whitespace_plus_bom() {
3486        for ch in [
3487            '\u{20}', '\t', '\n', '\u{a0}', '\u{3000}', '\u{85}', '\u{2028}', '\u{feff}',
3488        ] {
3489            assert!(is_comment_space(ch), "{ch:?}");
3490        }
3491        for ch in ['\u{200b}', 'a', '-'] {
3492            assert!(!is_comment_space(ch), "{ch:?}");
3493        }
3494    }
3495
3496    #[test]
3497    fn bom_counts_as_space_in_every_part_of_a_directive() {
3498        let content = "<!--\u{feff}rumdl-disable\u{feff}MD013\u{feff}MD001\u{feff}-->\nx\n";
3499        assert_eq!(disabled_lines(content, "MD013"), [2, 3]);
3500        assert_eq!(disabled_lines(content, "MD001"), [2, 3]);
3501        let zero_width = "<!--\u{200b}rumdl-disable MD013 -->\nx\n";
3502        assert!(disabled_lines(zero_width, "MD013").is_empty());
3503    }
3504
3505    #[test]
3506    fn prettier_ignore_accepts_the_same_whitespace() {
3507        assert!(prettier_ignore_len("prettier-ignore\u{feff}-->").is_some());
3508        assert!(prettier_ignore_len("prettier-ignore\u{200b}-->").is_none());
3509    }
3510
3511    // ── tool comment recogniser ─────────────────────────────────────────────
3512
3513    fn is_tool_comment(text: &str) -> bool {
3514        !config_comment_ranges(text, |_| false).is_empty()
3515    }
3516
3517    #[test]
3518    fn tool_comment_recognises_every_tool_in_any_spelling() {
3519        for text in [
3520            "<!-- rumdl-disable MD013 -->",
3521            "<!--markdownlint-disable-->",
3522            "<!-- vale off -->",
3523            "<!--vale off-->",
3524            "<!--  vale on  -->",
3525            "<!-- vale Style.Rule = NO -->",
3526            "<!-- vale style = X -->",
3527            "<!-- lint disable no-javascript -->",
3528            "<!--lint disable x-->",
3529            "<!--  lint enable x -->",
3530            "<!--\tlint ignore x\t-->",
3531            "<!--\u{feff}lint disable x-->",
3532            "<!--\nlint disable x\n-->",
3533        ] {
3534            assert!(is_tool_comment(text), "{text:?}");
3535        }
3536        for text in [
3537            "<!-- vale offset javascript -->",
3538            "<!-- vale onboarding javascript -->",
3539            "<!-- vale style guide javascript -->",
3540            "<!-- lint disabled x -->",
3541            "<!-- linting disable x -->",
3542            "<!-- lint -->",
3543            "<!-- valet off -->",
3544            "<!-- vale -->",
3545            "<!-- plain comment -->",
3546        ] {
3547            assert!(!is_tool_comment(text), "{text:?}");
3548        }
3549    }
3550
3551    #[test]
3552    fn config_comment_ranges_cover_tool_comments_only() {
3553        let content = "a\n<!--\nlint disable x\n-->\nb\n<!-- vale off -->\n<!--\nplain\n-->\n";
3554        let ranges = config_comment_ranges(content, |_| false);
3555        let texts: Vec<&str> = ranges.iter().map(|range| &content[range.clone()]).collect();
3556        assert_eq!(texts, ["<!--\nlint disable x\n-->", "<!-- vale off -->"]);
3557    }
3558
3559    #[test]
3560    fn config_comment_ranges_stop_at_the_comment_edges() {
3561        let content = "prose <!--vale off--> tail\n";
3562        let ranges = config_comment_ranges(content, |_| false);
3563        assert_eq!(&content[ranges[0].clone()], "<!--vale off-->");
3564    }
3565
3566    #[test]
3567    fn config_comment_ranges_skip_code_openers_and_end_an_unclosed_one_at_its_line() {
3568        let content = "<!-- vale off\nprose\n";
3569        assert_eq!(config_comment_ranges(content, |_| false), vec![0..13]);
3570        assert!(config_comment_ranges(content, |_| true).is_empty());
3571    }
3572
3573    #[test]
3574    fn blockquote_tracker_classifies_each_line_once_in_order() {
3575        let text = "<!-- a --> <!-- b -->\n> q <!-- c --> <!-- d -->\n- > l <!-- e -->\nx > y <!-- f -->\n1 > 0 <!-- g -->\n\n> <!-- h -->";
3576        let mut tracker = BlockquoteTracker::new();
3577        let got: Vec<usize> = text
3578            .match_indices(COMMENT_OPEN)
3579            .map(|(open, _)| tracker.quote_depth_at(text, open))
3580            .collect();
3581        assert_eq!(got, [0, 0, 1, 1, 1, 0, 0, 1]);
3582    }
3583
3584    #[test]
3585    fn quote_prefix_needs_real_container_syntax() {
3586        for (line, depth) in [
3587            ("> x", 1),
3588            (">> x", 2),
3589            ("  > x", 1),
3590            ("- > x", 1),
3591            ("1. > x", 1),
3592            ("10) - > x", 1),
3593            ("> - > x", 2),
3594            (">x", 1),
3595            ("x", 0),
3596            ("1 > 0", 0),
3597            ("a > b", 0),
3598            ("-> x", 0),
3599            ("1.> x", 0),
3600            ("-x > y", 0),
3601            ("- x > y", 0),
3602            ("1234567890. > x", 0),
3603            ("", 0),
3604            ("- x", 0),
3605        ] {
3606            assert_eq!(line_quote_depth(line), depth, "{line:?}");
3607        }
3608    }
3609
3610    #[test]
3611    fn config_comment_ranges_unterminated_openers_linear_complexity() {
3612        // Every opener is an unterminated tool comment: none may search the rest
3613        // of the document for a closer again. Compares the cost of one doubling.
3614        let cost = |count: usize| {
3615            let content = "<!-- vale off\n".repeat(count);
3616            let mut best = std::time::Duration::MAX;
3617            for _ in 0..5 {
3618                let start = std::time::Instant::now();
3619                let ranges = config_comment_ranges(std::hint::black_box(&content), |_| false);
3620                best = best.min(start.elapsed());
3621                assert_eq!(ranges.len(), count);
3622            }
3623            best.as_secs_f64()
3624        };
3625        let (small, large) = (cost(20_000), cost(80_000));
3626        assert!(
3627            large / small < 8.0,
3628            "4x the openers took {:.1}x the time (linear is about 4x, quadratic about 16x)",
3629            large / small
3630        );
3631    }
3632
3633    #[test]
3634    fn offset_in_ranges_matches_a_linear_scan() {
3635        let ranges = vec![2..5, 5..6, 9..12, 20..21];
3636        for offset in 0..25 {
3637            assert_eq!(
3638                offset_in_ranges(&ranges, offset),
3639                ranges.iter().any(|range| range.contains(&offset)),
3640                "offset {offset}"
3641            );
3642        }
3643        assert!(!offset_in_ranges(&[], 0));
3644    }
3645
3646    #[test]
3647    fn a_code_opener_does_not_swallow_the_directive_after_it() {
3648        let content = "```\n<!-- rumdl-disable\n```\n\n<!--\nrumdl-disable MD013\n-->\n";
3649        let kept = scan_multiline_directives(content, |open| open < 20);
3650        assert_eq!(kept.len(), 1);
3651        assert_eq!(kept[0].start_line, 5);
3652    }
3653
3654    #[test]
3655    fn comment_body_skips_blockquote_markers_on_later_lines_only() {
3656        assert_eq!(comment_body("\n> rumdl-disable", 1), "rumdl-disable");
3657        assert_eq!(comment_body("\n>> \t> rumdl-disable", 3), "rumdl-disable");
3658        assert_eq!(
3659            comment_body("> rumdl-disable", 1),
3660            "> rumdl-disable",
3661            "same line: not a marker"
3662        );
3663        assert_eq!(
3664            comment_body("\n> rumdl-disable", 0),
3665            "> rumdl-disable",
3666            "outside a blockquote the `>` is comment text"
3667        );
3668        assert_eq!(
3669            comment_body("\n> > rumdl-disable", 1),
3670            "> rumdl-disable",
3671            "a marker beyond the enclosing depth is text"
3672        );
3673        assert_eq!(comment_body("  \n  ", 1), "");
3674    }
3675
3676    #[test]
3677    fn multi_line_directive_in_a_blockquote_splits_rules_across_marked_lines() {
3678        let content = "> <!-- rumdl-disable MD013\n> MD009\n> -->\n> text\n";
3679        let found = scan_multiline_directives(content, |_| false);
3680        assert_eq!(found.len(), 1);
3681        assert_eq!(found[0].rules, ["MD013", "MD009"]);
3682        assert_eq!((found[0].start_line, found[0].end_line), (1, 3));
3683    }
3684
3685    #[test]
3686    fn an_unclosed_configure_file_example_in_code_does_not_swallow_the_directive_after_it() {
3687        let content = "```\n<!-- rumdl-configure-file\n```\n\n<!--\nrumdl-disable MD013\n-->\ntext\n";
3688        let code_end = content.find("\n\n<!--\n").unwrap();
3689        let found = scan_multiline_directives(content, |open| open < code_end);
3690        assert_eq!(found.len(), 1);
3691        assert_eq!(found[0].rules, ["MD013"]);
3692    }
3693}