1use 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
32pub(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
38pub 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
54pub(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
70const COMMENT_OPEN: &str = "<!--";
72
73pub(crate) fn is_comment_space(ch: char) -> bool {
82 ch.is_whitespace() || ch == '\u{feff}'
83}
84
85fn 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
110fn 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
123fn 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
154struct BlockquoteTracker {
163 scanned_to: usize,
164 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
193fn 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
205fn 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
213fn at_word_end(rest: &str) -> bool {
215 rest.is_empty() || rest.starts_with(is_comment_space) || rest.starts_with("-->")
216}
217
218fn 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
239fn 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
253pub(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 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
295pub(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
303pub type FileIndexExport = (
306 HashSet<String>,
307 Vec<(usize, HashSet<String>, HashSet<String>)>,
308 HashMap<usize, HashSet<String>>,
309);
310
311#[derive(Debug, Clone)]
315struct StateTransition {
316 line: usize,
318 disabled: HashSet<String>,
320 enabled: HashSet<String>,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum DisableLayer {
330 File,
332 Block,
334 Line,
336}
337
338#[derive(Debug, Clone)]
339pub struct InlineConfig {
340 transitions: Vec<StateTransition>,
343 line_disabled_rules: HashMap<usize, HashSet<String>>,
345 file_disabled_rules: HashSet<String>,
347 file_enabled_rules: HashSet<String>,
349 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 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
374 if self.transitions.is_empty() {
375 return None;
376 }
377 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 pub fn from_content(content: &str) -> Self {
392 if !has_inline_config_markers(content) {
393 return Self::new();
394 }
395
396 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 pub fn from_content_with_code_blocks(
404 content: &str,
405 code_blocks: &[(usize, usize)],
406 code_spans: &[(usize, usize)],
407 ) -> Self {
408 if !has_inline_config_markers(content) {
409 return Self::new();
410 }
411
412 Self::from_content_with_code_blocks_internal(content, code_blocks, code_spans)
413 }
414
415 fn from_content_with_code_blocks_internal(
416 content: &str,
417 code_blocks: &[(usize, usize)],
418 code_spans: &[(usize, usize)],
419 ) -> Self {
420 let mut config = Self::new();
421 let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
422 for (offset, json_config) in scan_configure_file_comments(content) {
430 if in_code.code_blocks.contains(offset) || in_code.code_spans.contains(offset) {
431 continue;
432 }
433 let Some(obj) = json_config.as_object() else {
434 continue;
435 };
436 for (rule_name, rule_config) in obj {
437 let normalized = normalize_rule_name(rule_name);
441 if let Some(enabled) = rule_config.as_bool() {
442 if enabled {
443 config.file_disabled_rules.remove(&normalized);
444 } else {
445 config.file_disabled_rules.insert(normalized);
446 }
447 continue;
448 }
449 config.file_rule_config.insert(normalized, rule_config.clone());
453 }
454 }
455
456 let mut currently_disabled: HashSet<String> = HashSet::new();
458 let mut currently_enabled: HashSet<String> = HashSet::new();
459 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
460
461 let mut prev_disabled: HashSet<String> = HashSet::new();
463 let mut prev_enabled: HashSet<String> = HashSet::new();
464
465 config.transitions.push(StateTransition {
467 line: 1,
468 disabled: HashSet::new(),
469 enabled: HashSet::new(),
470 });
471
472 let mut multiline_by_end_line: HashMap<usize, Vec<MultilineDirective<'_>>> = HashMap::new();
475 for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
476 multiline_by_end_line
477 .entry(directive.end_line)
478 .or_default()
479 .push(directive);
480 }
481
482 let mut position = 0;
483 let mut line_count = 0;
484 for (idx, line) in content.split('\n').enumerate() {
485 let line_num = idx + 1; line_count = line_num;
487 let line_position = position;
488 position += line.len() + 1;
489
490 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(¤tly_disabled);
499 prev_enabled.clone_from(¤tly_enabled);
500 }
501
502 let in_code_block = line_in_code_block(line_position, line, &in_code.code_blocks);
504
505 let ending_here = multiline_by_end_line.remove(&line_num).unwrap_or_default();
508 let single_line = if in_code_block {
509 Vec::new()
510 } else {
511 directives_outside_code_spans(line, line_position, &in_code.code_spans)
512 };
513 let directives: Vec<InlineDirective<'_>> = ending_here
514 .into_iter()
515 .map(|multiline| InlineDirective {
516 kind: multiline.kind,
517 rules: multiline.rules,
518 span: 0..0,
519 })
520 .chain(single_line)
521 .collect();
522
523 let has_prettier_ignore = !in_code_block
525 && line.match_indices(COMMENT_OPEN).any(|(offset, _)| {
526 let body = comment_body(&line[offset + COMMENT_OPEN.len()..], 0);
527 prettier_ignore_len(body).is_some() && !in_code.code_spans.contains(line_position + offset)
528 });
529
530 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 _ => {}
563 }
564 }
565
566 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 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
630 }
631 }
632
633 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 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 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 pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
665 if self.file_disabled_rules.contains("*") {
666 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 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 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 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
696 let mut disabled = HashSet::new();
697
698 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 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 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
721 self.file_rule_config.get(rule_name)
722 }
723
724 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
726 &self.file_rule_config
727 }
728
729 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#[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#[derive(Debug, Clone, PartialEq)]
771pub struct InlineDirective<'a> {
772 pub kind: DirectiveKind,
773 pub rules: Vec<&'a str>,
774 pub span: std::ops::Range<usize>,
776}
777
778const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
780
781const 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
796fn 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 if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
815 continue;
816 }
817
818 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; return Some((kind, rules, consumed));
831 }
832
833 return None;
835 }
836 None
837}
838
839pub 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#[derive(Debug, Clone, PartialEq)]
880struct MultilineDirective<'a> {
881 kind: DirectiveKind,
882 rules: Vec<&'a str>,
883 open: usize,
885 end: usize,
887 start_line: usize,
889 end_line: usize,
891}
892
893fn 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 let mut counted_to = 0;
911 let mut lines_before = 0;
912 let mut next_close: Option<usize> = None;
915 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 None => break,
928 },
929 };
930 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 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
973struct CommentCodeTest<'a> {
979 content: &'a str,
980 code_blocks: CodeRangeIndex<'a>,
981 code_spans: CodeRangeIndex<'a>,
982 line: std::cell::Cell<Option<(usize, usize, bool)>>,
984}
985
986impl<'a> CommentCodeTest<'a> {
987 fn new(content: &'a str, code_blocks: &'a [(usize, usize)], code_spans: &'a [(usize, usize)]) -> Self {
988 Self {
989 content,
990 code_blocks: CodeRangeIndex::new(code_blocks),
991 code_spans: CodeRangeIndex::new(code_spans),
992 line: std::cell::Cell::new(None),
993 }
994 }
995
996 fn contains(&self, open: usize) -> bool {
997 if self.code_blocks.contains(open) || self.code_spans.contains(open) {
998 return true;
999 }
1000 if let Some((start, end, in_code)) = self.line.get()
1001 && (start..end).contains(&open)
1002 {
1003 return in_code;
1004 }
1005 let line_start = self.content[..open].rfind('\n').map_or(0, |newline| newline + 1);
1006 let line_end = self.content[line_start..]
1007 .find('\n')
1008 .map_or(self.content.len(), |newline| line_start + newline);
1009 let in_code = line_in_code_block(line_start, &self.content[line_start..line_end], &self.code_blocks);
1010 self.line.set(Some((line_start, line_end.max(line_start + 1), in_code)));
1011 in_code
1012 }
1013}
1014
1015fn 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
1029fn 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
1041pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
1043 find_directive_rules(line, DirectiveKind::Disable)
1044}
1045
1046pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
1048 find_directive_rules(line, DirectiveKind::Enable)
1049}
1050
1051pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
1053 find_directive_rules(line, DirectiveKind::DisableLine)
1054}
1055
1056pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
1058 find_directive_rules(line, DirectiveKind::DisableNextLine)
1059}
1060
1061pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
1063 find_directive_rules(line, DirectiveKind::DisableFile)
1064}
1065
1066pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
1068 find_directive_rules(line, DirectiveKind::EnableFile)
1069}
1070
1071pub fn is_capture_comment(line: &str) -> bool {
1073 parse_inline_directives(line)
1074 .iter()
1075 .any(|d| d.kind == DirectiveKind::Capture)
1076}
1077
1078pub 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
1087struct CodeRangeIndex<'a>(std::borrow::Cow<'a, [(usize, usize)]>);
1091
1092impl<'a> CodeRangeIndex<'a> {
1093 fn new(ranges: &'a [(usize, usize)]) -> Self {
1094 if ranges.iter().all(|&(start, end)| start < end) && ranges.windows(2).all(|pair| pair[0].1 <= pair[1].0) {
1095 return Self(std::borrow::Cow::Borrowed(ranges));
1096 }
1097 let mut sorted: Vec<_> = ranges.iter().copied().filter(|&(start, end)| start < end).collect();
1098 sorted.sort_unstable_by_key(|&(start, _)| start);
1099 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
1100 for (start, end) in sorted {
1101 if let Some(last) = merged.last_mut()
1102 && start <= last.1
1103 {
1104 last.1 = last.1.max(end);
1105 } else {
1106 merged.push((start, end));
1107 }
1108 }
1109 Self(std::borrow::Cow::Owned(merged))
1110 }
1111
1112 fn contains(&self, offset: usize) -> bool {
1113 let after = self.0.partition_point(|&(_, end)| end <= offset);
1114 self.0.get(after).is_some_and(|&(start, _)| start <= offset)
1115 }
1116}
1117
1118fn directives_outside_code_spans<'a>(
1124 line: &'a str,
1125 line_start: usize,
1126 code_spans: &CodeRangeIndex<'_>,
1127) -> Vec<InlineDirective<'a>> {
1128 let mut directives = parse_inline_directives(line);
1129 if !code_spans.0.is_empty() {
1130 directives.retain(|directive| !code_spans.contains(line_start + directive.span.start));
1131 }
1132 directives
1133}
1134
1135fn line_in_code_block(line_start: usize, line: &str, code_blocks: &CodeRangeIndex<'_>) -> bool {
1143 let probe = line
1144 .find(|c: char| !c.is_whitespace())
1145 .map_or(line_start, |indent| line_start + indent);
1146 code_blocks.contains(probe)
1147}
1148
1149type LocatedWarning = (InlineConfigWarning, usize);
1154
1155fn drop_warnings_inside_code(
1164 content: &str,
1165 flavor: MarkdownFlavor,
1166 mut warnings: Vec<LocatedWarning>,
1167) -> Vec<InlineConfigWarning> {
1168 if warnings.is_empty() {
1169 return Vec::new();
1170 }
1171 let code = crate::lint_context::code_ranges(content, flavor);
1172 if !code.blocks.is_empty() {
1173 let blocks = CodeRangeIndex::new(&code.blocks);
1174 let line_spans: Vec<(usize, &str)> = {
1179 let mut spans = Vec::new();
1180 let mut start = 0;
1181 for line in content.split('\n') {
1182 spans.push((start, line));
1183 start += line.len() + 1;
1184 }
1185 spans
1186 };
1187
1188 warnings.retain(|(warning, _)| {
1189 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
1190 return true;
1191 };
1192 !line_in_code_block(line_start, line, &blocks)
1193 });
1194 }
1195 if !code.spans.is_empty() {
1196 let spans = CodeRangeIndex::new(&code.spans);
1197 warnings.retain(|&(_, offset)| !spans.contains(offset));
1198 }
1199
1200 warnings.into_iter().map(|(warning, _)| warning).collect()
1201}
1202
1203fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
1214 let mut found = Vec::new();
1215 let mut pos = 0;
1216 let mut quotes = BlockquoteTracker::new();
1217
1218 while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
1219 let comment_start = pos + open_offset;
1220 let after_open = comment_body(
1221 &text[comment_start + COMMENT_OPEN.len()..],
1222 quotes.quote_depth_at(text, comment_start),
1223 );
1224 let body_start = text.len() - after_open.len();
1225 pos = comment_start + COMMENT_OPEN.len();
1228
1229 for tool in TOOL_PREFIXES {
1230 let Some(after_tool) = after_open.strip_prefix(tool) else {
1231 continue;
1232 };
1233 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
1234 break;
1235 };
1236 if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
1239 break;
1240 }
1241 let Some(close_offset) = after_kw.find("-->") else {
1242 break;
1243 };
1244
1245 let json_str = after_kw[..close_offset].trim_matches(is_comment_space);
1246 if !json_str.is_empty()
1247 && let Ok(value) = serde_json::from_str(json_str)
1248 {
1249 found.push((comment_start, value));
1250 }
1251 pos = body_start + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
1252 break;
1253 }
1254 }
1255
1256 found
1257}
1258
1259pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
1263 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
1264}
1265
1266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270pub enum DisableScope {
1271 Line(usize),
1273 Block(usize),
1275 File,
1277}
1278
1279impl DisableScope {
1280 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
1292 match (self, layer) {
1293 (Self::Line(target), DisableLayer::Line) => target == line,
1294 (Self::Block(start), DisableLayer::Block) => start < line,
1296 (Self::File, DisableLayer::File) => true,
1297 _ => false,
1298 }
1299 }
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Eq)]
1304pub struct DisableSite {
1305 pub line: usize,
1307 pub span: std::ops::Range<usize>,
1309 pub kind: &'static str,
1311 pub rules: Vec<String>,
1313 pub scope: DisableScope,
1315}
1316
1317pub fn collect_disable_sites(
1329 content: &str,
1330 code_blocks: &[(usize, usize)],
1331 code_spans: &[(usize, usize)],
1332) -> Vec<DisableSite> {
1333 if !has_inline_config_markers(content) {
1334 return Vec::new();
1335 }
1336
1337 let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
1338 let line_spans: Vec<(usize, &str)> = {
1341 let mut spans = Vec::new();
1342 let mut start = 0;
1343 for line in content.split('\n') {
1344 spans.push((start, line));
1345 start += line.len() + 1;
1346 }
1347 spans
1348 };
1349
1350 let mut sites = Vec::new();
1351
1352 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
1353 if line_in_code_block(line_start, line, &in_code.code_blocks) {
1354 continue;
1355 }
1356 let line_num = idx + 1;
1357 for directive in directives_outside_code_spans(line, line_start, &in_code.code_spans) {
1358 let (kind, scope) = match directive.kind {
1359 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
1360 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
1361 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
1362 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1363 DirectiveKind::Enable
1364 | DirectiveKind::EnableFile
1365 | DirectiveKind::Capture
1366 | DirectiveKind::Restore
1367 | DirectiveKind::ConfigureFile => continue,
1368 };
1369 sites.push(DisableSite {
1370 line: line_num,
1371 span: directive.span,
1372 kind,
1373 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1374 scope,
1375 });
1376 }
1377 }
1378
1379 for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
1382 let (kind, scope) = match directive.kind {
1383 DirectiveKind::Disable => ("disable", DisableScope::Block(directive.end_line)),
1384 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(directive.end_line)),
1385 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(directive.end_line + 1)),
1386 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1387 _ => continue,
1388 };
1389 let Some(&(line_start, line)) = line_spans.get(directive.start_line - 1) else {
1390 continue;
1391 };
1392 sites.push(DisableSite {
1393 line: directive.start_line,
1394 span: (directive.open - line_start)..(directive.end - line_start).min(line.len()),
1395 kind,
1396 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1397 scope,
1398 });
1399 }
1400
1401 for (offset, json_config) in scan_configure_file_comments(content) {
1405 if in_code.code_blocks.contains(offset) || in_code.code_spans.contains(offset) {
1406 continue;
1407 }
1408 let Some(obj) = json_config.as_object() else {
1409 continue;
1410 };
1411 let rules: Vec<String> = obj
1412 .iter()
1413 .filter(|(_, value)| value.as_bool() == Some(false))
1414 .map(|(name, _)| name.clone())
1415 .collect();
1416 if rules.is_empty() {
1417 continue;
1418 }
1419 let line_num = line_spans.partition_point(|&(start, _)| start <= offset);
1420 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
1421 continue;
1422 };
1423 let comment_end = content[offset..]
1424 .find("-->")
1425 .map_or(content.len(), |close| offset + close + 3);
1426 sites.push(DisableSite {
1427 line: line_num,
1428 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
1429 kind: "configure-file",
1430 rules,
1431 scope: DisableScope::File,
1432 });
1433 }
1434
1435 sites.sort_by_key(|site| (site.line, site.span.start));
1436 sites
1437}
1438
1439#[derive(Debug, Clone, PartialEq, Eq)]
1441pub enum InlineConfigProblem {
1442 UnknownRule,
1444 UnknownOption { key: String },
1447 EnableHasNoEffect { reason: EnableNoEffectReason },
1451}
1452
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458pub enum EnableNoEffectReason {
1459 NotEnabled,
1461 IgnoredForFile,
1464}
1465
1466#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct InlineConfigWarning {
1469 pub line_number: usize,
1471 pub rule_name: String,
1473 pub comment_type: String,
1475 pub suggestion: Option<String>,
1477 pub problem: InlineConfigProblem,
1479}
1480
1481impl InlineConfigWarning {
1482 pub fn format_message(&self) -> String {
1484 match &self.problem {
1487 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1488 Some(ref suggestion) => format!(
1489 "Unknown option for rule {}: {} (did you mean: {}?)",
1490 self.rule_name, key, suggestion
1491 ),
1492 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1493 },
1494 InlineConfigProblem::UnknownRule => match self.suggestion {
1495 Some(ref suggestion) => format!(
1496 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1497 self.comment_type, self.rule_name, suggestion
1498 ),
1499 None => format!(
1500 "Unknown rule in inline {} comment: {}",
1501 self.comment_type, self.rule_name
1502 ),
1503 },
1504 InlineConfigProblem::EnableHasNoEffect {
1505 reason: EnableNoEffectReason::NotEnabled,
1506 } => format!(
1507 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1508 self.rule_name, self.comment_type
1509 ),
1510 InlineConfigProblem::EnableHasNoEffect {
1511 reason: EnableNoEffectReason::IgnoredForFile,
1512 } => format!(
1513 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1514 self.rule_name, self.comment_type
1515 ),
1516 }
1517 }
1518
1519 pub fn print_warning(&self, file_path: &str) {
1521 eprintln!(
1522 "{} {}:{}: {}",
1523 crate::utils::warning_label("inline config warning"),
1524 file_path,
1525 self.line_number,
1526 self.format_message()
1527 );
1528 }
1529
1530 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1538 let line_width = content
1539 .lines()
1540 .nth(self.line_number.saturating_sub(1))
1541 .map_or(0, |line| line.chars().count());
1542 crate::rule::LintWarning {
1543 message: self.format_message(),
1544 line: self.line_number,
1545 column: 1,
1546 end_line: self.line_number,
1547 end_column: line_width + 1,
1548 severity: crate::rule::Severity::Warning,
1549 fix: None,
1550 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1551 }
1552 }
1553}
1554
1555pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1560
1561pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1570 let (warnings, _) = collect_inline_config_warnings(content, flavor, true, None);
1571 drop_warnings_inside_code(content, flavor, warnings)
1572}
1573
1574pub fn validate_inline_enables_against_active_rules(
1579 content: &str,
1580 flavor: MarkdownFlavor,
1581 active_rules: &HashSet<String>,
1582 ignored_for_file: &HashSet<String>,
1583) -> Vec<InlineConfigWarning> {
1584 let (_, warnings) = collect_inline_config_warnings(content, flavor, false, Some((active_rules, ignored_for_file)));
1585 drop_warnings_inside_code(content, flavor, warnings)
1586}
1587
1588pub fn validate_inline_configuration(
1592 content: &str,
1593 flavor: MarkdownFlavor,
1594 active_rules: &HashSet<String>,
1595 ignored_for_file: &HashSet<String>,
1596) -> Vec<InlineConfigWarning> {
1597 let (mut warnings, enables) =
1598 collect_inline_config_warnings(content, flavor, true, Some((active_rules, ignored_for_file)));
1599 warnings.extend(enables);
1600 drop_warnings_inside_code(content, flavor, warnings)
1601}
1602
1603fn collect_inline_config_warnings(
1606 content: &str,
1607 flavor: MarkdownFlavor,
1608 validate_names: bool,
1609 active: Option<(&HashSet<String>, &HashSet<String>)>,
1610) -> (Vec<LocatedWarning>, Vec<LocatedWarning>) {
1611 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1612
1613 if !has_inline_config_markers(content) {
1614 return (Vec::new(), Vec::new());
1615 }
1616 let mut warnings = Vec::new();
1617 let mut enables = Vec::new();
1618 let all_rule_names = std::cell::OnceCell::<Vec<String>>::new();
1620 let suggest = |name: &str| {
1621 let names =
1622 all_rule_names.get_or_init(|| RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect());
1623 suggest_similar_key(name, names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1624 };
1625 let mut flag_enable = |name: &str, comment_type: &str, line: usize, offset: usize| {
1626 let Some((active_rules, ignored_for_file)) = active else {
1627 return;
1628 };
1629 if !is_valid_rule_name(name) {
1630 return;
1631 }
1632 let canonical = normalize_rule_name(name);
1633 let reason = if !active_rules.contains(&canonical) {
1634 EnableNoEffectReason::NotEnabled
1635 } else if ignored_for_file.contains(&canonical) {
1636 EnableNoEffectReason::IgnoredForFile
1637 } else {
1638 return;
1639 };
1640 enables.push((
1641 InlineConfigWarning {
1642 line_number: line,
1643 rule_name: canonical,
1644 comment_type: comment_type.to_string(),
1645 suggestion: None,
1646 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1647 },
1648 offset,
1649 ));
1650 };
1651
1652 let mut previous_offset = 0;
1655 let mut line_number = 1;
1656 for (offset, json_config) in scan_configure_file_comments(content) {
1657 let Some(obj) = json_config.as_object() else {
1658 continue;
1659 };
1660 line_number += content[previous_offset..offset].bytes().filter(|&b| b == b'\n').count();
1661 previous_offset = offset;
1662 for (rule_name, rule_config) in obj {
1663 if validate_names {
1664 if !is_valid_rule_name(rule_name) {
1665 warnings.push((
1666 InlineConfigWarning {
1667 line_number,
1668 rule_name: rule_name.clone(),
1669 comment_type: "configure-file".to_string(),
1670 suggestion: suggest(rule_name),
1671 problem: InlineConfigProblem::UnknownRule,
1672 },
1673 offset,
1674 ));
1675 continue;
1676 }
1677 if let Some(options) = rule_config.as_object() {
1678 let canonical = normalize_rule_name(rule_name);
1679 if let Some(valid_keys) = crate::config::default_registry().config_keys_for(&canonical) {
1682 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1683 for key in options.keys().filter(|key| !valid_keys.contains(*key)) {
1684 warnings.push((
1685 InlineConfigWarning {
1686 line_number,
1687 rule_name: canonical.clone(),
1688 comment_type: "configure-file".to_string(),
1689 suggestion: suggest_similar_key(key, &valid_keys_vec),
1690 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1691 },
1692 offset,
1693 ));
1694 }
1695 }
1696 }
1697 }
1698 if rule_config.as_bool() == Some(true) {
1699 flag_enable(rule_name, "configure-file", line_number, offset);
1700 }
1701 }
1702 }
1703
1704 let mut named: Vec<(DirectiveKind, Vec<&str>, usize, usize)> = Vec::new();
1709 let mut line_start = 0;
1710 for (idx, line) in content.split('\n').enumerate() {
1711 for directive in parse_inline_directives(line) {
1712 named.push((
1713 directive.kind,
1714 directive.rules,
1715 idx + 1,
1716 line_start + directive.span.start,
1717 ));
1718 }
1719 line_start += line.len() + 1;
1720 }
1721 let code = std::cell::OnceCell::new();
1724 let code_test = std::cell::OnceCell::new();
1725 let multiline_in_code = |open: usize| {
1726 code_test
1727 .get_or_init(|| {
1728 let ranges = code.get_or_init(|| crate::lint_context::code_ranges(content, flavor));
1729 CommentCodeTest::new(content, &ranges.blocks, &ranges.spans)
1730 })
1731 .contains(open)
1732 };
1733 for directive in scan_multiline_directives(content, multiline_in_code) {
1734 named.push((directive.kind, directive.rules, directive.start_line, directive.open));
1735 }
1736 for (kind, rules, line_number, offset) in named {
1737 let Some(comment_type) = directive_comment_type(kind) else {
1738 continue;
1739 };
1740 for rule_name in rules {
1741 if validate_names && !is_valid_rule_name(rule_name) {
1742 warnings.push((
1743 InlineConfigWarning {
1744 line_number,
1745 rule_name: rule_name.to_string(),
1746 comment_type: comment_type.to_string(),
1747 suggestion: suggest(rule_name),
1748 problem: InlineConfigProblem::UnknownRule,
1749 },
1750 offset,
1751 ));
1752 }
1753 if matches!(kind, DirectiveKind::Enable | DirectiveKind::EnableFile) {
1754 flag_enable(rule_name, comment_type, line_number, offset);
1755 }
1756 }
1757 }
1758 warnings.sort_by_key(|(warning, _)| warning.line_number);
1759 enables.sort_by_key(|(warning, _)| warning.line_number);
1760 (warnings, enables)
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765 use super::*;
1766
1767 #[test]
1768 fn code_range_index_preserves_arbitrary_range_membership() {
1769 let mut cases = vec![
1770 vec![],
1771 vec![(0, 2), (2, 5), (8, 10)],
1772 vec![(8, 20), (0, 12), (4, 6), (3, 3), (9, 1)],
1773 vec![(0, usize::MAX), (usize::MAX, usize::MAX)],
1774 vec![(usize::MAX - 1, usize::MAX), (5, 2)],
1775 ];
1776 for seed in 0..32 {
1777 cases.push(
1778 (0..16)
1779 .map(|i| ((i * 7 + seed * 11) % 61, (i * 13 + seed * 17) % 61))
1780 .collect(),
1781 );
1782 }
1783 for mut ranges in cases {
1784 for reverse in [false, true] {
1785 if reverse {
1786 ranges.reverse();
1787 }
1788 let index = CodeRangeIndex::new(&ranges);
1789 for offset in (0..64).chain([usize::MAX - 1, usize::MAX]) {
1790 let expected = ranges.iter().any(|&(start, end)| start <= offset && offset < end);
1791 assert_eq!(index.contains(offset), expected, "{ranges:?}, byte {offset}");
1792 }
1793 }
1794 }
1795 }
1796
1797 #[test]
1798 fn configure_file_positions_match_source_lines() {
1799 for separator in [" ", "\n", "\r\n"] {
1800 let source = [
1801 "é日本🙂",
1802 "<!-- rumdl-configure-file [] -->",
1803 "<!-- rumdl-configure-file {\"MD013\":false,\"unknown-rule\":false} -->",
1804 "<!-- rumdl-configure-file broken -->",
1805 "<!-- rumdl-configure-file\n{\"MD013\":false,\"unknown-rule\":false}\n-->",
1806 "<!-- rumdl-configure-file null -->",
1807 "<!-- rumdl-configure-file {\"MD013\":false,\"unknown-rule\":false} -->",
1808 ]
1809 .join(if separator == "\r\n" { "\n" } else { separator })
1810 .replace('\n', if separator == "\r\n" { "\r\n" } else { "\n" });
1811 let expected: Vec<_> = scan_configure_file_comments(&source)
1812 .into_iter()
1813 .filter(|(_, value)| value.is_object())
1814 .map(|(offset, _)| source[..offset].bytes().filter(|&byte| byte == b'\n').count() + 1)
1815 .collect();
1816 assert_eq!(expected.len(), 3);
1817 let sites = collect_disable_sites(&source, &[], &[]);
1818 assert_eq!(sites.iter().map(|site| site.line).collect::<Vec<_>>(), expected);
1819 let warnings = validate_inline_config_rules(&source, MarkdownFlavor::Standard);
1820 assert_eq!(
1821 warnings.iter().map(|warning| warning.line_number).collect::<Vec<_>>(),
1822 expected
1823 );
1824 }
1825 }
1826
1827 #[test]
1828 fn combined_validation_preserves_warning_order_and_literal_code() {
1829 let source = concat!(
1830 "<!-- rumdl-enable MD009 -->\n",
1831 "<!-- rumdl-disable MD999 -->\n",
1832 "<!-- rumdl-configure-file {\"MD011\":true,\"MD013\":{\"line-lenght\":40}} -->\n",
1833 "<!-- rumdl-enable MD013 -->\n",
1834 "```markdown\n",
1835 "<!-- rumdl-enable MD010 -->\n",
1836 "<!-- rumdl-disable MD998 -->\n",
1837 "```\n",
1838 "`<!-- rumdl-enable MD012 -->`\n",
1839 );
1840 let active = HashSet::from(["MD013".to_string()]);
1841 let ignored = active.clone();
1842 for ending in ["\n", "\r\n"] {
1843 let content = source.replace('\n', ending);
1844 for flavor in [
1845 MarkdownFlavor::Standard,
1846 MarkdownFlavor::MkDocs,
1847 MarkdownFlavor::MDX,
1848 MarkdownFlavor::Pandoc,
1849 MarkdownFlavor::Quarto,
1850 MarkdownFlavor::Obsidian,
1851 MarkdownFlavor::Kramdown,
1852 MarkdownFlavor::AzureDevOps,
1853 MarkdownFlavor::MyST,
1854 MarkdownFlavor::Hugo,
1855 MarkdownFlavor::MDG,
1856 MarkdownFlavor::GhAw,
1857 ] {
1858 let mut separate = validate_inline_config_rules(&content, flavor);
1859 separate.extend(validate_inline_enables_against_active_rules(
1860 &content, flavor, &active, &ignored,
1861 ));
1862 let combined = validate_inline_configuration(&content, flavor, &active, &ignored);
1863 assert_eq!(format!("{combined:?}"), format!("{separate:?}"), "{flavor:?}");
1864 assert_eq!(
1865 combined
1866 .iter()
1867 .map(|warning| (warning.rule_name.as_str(), warning.line_number))
1868 .collect::<Vec<_>>(),
1869 vec![("MD999", 2), ("MD013", 3), ("MD009", 1), ("MD011", 3), ("MD013", 4)],
1870 "{flavor:?}",
1871 );
1872 }
1873 }
1874 }
1875
1876 const COMPACT_OPENERS: &[&str] = &["<!--", "<!-- ", "<!-- ", "<!--\t", "<!-- \t "];
1879 const COMPACT_CLOSERS: &[&str] = &["-->", " -->", " -->", "\t-->"];
1880
1881 #[test]
1882 fn test_parse_inline_directives_whitespace_around_delimiters_is_optional() {
1883 let cases: &[(&str, DirectiveKind, &[&str])] = &[
1884 ("rumdl-disable MD013", DirectiveKind::Disable, &["MD013"]),
1885 (
1886 "markdownlint-disable MD013 MD009",
1887 DirectiveKind::Disable,
1888 &["MD013", "MD009"],
1889 ),
1890 ("markdownlint-disable", DirectiveKind::Disable, &[]),
1891 ("rumdl-enable", DirectiveKind::Enable, &[]),
1892 ("markdownlint-enable MD013", DirectiveKind::Enable, &["MD013"]),
1893 ("rumdl-disable-line MD013", DirectiveKind::DisableLine, &["MD013"]),
1894 (
1895 "markdownlint-disable-next-line MD013",
1896 DirectiveKind::DisableNextLine,
1897 &["MD013"],
1898 ),
1899 (
1900 "markdownlint-disable-file MD013",
1901 DirectiveKind::DisableFile,
1902 &["MD013"],
1903 ),
1904 ("rumdl-enable-file", DirectiveKind::EnableFile, &[]),
1905 ("markdownlint-capture", DirectiveKind::Capture, &[]),
1906 ("rumdl-restore", DirectiveKind::Restore, &[]),
1907 ("markdownlint-configure-file {}", DirectiveKind::ConfigureFile, &["{}"]),
1908 ];
1909 for &(body, kind, rules) in cases {
1910 for open in COMPACT_OPENERS {
1911 for close in COMPACT_CLOSERS {
1912 let comment = format!("{open}{body}{close}");
1913 let line = format!("text {comment} tail");
1915 let directives = parse_inline_directives(&line);
1916 assert_eq!(directives.len(), 1, "{line:?}");
1917 assert_eq!(directives[0].kind, kind, "{line:?}");
1918 assert_eq!(directives[0].rules, rules, "{line:?}");
1919 assert_eq!(&line[directives[0].span.clone()], comment, "{line:?}");
1920 }
1921 }
1922 }
1923 }
1924
1925 #[test]
1926 fn test_parse_inline_directives_compact_not_a_directive() {
1927 for line in [
1928 "<!--markdownlint-disablefoo -->",
1929 "<!--rumdl-disableMD013-->",
1930 "<!--rumdl-enablebar-->",
1931 "<!--rumdl-captures-->",
1932 "<!--RUMDL-disable MD009 -->",
1933 "<!--Markdownlint-Disable-->",
1934 "<!--note rumdl-disable MD009 -->",
1935 "<!---- rumdl-disable MD009 -->",
1936 "<!--rumdl-disable MD009",
1937 "<!--rumdl-",
1938 "<!--",
1939 ] {
1940 assert!(parse_inline_directives(line).is_empty(), "{line:?}");
1941 }
1942 }
1943
1944 #[test]
1945 fn test_parse_inline_directives_finds_compact_directive_after_other_comment() {
1946 let line = "<!-- other <!--rumdl-disable MD009--> <!--markdownlint-enable MD009-->";
1947 let directives = parse_inline_directives(line);
1948 assert_eq!(directives.len(), 2, "{line:?}");
1949 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1950 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1951 }
1952
1953 #[test]
1954 fn test_scan_configure_file_compact_and_multiline() {
1955 for text in [
1956 "<!--rumdl-configure-file {\"MD013\":false}-->",
1957 "<!--markdownlint-configure-file {\"MD013\":false} -->",
1958 "<!--\trumdl-configure-file\n{\n\"MD013\":false\n}\n-->",
1959 "<!--\nrumdl-configure-file {\"MD013\":false}-->",
1960 "x <!-- other --> <!--rumdl-configure-file {\"MD013\":false}-->",
1961 ] {
1962 let found = scan_configure_file_comments(text);
1963 assert_eq!(found.len(), 1, "{text:?}");
1964 assert_eq!(found[0].1, serde_json::json!({"MD013": false}), "{text:?}");
1965 assert_eq!(&text[found[0].0..found[0].0 + 4], "<!--", "{text:?}");
1966 }
1967 for text in [
1968 "<!--rumdl-configure-files {\"MD013\":false}-->",
1969 "<!--rumdl-configure-file{\"MD013\":false}-->",
1970 "<!--RUMDL-configure-file {\"MD013\":false}-->",
1971 "<!--rumdl-configure-file {\"MD013\":false}",
1972 ] {
1973 assert!(scan_configure_file_comments(text).is_empty(), "{text:?}");
1974 }
1975 let two = "<!--rumdl-configure-file {\"MD013\":false}--><!--rumdl-configure-file {\"MD009\":false}-->";
1977 assert_eq!(scan_configure_file_comments(two).len(), 2);
1978 }
1979
1980 #[test]
1981 fn test_from_content_compact_directives_match_spaced_form() {
1982 let pairs: &[(&str, &str)] = &[
1984 (
1985 "<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n",
1986 "<!-- rumdl-disable MD013 -->\nA\n<!-- rumdl-enable MD013 -->\nB\n",
1987 ),
1988 (
1989 "A <!--markdownlint-disable-line MD013-->\nB\n",
1990 "A <!-- markdownlint-disable-line MD013 -->\nB\n",
1991 ),
1992 (
1993 "<!--markdownlint-disable-next-line MD013-->\nA\nB\n",
1994 "<!-- markdownlint-disable-next-line MD013 -->\nA\nB\n",
1995 ),
1996 (
1997 "<!--rumdl-disable-file MD013-->\nA\n",
1998 "<!-- rumdl-disable-file MD013 -->\nA\n",
1999 ),
2000 (
2001 "<!--rumdl-disable-file-->\n<!--rumdl-enable-file MD013-->\nA\n",
2002 "<!-- rumdl-disable-file -->\n<!-- rumdl-enable-file MD013 -->\nA\n",
2003 ),
2004 (
2005 "<!--markdownlint-disable MD013-->\n<!--markdownlint-capture-->\n<!--markdownlint-enable-->\nA\n<!--markdownlint-restore-->\nB\n",
2006 "<!-- markdownlint-disable MD013 -->\n<!-- markdownlint-capture -->\n<!-- markdownlint-enable -->\nA\n<!-- markdownlint-restore -->\nB\n",
2007 ),
2008 ("<!--prettier-ignore-->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
2009 ("<!--prettier-ignore -->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
2010 (
2011 "<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n",
2012 "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":10}} -->\nA\n",
2013 ),
2014 (
2015 "<!--markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
2016 "<!-- markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
2017 ),
2018 (
2019 "> <!--rumdl-disable-next-line MD013-->\n> A\n",
2020 "> <!-- rumdl-disable-next-line MD013 -->\n> A\n",
2021 ),
2022 (
2023 "- item\n\n <!--rumdl-disable-next-line MD013-->\n A\n",
2024 "- item\n\n <!-- rumdl-disable-next-line MD013 -->\n A\n",
2025 ),
2026 ];
2027 for (compact, spaced) in pairs {
2028 let a = InlineConfig::from_content(compact);
2029 let b = InlineConfig::from_content(spaced);
2030 let lines = compact.split('\n').count() + 2;
2031 for line in 1..=lines {
2032 for rule in ["MD013", "MD009"] {
2033 assert_eq!(
2034 a.disabling_layer(rule, line),
2035 b.disabling_layer(rule, line),
2036 "{compact:?} vs {spaced:?}, {rule} at line {line}"
2037 );
2038 }
2039 }
2040 assert_eq!(a.get_all_rule_configs(), b.get_all_rule_configs(), "{compact:?}");
2041 }
2042 let c = InlineConfig::from_content("<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n");
2044 assert!(c.is_rule_disabled("MD013", 2));
2045 assert!(!c.is_rule_disabled("MD013", 4));
2046 let c = InlineConfig::from_content("<!--prettier-ignore-->\nA\nB\n");
2047 assert!(c.is_rule_disabled("MD013", 2));
2048 assert!(!c.is_rule_disabled("MD013", 3));
2049 let c = InlineConfig::from_content("<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n");
2050 assert!(c.get_rule_config("MD013").is_some());
2051 }
2052
2053 #[test]
2054 fn test_from_content_compact_directive_in_code_configures_nothing() {
2055 for content in [
2056 "`<!--rumdl-disable MD013-->`\nA\n",
2057 "```\n<!--rumdl-disable MD013-->\n```\nA\n",
2058 " <!--rumdl-disable MD013-->\n\nA\n",
2059 "`<!--rumdl-disable-file MD013-->`\nA\n",
2060 "`<!--prettier-ignore-->`\nA\n",
2061 "`<!--rumdl-configure-file {\"MD013\":false}-->`\nA\n",
2062 ] {
2063 let config = InlineConfig::from_content(content);
2064 for line in 1..=6 {
2065 assert!(!config.is_rule_disabled("MD013", line), "{content:?} line {line}");
2066 }
2067 assert!(config.get_all_rule_configs().is_empty(), "{content:?}");
2068 }
2069 }
2070
2071 #[test]
2072 fn test_validate_inline_config_reports_unknown_rule_in_compact_directive() {
2073 let warnings = validate_inline_config_rules("<!--rumdl-disable MD998-->\n", MarkdownFlavor::Standard);
2074 assert_eq!(warnings.len(), 1, "{warnings:?}");
2075 assert_eq!(warnings[0].line_number, 1);
2076 }
2077
2078 #[test]
2081 fn candidate_scan_keeps_nested_and_multiline_directives() {
2082 for source in [
2083 "<!-- rumdl-disable MD009 -->",
2084 "prose <!-- markdownlint-enable-file MD013 -->",
2085 "<!-- rumdl-configure-file {\n\"MD013\":false\n} -->",
2086 "<!-- other <!-- rumdl-disable-next-line MD009 -->",
2087 "<!-- prettier-ignore -->",
2088 "<!--rumdl-disable MD009 -->",
2089 "<!--markdownlint-disable-->",
2090 "<!--\trumdl-enable-->",
2091 "<!--prettier-ignore-->",
2092 "<!-- prettier-ignore-->",
2093 "<!--prettier-ignore -->",
2094 "<!--\nrumdl-configure-file {} -->",
2095 ] {
2096 assert!(has_inline_config_markers(source), "{source:?}");
2097 }
2098 for source in [
2099 "rumdl prose",
2100 "<!-- unrelated --> rumdl",
2101 "<!-- RUMDL-disable MD009 -->",
2102 "<!--RUMDL-disable MD009 -->",
2103 "<!---- rumdl-disable MD009 -->",
2104 "<!--prettier-ignore-start-->",
2105 "<!-- prettier-ignore-end -->",
2106 "<!--x--> rumdl-disable",
2107 ] {
2108 assert!(!has_inline_config_markers(source), "{source:?}");
2109 assert!(validate_inline_config_rules(source, MarkdownFlavor::Standard).is_empty());
2110 }
2111 }
2112
2113 #[test]
2114 fn test_parse_inline_directives_all_kinds() {
2115 let cases: &[(&str, DirectiveKind)] = &[
2117 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
2118 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
2119 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
2120 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
2121 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
2122 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
2123 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
2124 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
2125 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
2126 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
2128 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
2129 (
2130 "<!-- markdownlint-disable-next-line -->",
2131 DirectiveKind::DisableNextLine,
2132 ),
2133 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
2134 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
2135 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
2136 ];
2137 for (input, expected_kind) in cases {
2138 let directives = parse_inline_directives(input);
2139 assert_eq!(
2140 directives.len(),
2141 1,
2142 "Expected 1 directive for {input:?}, got {directives:?}"
2143 );
2144 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
2145 }
2146 }
2147
2148 #[test]
2149 fn test_parse_inline_directives_disambiguation() {
2150 let line = "<!-- rumdl-disable-line MD001 -->";
2152 let directives = parse_inline_directives(line);
2153 assert_eq!(directives.len(), 1);
2154 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
2155
2156 let line = "<!-- rumdl-disable-next-line -->";
2157 let directives = parse_inline_directives(line);
2158 assert_eq!(directives.len(), 1);
2159 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
2160
2161 let line = "<!-- rumdl-disable-file MD001 -->";
2162 let directives = parse_inline_directives(line);
2163 assert_eq!(directives.len(), 1);
2164 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
2165
2166 let line = "<!-- rumdl-enable-file -->";
2167 let directives = parse_inline_directives(line);
2168 assert_eq!(directives.len(), 1);
2169 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
2170 }
2171
2172 #[test]
2173 fn test_parse_inline_directives_no_space_before_close() {
2174 let directives = parse_inline_directives("<!-- rumdl-disable-->");
2176 assert_eq!(directives.len(), 1);
2177 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2178 assert!(directives[0].rules.is_empty());
2179
2180 let directives = parse_inline_directives("<!-- rumdl-enable-->");
2181 assert_eq!(directives.len(), 1);
2182 assert_eq!(directives[0].kind, DirectiveKind::Enable);
2183 }
2184
2185 #[test]
2186 fn test_parse_inline_directives_multiple_on_one_line() {
2187 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
2188 let directives = parse_inline_directives(line);
2189 assert_eq!(directives.len(), 2);
2190 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2191 assert_eq!(directives[0].rules, vec!["MD001"]);
2192 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2193 assert_eq!(directives[1].rules, vec!["MD001"]);
2194 }
2195
2196 #[test]
2197 fn test_parse_inline_directives_global_disable_then_specific_enable() {
2198 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
2199 let directives = parse_inline_directives(line);
2200 assert_eq!(directives.len(), 2);
2201 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2202 assert!(directives[0].rules.is_empty());
2203 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2204 assert_eq!(directives[1].rules, vec!["MD001"]);
2205 }
2206
2207 #[test]
2208 fn test_parse_inline_directives_word_boundary() {
2209 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
2211 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
2213 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
2215 }
2216
2217 #[test]
2218 fn test_parse_inline_directives_no_closing_tag() {
2219 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
2221 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
2222 }
2223
2224 #[test]
2225 fn test_parse_inline_directives_not_a_comment() {
2226 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
2227 assert!(parse_inline_directives("Some regular text").is_empty());
2228 assert!(parse_inline_directives("").is_empty());
2229 }
2230
2231 #[test]
2232 fn test_parse_inline_directives_case_sensitive() {
2233 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
2234 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
2235 }
2236
2237 #[test]
2238 fn test_parse_inline_directives_rules_extraction() {
2239 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
2240 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
2241
2242 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
2244 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
2245
2246 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
2248 assert_eq!(directives[0].rules, vec!["MD001"]);
2249 }
2250
2251 #[test]
2252 fn test_parse_inline_directives_embedded_in_text() {
2253 let line = "Some text <!-- rumdl-disable MD001 --> more text";
2254 let directives = parse_inline_directives(line);
2255 assert_eq!(directives.len(), 1);
2256 assert_eq!(directives[0].rules, vec!["MD001"]);
2257
2258 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
2259 let directives = parse_inline_directives(line);
2260 assert_eq!(directives.len(), 1);
2261 assert_eq!(directives[0].rules, vec!["MD001"]);
2262 }
2263
2264 #[test]
2265 fn test_parse_inline_directives_mixed_tools_same_line() {
2266 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
2267 let directives = parse_inline_directives(line);
2268 assert_eq!(directives.len(), 2);
2269 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2270 assert_eq!(directives[0].rules, vec!["MD001"]);
2271 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2272 assert_eq!(directives[1].rules, vec!["MD002"]);
2273 }
2274
2275 #[test]
2278 fn test_parse_disable_comment() {
2279 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
2281 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2282
2283 assert_eq!(
2285 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
2286 Some(vec!["MD001", "MD002"])
2287 );
2288
2289 assert_eq!(parse_disable_comment("Some regular text"), None);
2291 }
2292
2293 #[test]
2294 fn test_parse_disable_line_comment() {
2295 assert_eq!(
2297 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
2298 Some(vec![])
2299 );
2300
2301 assert_eq!(
2303 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
2304 Some(vec!["MD013"])
2305 );
2306
2307 assert_eq!(parse_disable_line_comment("Some regular text"), None);
2309 }
2310
2311 #[test]
2312 fn test_inline_config_from_content() {
2313 let content = r#"# Test Document
2314
2315<!-- markdownlint-disable MD013 -->
2316This is a very long line that would normally trigger MD013 but it's disabled
2317
2318<!-- markdownlint-enable MD013 -->
2319This line will be checked again
2320
2321<!-- markdownlint-disable-next-line MD001 -->
2322# This heading will not be checked for MD001
2323## But this one will
2324
2325Some text <!-- markdownlint-disable-line MD013 -->
2326
2327<!-- markdownlint-capture -->
2328<!-- markdownlint-disable MD001 MD002 -->
2329# Heading with MD001 disabled
2330<!-- markdownlint-restore -->
2331# Heading with MD001 enabled again
2332"#;
2333
2334 let config = InlineConfig::from_content(content);
2335
2336 assert!(config.is_rule_disabled("MD013", 4));
2338
2339 assert!(!config.is_rule_disabled("MD013", 7));
2341
2342 assert!(config.is_rule_disabled("MD001", 10));
2344
2345 assert!(!config.is_rule_disabled("MD001", 11));
2347
2348 assert!(config.is_rule_disabled("MD013", 13));
2350
2351 assert!(!config.is_rule_disabled("MD001", 19));
2353 }
2354
2355 #[test]
2356 fn test_capture_restore() {
2357 let content = r#"<!-- markdownlint-disable MD001 -->
2358<!-- markdownlint-capture -->
2359<!-- markdownlint-disable MD002 MD003 -->
2360<!-- markdownlint-restore -->
2361Some content after restore
2362"#;
2363
2364 let config = InlineConfig::from_content(content);
2365
2366 assert!(config.is_rule_disabled("MD001", 5));
2368 assert!(!config.is_rule_disabled("MD002", 5));
2369 assert!(!config.is_rule_disabled("MD003", 5));
2370 }
2371
2372 #[test]
2373 fn test_validate_inline_config_rules_unknown_rule() {
2374 let content = "<!-- rumdl-disable abc -->\nSome content";
2375 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2376 assert_eq!(warnings.len(), 1);
2377 assert_eq!(warnings[0].line_number, 1);
2378 assert_eq!(warnings[0].rule_name, "abc");
2379 assert_eq!(warnings[0].comment_type, "disable");
2380 }
2381
2382 #[test]
2383 fn test_validate_inline_config_rules_valid_rule() {
2384 let content = "<!-- rumdl-disable MD001 -->\nSome content";
2385 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2386 assert!(
2387 warnings.is_empty(),
2388 "MD001 is a valid rule, should not produce warnings"
2389 );
2390 }
2391
2392 #[test]
2393 fn test_validate_inline_config_rules_alias() {
2394 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
2395 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2396 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
2397 }
2398
2399 #[test]
2400 fn test_validate_inline_config_rules_multiple_unknown() {
2401 let content = r#"<!-- rumdl-disable abc xyz -->
2402<!-- rumdl-disable-line foo -->
2403<!-- markdownlint-disable-next-line bar -->
2404"#;
2405 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2406 assert_eq!(warnings.len(), 4);
2407 assert_eq!(warnings[0].rule_name, "abc");
2408 assert_eq!(warnings[1].rule_name, "xyz");
2409 assert_eq!(warnings[2].rule_name, "foo");
2410 assert_eq!(warnings[3].rule_name, "bar");
2411 }
2412
2413 #[test]
2414 fn test_validate_inline_config_rules_suggestion() {
2415 let content = "<!-- rumdl-disable MD00 -->\n";
2417 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2418 assert_eq!(warnings.len(), 1);
2419 assert!(warnings[0].suggestion.is_some());
2421 }
2422
2423 #[test]
2424 fn test_validate_inline_config_rules_file_comments() {
2425 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
2426 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2427 assert_eq!(warnings.len(), 2);
2428 assert_eq!(warnings[0].comment_type, "disable-file");
2429 assert_eq!(warnings[1].comment_type, "enable-file");
2430 }
2431
2432 #[test]
2433 fn test_validate_inline_config_rules_global_disable() {
2434 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
2436 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2437 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
2438 }
2439
2440 #[test]
2441 fn test_validate_inline_config_rules_mixed_valid_invalid() {
2442 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
2444 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2445 assert_eq!(warnings.len(), 2);
2446 assert_eq!(warnings[0].rule_name, "abc");
2447 assert_eq!(warnings[1].rule_name, "xyz");
2448 }
2449
2450 #[test]
2451 fn test_validate_inline_config_rules_configure_file() {
2452 let content =
2454 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
2455 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2456 assert_eq!(warnings.len(), 1);
2457 assert_eq!(warnings[0].rule_name, "nonexistent");
2458 assert_eq!(warnings[0].comment_type, "configure-file");
2459 }
2460
2461 #[test]
2462 fn test_validate_inline_config_rules_markdownlint_variants() {
2463 let content = r#"<!-- markdownlint-disable unknown_rule -->
2465<!-- markdownlint-enable another_fake -->
2466<!-- markdownlint-disable-line bad_rule -->
2467<!-- markdownlint-disable-next-line fake_rule -->
2468<!-- markdownlint-disable-file missing_rule -->
2469<!-- markdownlint-enable-file nonexistent -->
2470"#;
2471 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2472 assert_eq!(warnings.len(), 6);
2473 assert_eq!(warnings[0].rule_name, "unknown_rule");
2474 assert_eq!(warnings[1].rule_name, "another_fake");
2475 assert_eq!(warnings[2].rule_name, "bad_rule");
2476 assert_eq!(warnings[3].rule_name, "fake_rule");
2477 assert_eq!(warnings[4].rule_name, "missing_rule");
2478 assert_eq!(warnings[5].rule_name, "nonexistent");
2479 }
2480
2481 #[test]
2485 fn test_validate_inline_config_rules_ignores_code_blocks() {
2486 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
2487 assert!(
2488 validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
2489 "a directive inside a fence is documentation, not configuration"
2490 );
2491
2492 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
2493 assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
2494
2495 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
2496 assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
2497
2498 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2499 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2500 }
2501
2502 #[test]
2507 fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
2508 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
2509 assert_eq!(
2510 validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
2511 1,
2512 "an admonition holds its content at an indent that is not code"
2513 );
2514 assert!(
2515 validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
2516 "without admonitions the same lines are an indented code block"
2517 );
2518
2519 let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
2520 assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
2521 assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
2522 }
2523
2524 #[test]
2527 fn test_validate_inline_enables_reports_inside_an_indented_container() {
2528 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
2529 let active = active_set(&["MD009"]);
2530 assert_eq!(
2531 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
2532 .len(),
2533 1,
2534 "the enable is live in an admonition body, and configuration never enabled MD013"
2535 );
2536 assert!(
2537 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
2538 .is_empty(),
2539 "without admonitions the same lines are an indented code block"
2540 );
2541 }
2542
2543 #[test]
2546 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
2547 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
2548 assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
2549
2550 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
2551 assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
2552
2553 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
2554 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2555 }
2556
2557 #[test]
2558 fn test_validate_inline_config_rules_markdownlint_configure_file() {
2559 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
2560 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2561 assert_eq!(warnings.len(), 1);
2562 assert_eq!(warnings[0].rule_name, "fake_rule");
2563 assert_eq!(warnings[0].comment_type, "configure-file");
2564 }
2565
2566 #[test]
2567 fn test_get_rule_config_from_configure_file() {
2568 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
2569
2570This is a test line."#;
2571
2572 let inline_config = InlineConfig::from_content(content);
2573 let config_override = inline_config.get_rule_config("MD013");
2574
2575 assert!(config_override.is_some(), "MD013 config should be found");
2576 let json = config_override.unwrap();
2577 assert!(json.is_object(), "Config should be an object");
2578 let obj = json.as_object().unwrap();
2579 assert!(obj.contains_key("line_length"), "Should have line_length key");
2580 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2581 }
2582
2583 #[test]
2584 fn test_get_rule_config_tables_false() {
2585 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
2587
2588 let inline_config = InlineConfig::from_content(content);
2589 let config_override = inline_config.get_rule_config("MD013");
2590
2591 assert!(config_override.is_some(), "MD013 config should be found");
2592 let json = config_override.unwrap();
2593 let obj = json.as_object().unwrap();
2594 assert!(obj.contains_key("tables"), "Should have tables key");
2595 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
2596 }
2597
2598 fn active_set(names: &[&str]) -> HashSet<String> {
2612 names.iter().map(|s| (*s).to_string()).collect()
2613 }
2614
2615 fn no_ignores() -> HashSet<String> {
2616 HashSet::new()
2617 }
2618
2619 #[test]
2620 fn test_inline_enable_of_inactive_rule_warns() {
2621 let active = active_set(&["MD013", "MD022"]);
2622 let content = "<!-- rumdl-enable MD012 -->\n";
2623
2624 let warnings =
2625 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2626
2627 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2628 assert_eq!(warnings[0].rule_name, "MD012");
2629 assert_eq!(warnings[0].comment_type, "enable");
2630 assert_eq!(
2631 warnings[0].problem,
2632 InlineConfigProblem::EnableHasNoEffect {
2633 reason: EnableNoEffectReason::NotEnabled
2634 }
2635 );
2636 assert_eq!(warnings[0].line_number, 1);
2637 }
2638
2639 #[test]
2640 fn test_inline_enable_of_active_rule_does_not_warn() {
2641 let active = active_set(&["MD012", "MD013"]);
2644 let content = "<!-- rumdl-enable MD012 -->\n";
2645
2646 let warnings =
2647 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2648
2649 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
2650 }
2651
2652 #[test]
2653 fn test_inline_enable_of_per_file_ignored_rule_warns() {
2654 let active = active_set(&["MD012", "MD013"]);
2657 let ignored = active_set(&["MD012"]);
2658 let content = "<!-- rumdl-enable MD012 -->\n";
2659
2660 let warnings =
2661 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2662
2663 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2664 assert_eq!(warnings[0].rule_name, "MD012");
2665 assert_eq!(
2666 warnings[0].problem,
2667 InlineConfigProblem::EnableHasNoEffect {
2668 reason: EnableNoEffectReason::IgnoredForFile
2669 }
2670 );
2671 assert!(
2672 warnings[0].format_message().contains("per-file-ignores"),
2673 "the message must name the setting in play: {}",
2674 warnings[0].format_message()
2675 );
2676 }
2677
2678 #[test]
2679 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
2680 let active = active_set(&["MD013"]);
2683 let ignored = active_set(&["MD012"]);
2684 let content = "<!-- rumdl-enable MD012 -->\n";
2685
2686 let warnings =
2687 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2688
2689 assert_eq!(warnings.len(), 1, "{warnings:?}");
2690 assert_eq!(
2691 warnings[0].problem,
2692 InlineConfigProblem::EnableHasNoEffect {
2693 reason: EnableNoEffectReason::NotEnabled
2694 }
2695 );
2696 }
2697
2698 #[test]
2699 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
2700 let active = active_set(&["MD012", "MD013"]);
2703 let ignored = active_set(&["MD013"]);
2704 let content = "<!-- rumdl-enable MD012 -->\n";
2705
2706 let warnings =
2707 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2708
2709 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
2710 }
2711
2712 #[test]
2713 fn test_enable_inside_code_block_does_not_warn() {
2714 let active = active_set(&["MD012", "MD013"]);
2717 let ignored = active_set(&["MD012"]);
2718
2719 for content in [
2720 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
2721 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
2722 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
2723 ] {
2724 let warnings =
2725 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2726 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
2727 }
2728
2729 for content in [
2730 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
2731 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
2732 ] {
2733 let warnings =
2734 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2735 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
2736 }
2737 }
2738
2739 #[test]
2740 fn test_bare_enable_all_does_not_warn() {
2741 let active = active_set(&["MD013"]);
2743 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
2744 let warnings = validate_inline_enables_against_active_rules(
2745 content,
2746 MarkdownFlavor::Standard,
2747 &active,
2748 &active_set(&["MD013"]),
2749 );
2750 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
2751 }
2752 }
2753
2754 #[test]
2755 fn test_enable_file_and_alias_of_inactive_rule_warn() {
2756 let active = active_set(&["MD013"]);
2757 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
2759
2760 let warnings =
2761 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2762
2763 assert_eq!(warnings.len(), 1, "{warnings:?}");
2764 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
2765 assert_eq!(warnings[0].comment_type, "enable-file");
2766 }
2767
2768 #[test]
2769 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
2770 let active = active_set(&["MD013"]);
2771 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2772 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2773
2774 let warn_true =
2775 validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
2776 let warn_false =
2777 validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
2778
2779 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
2780 assert_eq!(warn_true[0].rule_name, "MD012");
2781 assert!(
2782 warn_false.is_empty(),
2783 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
2784 );
2785 }
2786
2787 #[test]
2788 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
2789 let active = active_set(&["MD013"]);
2792 let content = "<!-- rumdl-enable NotARule -->\n";
2793
2794 let warnings =
2795 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2796
2797 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
2798 }
2799
2800 #[test]
2801 fn test_disable_directive_never_warns_as_no_effect() {
2802 let active = active_set(&["MD012", "MD013"]);
2805 let ignored = active_set(&["MD012"]);
2806 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
2807
2808 let warnings =
2809 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2810
2811 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
2812 }
2813
2814 #[test]
2820 fn test_unknown_option_key_in_configure_file_warns() {
2821 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2822
2823 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2824
2825 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2826 assert_eq!(warnings[0].rule_name, "MD013");
2827 assert_eq!(
2828 warnings[0].problem,
2829 InlineConfigProblem::UnknownOption {
2830 key: "line_lenght".to_string()
2831 }
2832 );
2833 assert!(
2834 warnings[0].suggestion.is_some(),
2835 "a near-miss key should suggest the real one"
2836 );
2837 assert!(
2838 warnings[0].format_message().contains("Unknown option for rule MD013"),
2839 "message was: {}",
2840 warnings[0].format_message()
2841 );
2842 }
2843
2844 #[test]
2845 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2846 for content in [
2849 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2850 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2851 ] {
2852 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2853 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2854 }
2855 }
2856
2857 #[test]
2858 fn test_unknown_rule_does_not_also_warn_about_its_options() {
2859 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2862
2863 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2864
2865 assert_eq!(
2866 warnings.len(),
2867 1,
2868 "expected only the unknown-rule warning: {warnings:?}"
2869 );
2870 assert_eq!(warnings[0].rule_name, "nonexistent");
2871 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2872 }
2873
2874 #[test]
2875 fn test_boolean_rule_value_has_no_option_warnings() {
2876 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2877
2878 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2879
2880 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2881 }
2882
2883 #[test]
2884 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2885 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2886
2887 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2888
2889 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2890 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2891 }
2892
2893 #[test]
2894 fn test_configure_file_spanning_multiple_lines_applies() {
2895 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2896
2897 let inline_config = InlineConfig::from_content(content);
2898 let config_override = inline_config.get_rule_config("MD013");
2899
2900 assert!(config_override.is_some(), "a multi-line configure-file must apply");
2901 let obj = config_override.unwrap().as_object().unwrap();
2902 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2903 }
2904
2905 #[test]
2906 fn test_every_configure_file_comment_applies_not_just_the_first() {
2907 let content = "<!-- markdownlint-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n\n<!-- markdownlint-configure-file\n{\n \"MD007\": { \"indent\": 4 }\n}\n-->\n\n# Head\n";
2910
2911 let inline_config = InlineConfig::from_content(content);
2912
2913 assert!(
2914 inline_config.get_rule_config("MD013").is_some(),
2915 "first (single-line) configure-file dropped"
2916 );
2917 assert!(
2918 inline_config.get_rule_config("MD007").is_some(),
2919 "second (multi-line) configure-file dropped"
2920 );
2921 }
2922
2923 #[test]
2924 fn test_multiline_configure_file_in_code_block_is_ignored() {
2925 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2928
2929 let inline_config = InlineConfig::from_content(content);
2930
2931 assert!(
2932 inline_config.get_rule_config("MD013").is_none(),
2933 "configure-file inside a fenced code block must not apply"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_multiline_configure_file_bool_and_alias_still_honored() {
2939 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2942
2943 let inline_config = InlineConfig::from_content(content);
2944
2945 assert!(
2946 inline_config.is_rule_disabled("MD012", 1),
2947 "boolean alias key must disable the rule"
2948 );
2949 let obj = inline_config
2950 .get_rule_config("MD013")
2951 .expect("alias-keyed config should resolve to MD013")
2952 .as_object()
2953 .unwrap();
2954 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2955 }
2956
2957 #[test]
2958 fn test_multiline_configure_file_warning_reports_start_line() {
2959 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2962
2963 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2964
2965 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2966 assert_eq!(warnings[0].rule_name, "nonexistent");
2967 assert_eq!(warnings[0].comment_type, "configure-file");
2968 assert_eq!(
2969 warnings[0].line_number, 3,
2970 "warning must point at the line the comment starts on"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_configure_file_bool_false_disables_rule() {
2976 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2979
2980 let inline_config = InlineConfig::from_content(content);
2981
2982 assert!(inline_config.is_rule_disabled("MD012", 1));
2983 assert!(
2984 inline_config.get_rule_config("MD012").is_none(),
2985 "a boolean should not be stored as rule options"
2986 );
2987 }
2988
2989 #[test]
2990 fn test_configure_file_bool_false_disables_rule_by_alias() {
2991 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2992
2993 let inline_config = InlineConfig::from_content(content);
2994
2995 assert!(inline_config.is_rule_disabled("MD012", 1));
2996 }
2997
2998 #[test]
2999 fn test_configure_file_bool_true_leaves_rule_enabled() {
3000 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
3001
3002 let inline_config = InlineConfig::from_content(content);
3003
3004 assert!(!inline_config.is_rule_disabled("MD012", 1));
3005 }
3006
3007 #[test]
3008 fn test_get_rule_config_from_configure_file_alias_key() {
3009 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
3011
3012 let inline_config = InlineConfig::from_content(content);
3013 let config_override = inline_config.get_rule_config("MD013");
3014
3015 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
3016 let obj = config_override.unwrap().as_object().unwrap();
3017 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
3018 }
3019
3020 #[test]
3023 fn test_parse_disable_does_not_match_disable_line() {
3024 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
3026 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
3027 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
3028 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
3029 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
3030 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
3031 }
3032
3033 #[test]
3034 fn test_parse_enable_does_not_match_enable_file() {
3035 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
3036 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
3037 }
3038
3039 #[test]
3040 fn test_parse_disable_comment_edge_cases() {
3041 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
3043
3044 assert_eq!(
3046 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
3047 Some(vec!["MD001", "MD002"])
3048 );
3049
3050 assert_eq!(
3052 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
3053 Some(vec!["MD001"])
3054 );
3055
3056 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
3058
3059 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
3061
3062 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
3064
3065 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
3067
3068 assert_eq!(
3070 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
3071 Some(vec!["MD001", "MD001", "MD002"])
3072 );
3073
3074 assert_eq!(
3076 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
3077 Some(vec!["MD001"])
3078 );
3079
3080 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
3082 let comment = format!("<!-- rumdl-disable {many_rules} -->");
3083 let parsed = parse_disable_comment(&comment);
3084 assert!(parsed.is_some());
3085 assert_eq!(parsed.unwrap().len(), 100);
3086
3087 assert_eq!(
3089 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
3090 Some(vec!["MD001-test"])
3091 );
3092 assert_eq!(
3093 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
3094 Some(vec!["custom_rule"])
3095 );
3096 }
3097
3098 #[test]
3099 fn test_parse_enable_comment_edge_cases() {
3100 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
3101 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
3102 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
3103 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
3104 }
3105
3106 #[test]
3109 fn test_disable_inside_fenced_code_block_ignored() {
3110 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
3111 let config = InlineConfig::from_content(content);
3112 assert!(!config.is_rule_disabled("MD001", 6));
3114 }
3115
3116 #[test]
3117 fn test_disable_inside_tilde_fence_ignored() {
3118 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
3119 let config = InlineConfig::from_content(content);
3120 assert!(!config.is_rule_disabled("MD001", 6));
3121 }
3122
3123 #[test]
3124 fn test_disable_before_code_block_persists_after() {
3125 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
3127 let config = InlineConfig::from_content(content);
3128 assert!(config.is_rule_disabled("MD001", 5));
3129 }
3130
3131 #[test]
3132 fn test_enable_inside_code_block_ignored() {
3133 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
3135 let config = InlineConfig::from_content(content);
3136 assert!(config.is_rule_disabled("MD001", 5));
3137 }
3138
3139 #[test]
3140 fn test_disable_inside_indented_code_block_ignored() {
3141 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
3145 let config = InlineConfig::from_content(content);
3146 assert!(!config.is_rule_disabled("MD001", 5));
3147
3148 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
3150 let config = InlineConfig::from_content(unindented);
3151 assert!(config.is_rule_disabled("MD001", 5));
3152 }
3153
3154 #[test]
3155 fn test_configure_file_inside_indented_code_block_ignored() {
3156 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3159 let config = InlineConfig::from_content(content);
3160 assert!(config.get_rule_config("MD013").is_none());
3161
3162 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3163 let config = InlineConfig::from_content(unindented);
3164 assert!(config.get_rule_config("MD013").is_some());
3165 }
3166
3167 #[test]
3170 fn test_disable_inside_code_span_ignored() {
3171 let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
3174 let config = InlineConfig::from_content(content);
3175 assert!(!config.is_rule_disabled("MD001", 5));
3176
3177 let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3179 let config = InlineConfig::from_content(bare);
3180 assert!(config.is_rule_disabled("MD001", 5));
3181 }
3182
3183 #[test]
3184 fn test_directive_beside_one_in_a_code_span_still_applies() {
3185 let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
3188 let config = InlineConfig::from_content(content);
3189 assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
3190 assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
3191 }
3192
3193 #[test]
3194 fn test_disable_file_inside_code_span_ignored() {
3195 let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
3196 let config = InlineConfig::from_content(content);
3197 assert!(!config.is_rule_disabled("MD013", 3));
3198
3199 let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
3200 let config = InlineConfig::from_content(bare);
3201 assert!(config.is_rule_disabled("MD013", 3));
3202 }
3203
3204 #[test]
3205 fn test_configure_file_inside_code_span_ignored() {
3206 let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
3209 let config = InlineConfig::from_content(content);
3210 assert!(config.get_rule_config("MD013").is_none());
3211
3212 let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
3213 let config = InlineConfig::from_content(disabling);
3214 assert!(!config.is_rule_disabled("MD013", 3));
3215 }
3216
3217 #[test]
3218 fn test_disable_inside_multi_line_code_span_ignored() {
3219 let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
3221 let config = InlineConfig::from_content(content);
3222 assert!(!config.is_rule_disabled("MD001", 5));
3223 }
3224
3225 #[test]
3226 fn test_comment_opening_a_line_inside_backticks_still_applies() {
3227 let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
3231 let config = InlineConfig::from_content(content);
3232 assert!(config.is_rule_disabled("MD001", 5));
3233 }
3234
3235 #[test]
3236 fn test_disable_inside_double_backtick_code_span_ignored() {
3237 let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
3238 let config = InlineConfig::from_content(content);
3239 assert!(!config.is_rule_disabled("MD001", 3));
3240 }
3241
3242 #[test]
3243 fn test_code_ranges_line_up_on_a_crlf_document() {
3244 let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
3249 let config = InlineConfig::from_content(fenced);
3250 assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
3251
3252 let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
3253 let config = InlineConfig::from_content(spanned);
3254 assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
3255
3256 let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
3258 let config = InlineConfig::from_content(bare);
3259 assert!(config.is_rule_disabled("MD001", 5));
3260 }
3261
3262 #[test]
3263 fn test_unmatched_backtick_does_not_open_a_code_span() {
3264 let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3266 let config = InlineConfig::from_content(content);
3267 assert!(config.is_rule_disabled("MD001", 3));
3268 }
3269
3270 #[test]
3271 fn test_validate_inline_config_rules_ignores_code_spans() {
3272 let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
3275 assert!(
3276 validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
3277 "a directive shown in a code span warned"
3278 );
3279
3280 let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
3281 assert_eq!(
3282 validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
3283 1,
3284 "the same name outside a span is still validated"
3285 );
3286 }
3287
3288 #[test]
3289 fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
3290 let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
3291 assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
3292 }
3293
3294 #[test]
3295 fn test_enable_inside_code_span_does_not_warn() {
3296 let active = active_set(&["MD012", "MD013"]);
3298 let ignored = active_set(&["MD012"]);
3299
3300 for content in [
3301 "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
3302 "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
3303 ] {
3304 let warnings =
3305 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
3306 assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
3307 }
3308 }
3309
3310 #[test]
3313 fn test_markdownlint_disable_rumdl_enable_interop() {
3314 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
3315 let config = InlineConfig::from_content(content);
3316 assert!(config.is_rule_disabled("MD001", 2));
3317 assert!(!config.is_rule_disabled("MD001", 4));
3318 }
3319
3320 #[test]
3321 fn test_rumdl_disable_markdownlint_enable_interop() {
3322 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
3323 let config = InlineConfig::from_content(content);
3324 assert!(config.is_rule_disabled("MD013", 2));
3325 assert!(!config.is_rule_disabled("MD013", 4));
3326 }
3327
3328 #[test]
3331 fn test_global_disable_then_specific_enable() {
3332 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
3333 let config = InlineConfig::from_content(content);
3334 assert!(!config.is_rule_disabled("MD001", 4));
3335 assert!(config.is_rule_disabled("MD002", 4));
3336 assert!(config.is_rule_disabled("MD013", 4));
3337 }
3338
3339 #[test]
3340 fn test_specific_disable_then_global_enable() {
3341 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
3342 let config = InlineConfig::from_content(content);
3343 assert!(config.is_rule_disabled("MD001", 2));
3344 assert!(config.is_rule_disabled("MD002", 2));
3345 assert!(!config.is_rule_disabled("MD001", 4));
3346 assert!(!config.is_rule_disabled("MD002", 4));
3347 }
3348
3349 #[test]
3350 fn test_multiple_rules_disable_enable_independently() {
3351 let content = "\
3352Line 1\n\
3353<!-- rumdl-disable MD001 MD002 -->\n\
3354Line 3\n\
3355<!-- rumdl-enable MD001 -->\n\
3356Line 5\n\
3357<!-- rumdl-disable -->\n\
3358Line 7\n\
3359<!-- rumdl-enable MD002 -->\n\
3360Line 9\n";
3361 let config = InlineConfig::from_content(content);
3362
3363 assert!(!config.is_rule_disabled("MD001", 1));
3365 assert!(!config.is_rule_disabled("MD002", 1));
3366
3367 assert!(config.is_rule_disabled("MD001", 3));
3369 assert!(config.is_rule_disabled("MD002", 3));
3370
3371 assert!(!config.is_rule_disabled("MD001", 5));
3373 assert!(config.is_rule_disabled("MD002", 5));
3374
3375 assert!(config.is_rule_disabled("MD001", 7));
3377 assert!(config.is_rule_disabled("MD002", 7));
3378
3379 assert!(config.is_rule_disabled("MD001", 9));
3381 assert!(!config.is_rule_disabled("MD002", 9));
3382 }
3383
3384 #[test]
3387 fn test_empty_content() {
3388 let config = InlineConfig::from_content("");
3389 assert!(!config.is_rule_disabled("MD001", 1));
3390 }
3391
3392 #[test]
3393 fn test_single_disable_comment_only() {
3394 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
3397 assert!(!config.is_rule_disabled("MD001", 1));
3398 assert!(config.is_rule_disabled("MD001", 2));
3399 assert!(config.is_rule_disabled("MD999", 2));
3400
3401 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
3403 assert!(!config.is_rule_disabled("MD001", 1));
3404 assert!(config.is_rule_disabled("MD001", 2));
3405 assert!(config.is_rule_disabled("MD001", 3));
3406 }
3407
3408 #[test]
3409 fn test_no_inline_markers() {
3410 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
3411 assert!(!config.is_rule_disabled("MD001", 1));
3412 assert!(!config.is_rule_disabled("MD001", 5));
3413 }
3414
3415 #[test]
3418 fn test_export_for_file_index_persistent_transitions() {
3419 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
3420 let config = InlineConfig::from_content(content);
3421 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
3422
3423 assert!(file_disabled.is_empty());
3424 assert!(
3426 persistent.len() >= 2,
3427 "Expected at least 2 transitions, got {}",
3428 persistent.len()
3429 );
3430 }
3431
3432 #[test]
3433 fn test_export_for_file_index_disable_file() {
3434 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
3435 let config = InlineConfig::from_content(content);
3436 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
3437
3438 assert!(file_disabled.contains("MD001"));
3439 }
3440
3441 #[test]
3442 fn test_export_for_file_index_disable_line() {
3443 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
3444 let config = InlineConfig::from_content(content);
3445 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
3446
3447 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
3448 assert!(line_disabled[&2].contains("MD001"));
3449 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
3450 }
3451
3452 fn disabled_lines(content: &str, rule: &str) -> Vec<usize> {
3455 let config = InlineConfig::from_content(content);
3456 let lines = content.split('\n').count();
3457 (1..=lines)
3458 .filter(|&line| config.is_rule_disabled(rule, line))
3459 .collect()
3460 }
3461
3462 #[test]
3463 fn multi_line_disable_takes_effect_after_the_closing_line() {
3464 let content = "<!--\nrumdl-disable MD013\n-->\nlong\nlong\n";
3466 assert_eq!(disabled_lines(content, "MD013"), [4, 5, 6]);
3467 }
3468
3469 #[test]
3470 fn multi_line_markdownlint_forms_and_rule_lists_split_over_lines() {
3471 let content = "<!--\nmarkdownlint-disable MD013\nMD001\n MD009\n-->\nx\n";
3472 for rule in ["MD013", "MD001", "MD009"] {
3473 assert_eq!(disabled_lines(content, rule), [6, 7], "{rule}");
3474 }
3475 let all = "<!-- rumdl-disable\n-->\nx\n";
3476 assert_eq!(disabled_lines(all, "MD013"), [3, 4]);
3477 }
3478
3479 #[test]
3480 fn multi_line_enable_restores_and_capture_restore_nest() {
3481 let content = "<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-enable MD013\n-->\nb\n";
3482 assert_eq!(disabled_lines(content, "MD013"), [2, 3, 4, 5]);
3483 let content = "<!--\nrumdl-capture\n-->\n<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-restore\n-->\nb\n";
3484 assert_eq!(disabled_lines(content, "MD013"), [5, 6, 7, 8]);
3485 }
3486
3487 #[test]
3488 fn multi_line_line_and_next_line_forms_use_the_closing_line() {
3489 let content = "<!--\nrumdl-disable-line MD013\n-->\nx\n";
3490 assert_eq!(disabled_lines(content, "MD013"), [3]);
3491 let content = "<!--\nrumdl-disable-next-line MD013\n-->\nx\ny\n";
3492 assert_eq!(disabled_lines(content, "MD013"), [4]);
3493 }
3494
3495 #[test]
3496 fn multi_line_file_forms_apply_to_the_whole_document() {
3497 let content = "x\n<!--\nrumdl-disable-file MD013\n-->\ny\n";
3498 assert_eq!(disabled_lines(content, "MD013"), [1, 2, 3, 4, 5, 6]);
3499 }
3500
3501 #[test]
3502 fn single_line_comment_after_a_multi_line_one_on_its_closing_line_applies_in_order() {
3503 let content = "<!--\nrumdl-disable MD013\n--> <!-- rumdl-enable MD013 -->\nx\n";
3505 assert!(disabled_lines(content, "MD013").is_empty());
3506 let content = "<!--\nrumdl-enable MD013\n--> <!-- rumdl-disable MD013 -->\nx\n";
3508 assert_eq!(disabled_lines(content, "MD013"), [4, 5]);
3509 }
3510
3511 #[test]
3512 fn multi_line_directive_inside_a_code_block_or_span_configures_nothing() {
3513 let fenced = "```\n<!--\nrumdl-disable MD013\n-->\n```\nx\n";
3514 assert!(disabled_lines(fenced, "MD013").is_empty());
3515 let span = "`<!--\nrumdl-disable MD013\n-->`\nx\n";
3516 assert!(disabled_lines(span, "MD013").is_empty());
3517 }
3518
3519 #[test]
3520 fn multi_line_comment_with_other_leading_text_is_not_a_directive() {
3521 let content = "<!--\nnote\nrumdl-disable MD013\n-->\nx\n";
3522 assert!(disabled_lines(content, "MD013").is_empty());
3523 }
3524
3525 #[test]
3526 fn single_line_directives_are_not_counted_twice() {
3527 let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-disable-next-line MD001 -->\nx\n";
3528 let config = InlineConfig::from_content(content);
3529 let (_, transitions, line_disabled) = config.export_for_file_index();
3530 assert_eq!(transitions.len(), 2, "{transitions:?}");
3532 assert_eq!(line_disabled.len(), 1, "{line_disabled:?}");
3533 }
3534
3535 #[test]
3536 fn multi_line_disable_site_reports_the_opening_line_and_the_closing_line_scope() {
3537 let content = "x\n<!--\nrumdl-disable-next-line MD013\n-->\ny\n";
3538 let sites = collect_disable_sites(content, &[], &[]);
3539 assert_eq!(sites.len(), 1, "{sites:?}");
3540 assert_eq!(sites[0].line, 2);
3541 assert_eq!(sites[0].kind, "disable-next-line");
3542 assert_eq!(sites[0].scope, DisableScope::Line(5));
3543 assert_eq!(sites[0].span.start, 0);
3544 assert_eq!(sites[0].span.end, 4, "clipped to the opening line");
3545 }
3546
3547 #[test]
3548 fn multi_line_directive_unknown_rule_is_reported_on_the_opening_line() {
3549 let content = "x\n<!--\nrumdl-disable MD9999\n-->\n";
3550 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
3551 assert_eq!(warnings.len(), 1, "{warnings:?}");
3552 assert_eq!(warnings[0].line_number, 2);
3553 assert_eq!(warnings[0].rule_name, "MD9999");
3554 }
3555
3556 #[test]
3559 fn comment_whitespace_is_unicode_whitespace_plus_bom() {
3560 for ch in [
3561 '\u{20}', '\t', '\n', '\u{a0}', '\u{3000}', '\u{85}', '\u{2028}', '\u{feff}',
3562 ] {
3563 assert!(is_comment_space(ch), "{ch:?}");
3564 }
3565 for ch in ['\u{200b}', 'a', '-'] {
3566 assert!(!is_comment_space(ch), "{ch:?}");
3567 }
3568 }
3569
3570 #[test]
3571 fn bom_counts_as_space_in_every_part_of_a_directive() {
3572 let content = "<!--\u{feff}rumdl-disable\u{feff}MD013\u{feff}MD001\u{feff}-->\nx\n";
3573 assert_eq!(disabled_lines(content, "MD013"), [2, 3]);
3574 assert_eq!(disabled_lines(content, "MD001"), [2, 3]);
3575 let zero_width = "<!--\u{200b}rumdl-disable MD013 -->\nx\n";
3576 assert!(disabled_lines(zero_width, "MD013").is_empty());
3577 }
3578
3579 #[test]
3580 fn prettier_ignore_accepts_the_same_whitespace() {
3581 assert!(prettier_ignore_len("prettier-ignore\u{feff}-->").is_some());
3582 assert!(prettier_ignore_len("prettier-ignore\u{200b}-->").is_none());
3583 }
3584
3585 fn is_tool_comment(text: &str) -> bool {
3588 !config_comment_ranges(text, |_| false).is_empty()
3589 }
3590
3591 #[test]
3592 fn tool_comment_recognises_every_tool_in_any_spelling() {
3593 for text in [
3594 "<!-- rumdl-disable MD013 -->",
3595 "<!--markdownlint-disable-->",
3596 "<!-- vale off -->",
3597 "<!--vale off-->",
3598 "<!-- vale on -->",
3599 "<!-- vale Style.Rule = NO -->",
3600 "<!-- vale style = X -->",
3601 "<!-- lint disable no-javascript -->",
3602 "<!--lint disable x-->",
3603 "<!-- lint enable x -->",
3604 "<!--\tlint ignore x\t-->",
3605 "<!--\u{feff}lint disable x-->",
3606 "<!--\nlint disable x\n-->",
3607 ] {
3608 assert!(is_tool_comment(text), "{text:?}");
3609 }
3610 for text in [
3611 "<!-- vale offset javascript -->",
3612 "<!-- vale onboarding javascript -->",
3613 "<!-- vale style guide javascript -->",
3614 "<!-- lint disabled x -->",
3615 "<!-- linting disable x -->",
3616 "<!-- lint -->",
3617 "<!-- valet off -->",
3618 "<!-- vale -->",
3619 "<!-- plain comment -->",
3620 ] {
3621 assert!(!is_tool_comment(text), "{text:?}");
3622 }
3623 }
3624
3625 #[test]
3626 fn config_comment_ranges_cover_tool_comments_only() {
3627 let content = "a\n<!--\nlint disable x\n-->\nb\n<!-- vale off -->\n<!--\nplain\n-->\n";
3628 let ranges = config_comment_ranges(content, |_| false);
3629 let texts: Vec<&str> = ranges.iter().map(|range| &content[range.clone()]).collect();
3630 assert_eq!(texts, ["<!--\nlint disable x\n-->", "<!-- vale off -->"]);
3631 }
3632
3633 #[test]
3634 fn config_comment_ranges_stop_at_the_comment_edges() {
3635 let content = "prose <!--vale off--> tail\n";
3636 let ranges = config_comment_ranges(content, |_| false);
3637 assert_eq!(&content[ranges[0].clone()], "<!--vale off-->");
3638 }
3639
3640 #[test]
3641 fn config_comment_ranges_skip_code_openers_and_end_an_unclosed_one_at_its_line() {
3642 let content = "<!-- vale off\nprose\n";
3643 assert_eq!(config_comment_ranges(content, |_| false), vec![0..13]);
3644 assert!(config_comment_ranges(content, |_| true).is_empty());
3645 }
3646
3647 #[test]
3648 fn blockquote_tracker_classifies_each_line_once_in_order() {
3649 let text = "<!-- a --> <!-- b -->\n> q <!-- c --> <!-- d -->\n- > l <!-- e -->\nx > y <!-- f -->\n1 > 0 <!-- g -->\n\n> <!-- h -->";
3650 let mut tracker = BlockquoteTracker::new();
3651 let got: Vec<usize> = text
3652 .match_indices(COMMENT_OPEN)
3653 .map(|(open, _)| tracker.quote_depth_at(text, open))
3654 .collect();
3655 assert_eq!(got, [0, 0, 1, 1, 1, 0, 0, 1]);
3656 }
3657
3658 #[test]
3659 fn quote_prefix_needs_real_container_syntax() {
3660 for (line, depth) in [
3661 ("> x", 1),
3662 (">> x", 2),
3663 (" > x", 1),
3664 ("- > x", 1),
3665 ("1. > x", 1),
3666 ("10) - > x", 1),
3667 ("> - > x", 2),
3668 (">x", 1),
3669 ("x", 0),
3670 ("1 > 0", 0),
3671 ("a > b", 0),
3672 ("-> x", 0),
3673 ("1.> x", 0),
3674 ("-x > y", 0),
3675 ("- x > y", 0),
3676 ("1234567890. > x", 0),
3677 ("", 0),
3678 ("- x", 0),
3679 ] {
3680 assert_eq!(line_quote_depth(line), depth, "{line:?}");
3681 }
3682 }
3683
3684 #[test]
3685 fn config_comment_ranges_unterminated_openers_linear_complexity() {
3686 let cost = |count: usize| {
3689 let content = "<!-- vale off\n".repeat(count);
3690 let mut best = std::time::Duration::MAX;
3691 for _ in 0..5 {
3692 let start = std::time::Instant::now();
3693 let ranges = config_comment_ranges(std::hint::black_box(&content), |_| false);
3694 best = best.min(start.elapsed());
3695 assert_eq!(ranges.len(), count);
3696 }
3697 best.as_secs_f64()
3698 };
3699 let (small, large) = (cost(20_000), cost(80_000));
3700 assert!(
3701 large / small < 8.0,
3702 "4x the openers took {:.1}x the time (linear is about 4x, quadratic about 16x)",
3703 large / small
3704 );
3705 }
3706
3707 #[test]
3708 fn offset_in_ranges_matches_a_linear_scan() {
3709 let ranges = vec![2..5, 5..6, 9..12, 20..21];
3710 for offset in 0..25 {
3711 assert_eq!(
3712 offset_in_ranges(&ranges, offset),
3713 ranges.iter().any(|range| range.contains(&offset)),
3714 "offset {offset}"
3715 );
3716 }
3717 assert!(!offset_in_ranges(&[], 0));
3718 }
3719
3720 #[test]
3721 fn a_code_opener_does_not_swallow_the_directive_after_it() {
3722 let content = "```\n<!-- rumdl-disable\n```\n\n<!--\nrumdl-disable MD013\n-->\n";
3723 let kept = scan_multiline_directives(content, |open| open < 20);
3724 assert_eq!(kept.len(), 1);
3725 assert_eq!(kept[0].start_line, 5);
3726 }
3727
3728 #[test]
3729 fn comment_body_skips_blockquote_markers_on_later_lines_only() {
3730 assert_eq!(comment_body("\n> rumdl-disable", 1), "rumdl-disable");
3731 assert_eq!(comment_body("\n>> \t> rumdl-disable", 3), "rumdl-disable");
3732 assert_eq!(
3733 comment_body("> rumdl-disable", 1),
3734 "> rumdl-disable",
3735 "same line: not a marker"
3736 );
3737 assert_eq!(
3738 comment_body("\n> rumdl-disable", 0),
3739 "> rumdl-disable",
3740 "outside a blockquote the `>` is comment text"
3741 );
3742 assert_eq!(
3743 comment_body("\n> > rumdl-disable", 1),
3744 "> rumdl-disable",
3745 "a marker beyond the enclosing depth is text"
3746 );
3747 assert_eq!(comment_body(" \n ", 1), "");
3748 }
3749
3750 #[test]
3751 fn multi_line_directive_in_a_blockquote_splits_rules_across_marked_lines() {
3752 let content = "> <!-- rumdl-disable MD013\n> MD009\n> -->\n> text\n";
3753 let found = scan_multiline_directives(content, |_| false);
3754 assert_eq!(found.len(), 1);
3755 assert_eq!(found[0].rules, ["MD013", "MD009"]);
3756 assert_eq!((found[0].start_line, found[0].end_line), (1, 3));
3757 }
3758
3759 #[test]
3760 fn an_unclosed_configure_file_example_in_code_does_not_swallow_the_directive_after_it() {
3761 let content = "```\n<!-- rumdl-configure-file\n```\n\n<!--\nrumdl-disable MD013\n-->\ntext\n";
3762 let code_end = content.find("\n\n<!--\n").unwrap();
3763 let found = scan_multiline_directives(content, |open| open < code_end);
3764 assert_eq!(found.len(), 1);
3765 assert_eq!(found[0].rules, ["MD013"]);
3766 }
3767}