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 for (offset, json_config) in scan_configure_file_comments(content) {
429 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
430 continue;
431 }
432 let Some(obj) = json_config.as_object() else {
433 continue;
434 };
435 for (rule_name, rule_config) in obj {
436 let normalized = normalize_rule_name(rule_name);
440 if let Some(enabled) = rule_config.as_bool() {
441 if enabled {
442 config.file_disabled_rules.remove(&normalized);
443 } else {
444 config.file_disabled_rules.insert(normalized);
445 }
446 continue;
447 }
448 config.file_rule_config.insert(normalized, rule_config.clone());
452 }
453 }
454
455 let mut currently_disabled: HashSet<String> = HashSet::new();
457 let mut currently_enabled: HashSet<String> = HashSet::new();
458 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
459
460 let mut prev_disabled: HashSet<String> = HashSet::new();
462 let mut prev_enabled: HashSet<String> = HashSet::new();
463
464 config.transitions.push(StateTransition {
466 line: 1,
467 disabled: HashSet::new(),
468 enabled: HashSet::new(),
469 });
470
471 let mut multiline_by_end_line: HashMap<usize, Vec<MultilineDirective<'_>>> = HashMap::new();
474 let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
475 for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
476 multiline_by_end_line
477 .entry(directive.end_line)
478 .or_default()
479 .push(directive);
480 }
481
482 let mut position = 0;
483 let mut line_count = 0;
484 for (idx, line) in content.split('\n').enumerate() {
485 let line_num = idx + 1; 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, 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, 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() && !offset_in_code_span(line_position + offset, code_spans)
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: &'a [(usize, usize)],
981 code_spans: &'a [(usize, usize)],
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,
991 code_spans,
992 line: std::cell::Cell::new(None),
993 }
994 }
995
996 fn contains(&self, open: usize) -> bool {
997 if offset_in_code_block(open, self.code_blocks) || offset_in_code_span(open, self.code_spans) {
998 return true;
999 }
1000 if let Some((start, end, in_code)) = self.line.get()
1001 && (start..end).contains(&open)
1002 {
1003 return in_code;
1004 }
1005 let line_start = self.content[..open].rfind('\n').map_or(0, |newline| newline + 1);
1006 let line_end = self.content[line_start..]
1007 .find('\n')
1008 .map_or(self.content.len(), |newline| line_start + newline);
1009 let in_code = line_in_code_block(line_start, &self.content[line_start..line_end], self.code_blocks);
1010 self.line.set(Some((line_start, line_end.max(line_start + 1), in_code)));
1011 in_code
1012 }
1013}
1014
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
1087fn offset_within(offset: usize, ranges: &[(usize, usize)]) -> bool {
1089 ranges.iter().any(|&(start, end)| offset >= start && offset < end)
1090}
1091
1092fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
1094 offset_within(offset, code_blocks)
1095}
1096
1097fn offset_in_code_span(offset: usize, code_spans: &[(usize, usize)]) -> bool {
1104 offset_within(offset, code_spans)
1105}
1106
1107fn directives_outside_code_spans<'a>(
1112 line: &'a str,
1113 line_start: usize,
1114 code_spans: &[(usize, usize)],
1115) -> Vec<InlineDirective<'a>> {
1116 let mut directives = parse_inline_directives(line);
1117 if !code_spans.is_empty() {
1118 directives.retain(|directive| !offset_in_code_span(line_start + directive.span.start, code_spans));
1119 }
1120 directives
1121}
1122
1123fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
1131 let probe = line
1132 .find(|c: char| !c.is_whitespace())
1133 .map_or(line_start, |indent| line_start + indent);
1134 offset_in_code_block(probe, code_blocks)
1135}
1136
1137fn line_of_offset(text: &str, offset: usize) -> usize {
1139 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
1140}
1141
1142type LocatedWarning = (InlineConfigWarning, usize);
1147
1148fn drop_warnings_inside_code(
1157 content: &str,
1158 flavor: MarkdownFlavor,
1159 mut warnings: Vec<LocatedWarning>,
1160) -> Vec<InlineConfigWarning> {
1161 if warnings.is_empty() {
1162 return Vec::new();
1163 }
1164 let code = crate::lint_context::code_ranges(content, flavor);
1165 if !code.blocks.is_empty() {
1166 let line_spans: Vec<(usize, &str)> = {
1171 let mut spans = Vec::new();
1172 let mut start = 0;
1173 for line in content.split('\n') {
1174 spans.push((start, line));
1175 start += line.len() + 1;
1176 }
1177 spans
1178 };
1179
1180 warnings.retain(|(warning, _)| {
1181 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
1182 return true;
1183 };
1184 !line_in_code_block(line_start, line, &code.blocks)
1185 });
1186 }
1187 if !code.spans.is_empty() {
1188 warnings.retain(|&(_, offset)| !offset_in_code_span(offset, &code.spans));
1189 }
1190
1191 warnings.into_iter().map(|(warning, _)| warning).collect()
1192}
1193
1194fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
1205 let mut found = Vec::new();
1206 let mut pos = 0;
1207 let mut quotes = BlockquoteTracker::new();
1208
1209 while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
1210 let comment_start = pos + open_offset;
1211 let after_open = comment_body(
1212 &text[comment_start + COMMENT_OPEN.len()..],
1213 quotes.quote_depth_at(text, comment_start),
1214 );
1215 let body_start = text.len() - after_open.len();
1216 pos = comment_start + COMMENT_OPEN.len();
1219
1220 for tool in TOOL_PREFIXES {
1221 let Some(after_tool) = after_open.strip_prefix(tool) else {
1222 continue;
1223 };
1224 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
1225 break;
1226 };
1227 if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
1230 break;
1231 }
1232 let Some(close_offset) = after_kw.find("-->") else {
1233 break;
1234 };
1235
1236 let json_str = after_kw[..close_offset].trim_matches(is_comment_space);
1237 if !json_str.is_empty()
1238 && let Ok(value) = serde_json::from_str(json_str)
1239 {
1240 found.push((comment_start, value));
1241 }
1242 pos = body_start + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
1243 break;
1244 }
1245 }
1246
1247 found
1248}
1249
1250pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
1254 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
1255}
1256
1257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1261pub enum DisableScope {
1262 Line(usize),
1264 Block(usize),
1266 File,
1268}
1269
1270impl DisableScope {
1271 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
1283 match (self, layer) {
1284 (Self::Line(target), DisableLayer::Line) => target == line,
1285 (Self::Block(start), DisableLayer::Block) => start < line,
1287 (Self::File, DisableLayer::File) => true,
1288 _ => false,
1289 }
1290 }
1291}
1292
1293#[derive(Debug, Clone, PartialEq, Eq)]
1295pub struct DisableSite {
1296 pub line: usize,
1298 pub span: std::ops::Range<usize>,
1300 pub kind: &'static str,
1302 pub rules: Vec<String>,
1304 pub scope: DisableScope,
1306}
1307
1308pub fn collect_disable_sites(
1320 content: &str,
1321 code_blocks: &[(usize, usize)],
1322 code_spans: &[(usize, usize)],
1323) -> Vec<DisableSite> {
1324 if !has_inline_config_markers(content) {
1325 return Vec::new();
1326 }
1327
1328 let line_spans: Vec<(usize, &str)> = {
1331 let mut spans = Vec::new();
1332 let mut start = 0;
1333 for line in content.split('\n') {
1334 spans.push((start, line));
1335 start += line.len() + 1;
1336 }
1337 spans
1338 };
1339
1340 let mut sites = Vec::new();
1341
1342 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
1343 if line_in_code_block(line_start, line, code_blocks) {
1344 continue;
1345 }
1346 let line_num = idx + 1;
1347 for directive in directives_outside_code_spans(line, line_start, code_spans) {
1348 let (kind, scope) = match directive.kind {
1349 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
1350 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
1351 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
1352 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1353 DirectiveKind::Enable
1354 | DirectiveKind::EnableFile
1355 | DirectiveKind::Capture
1356 | DirectiveKind::Restore
1357 | DirectiveKind::ConfigureFile => continue,
1358 };
1359 sites.push(DisableSite {
1360 line: line_num,
1361 span: directive.span,
1362 kind,
1363 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1364 scope,
1365 });
1366 }
1367 }
1368
1369 let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
1372 for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
1373 let (kind, scope) = match directive.kind {
1374 DirectiveKind::Disable => ("disable", DisableScope::Block(directive.end_line)),
1375 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(directive.end_line)),
1376 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(directive.end_line + 1)),
1377 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
1378 _ => continue,
1379 };
1380 let Some(&(line_start, line)) = line_spans.get(directive.start_line - 1) else {
1381 continue;
1382 };
1383 sites.push(DisableSite {
1384 line: directive.start_line,
1385 span: (directive.open - line_start)..(directive.end - line_start).min(line.len()),
1386 kind,
1387 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
1388 scope,
1389 });
1390 }
1391
1392 for (offset, json_config) in scan_configure_file_comments(content) {
1396 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
1397 continue;
1398 }
1399 let Some(obj) = json_config.as_object() else {
1400 continue;
1401 };
1402 let rules: Vec<String> = obj
1403 .iter()
1404 .filter(|(_, value)| value.as_bool() == Some(false))
1405 .map(|(name, _)| name.clone())
1406 .collect();
1407 if rules.is_empty() {
1408 continue;
1409 }
1410 let line_num = line_of_offset(content, offset);
1411 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
1412 continue;
1413 };
1414 let comment_end = content[offset..]
1415 .find("-->")
1416 .map_or(content.len(), |close| offset + close + 3);
1417 sites.push(DisableSite {
1418 line: line_num,
1419 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
1420 kind: "configure-file",
1421 rules,
1422 scope: DisableScope::File,
1423 });
1424 }
1425
1426 sites.sort_by_key(|site| (site.line, site.span.start));
1427 sites
1428}
1429
1430#[derive(Debug, Clone, PartialEq, Eq)]
1432pub enum InlineConfigProblem {
1433 UnknownRule,
1435 UnknownOption { key: String },
1438 EnableHasNoEffect { reason: EnableNoEffectReason },
1442}
1443
1444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1449pub enum EnableNoEffectReason {
1450 NotEnabled,
1452 IgnoredForFile,
1455}
1456
1457#[derive(Debug, Clone, PartialEq, Eq)]
1459pub struct InlineConfigWarning {
1460 pub line_number: usize,
1462 pub rule_name: String,
1464 pub comment_type: String,
1466 pub suggestion: Option<String>,
1468 pub problem: InlineConfigProblem,
1470}
1471
1472impl InlineConfigWarning {
1473 pub fn format_message(&self) -> String {
1475 match &self.problem {
1478 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1479 Some(ref suggestion) => format!(
1480 "Unknown option for rule {}: {} (did you mean: {}?)",
1481 self.rule_name, key, suggestion
1482 ),
1483 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1484 },
1485 InlineConfigProblem::UnknownRule => match self.suggestion {
1486 Some(ref suggestion) => format!(
1487 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1488 self.comment_type, self.rule_name, suggestion
1489 ),
1490 None => format!(
1491 "Unknown rule in inline {} comment: {}",
1492 self.comment_type, self.rule_name
1493 ),
1494 },
1495 InlineConfigProblem::EnableHasNoEffect {
1496 reason: EnableNoEffectReason::NotEnabled,
1497 } => format!(
1498 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1499 self.rule_name, self.comment_type
1500 ),
1501 InlineConfigProblem::EnableHasNoEffect {
1502 reason: EnableNoEffectReason::IgnoredForFile,
1503 } => format!(
1504 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1505 self.rule_name, self.comment_type
1506 ),
1507 }
1508 }
1509
1510 pub fn print_warning(&self, file_path: &str) {
1512 eprintln!(
1513 "{} {}:{}: {}",
1514 crate::utils::warning_label("inline config warning"),
1515 file_path,
1516 self.line_number,
1517 self.format_message()
1518 );
1519 }
1520
1521 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1529 let line_width = content
1530 .lines()
1531 .nth(self.line_number.saturating_sub(1))
1532 .map_or(0, |line| line.chars().count());
1533 crate::rule::LintWarning {
1534 message: self.format_message(),
1535 line: self.line_number,
1536 column: 1,
1537 end_line: self.line_number,
1538 end_column: line_width + 1,
1539 severity: crate::rule::Severity::Warning,
1540 fix: None,
1541 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1542 }
1543 }
1544}
1545
1546pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1551
1552pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1561 let (warnings, _) = collect_inline_config_warnings(content, flavor, true, None);
1562 drop_warnings_inside_code(content, flavor, warnings)
1563}
1564
1565pub fn validate_inline_enables_against_active_rules(
1570 content: &str,
1571 flavor: MarkdownFlavor,
1572 active_rules: &HashSet<String>,
1573 ignored_for_file: &HashSet<String>,
1574) -> Vec<InlineConfigWarning> {
1575 let (_, warnings) = collect_inline_config_warnings(content, flavor, false, Some((active_rules, ignored_for_file)));
1576 drop_warnings_inside_code(content, flavor, warnings)
1577}
1578
1579pub fn validate_inline_configuration(
1583 content: &str,
1584 flavor: MarkdownFlavor,
1585 active_rules: &HashSet<String>,
1586 ignored_for_file: &HashSet<String>,
1587) -> Vec<InlineConfigWarning> {
1588 let (mut warnings, enables) =
1589 collect_inline_config_warnings(content, flavor, true, Some((active_rules, ignored_for_file)));
1590 warnings.extend(enables);
1591 drop_warnings_inside_code(content, flavor, warnings)
1592}
1593
1594fn collect_inline_config_warnings(
1597 content: &str,
1598 flavor: MarkdownFlavor,
1599 validate_names: bool,
1600 active: Option<(&HashSet<String>, &HashSet<String>)>,
1601) -> (Vec<LocatedWarning>, Vec<LocatedWarning>) {
1602 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1603
1604 if !has_inline_config_markers(content) {
1605 return (Vec::new(), Vec::new());
1606 }
1607 let mut warnings = Vec::new();
1608 let mut enables = Vec::new();
1609 let all_rule_names = std::cell::OnceCell::<Vec<String>>::new();
1611 let suggest = |name: &str| {
1612 let names =
1613 all_rule_names.get_or_init(|| RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect());
1614 suggest_similar_key(name, names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1615 };
1616 let mut flag_enable = |name: &str, comment_type: &str, line: usize, offset: usize| {
1617 let Some((active_rules, ignored_for_file)) = active else {
1618 return;
1619 };
1620 if !is_valid_rule_name(name) {
1621 return;
1622 }
1623 let canonical = normalize_rule_name(name);
1624 let reason = if !active_rules.contains(&canonical) {
1625 EnableNoEffectReason::NotEnabled
1626 } else if ignored_for_file.contains(&canonical) {
1627 EnableNoEffectReason::IgnoredForFile
1628 } else {
1629 return;
1630 };
1631 enables.push((
1632 InlineConfigWarning {
1633 line_number: line,
1634 rule_name: canonical,
1635 comment_type: comment_type.to_string(),
1636 suggestion: None,
1637 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1638 },
1639 offset,
1640 ));
1641 };
1642
1643 for (offset, json_config) in scan_configure_file_comments(content) {
1644 let Some(obj) = json_config.as_object() else {
1645 continue;
1646 };
1647 let line_number = line_of_offset(content, offset);
1648 for (rule_name, rule_config) in obj {
1649 if validate_names {
1650 if !is_valid_rule_name(rule_name) {
1651 warnings.push((
1652 InlineConfigWarning {
1653 line_number,
1654 rule_name: rule_name.clone(),
1655 comment_type: "configure-file".to_string(),
1656 suggestion: suggest(rule_name),
1657 problem: InlineConfigProblem::UnknownRule,
1658 },
1659 offset,
1660 ));
1661 continue;
1662 }
1663 if let Some(options) = rule_config.as_object() {
1664 let canonical = normalize_rule_name(rule_name);
1665 if let Some(valid_keys) = crate::config::default_registry().config_keys_for(&canonical) {
1668 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1669 for key in options.keys().filter(|key| !valid_keys.contains(*key)) {
1670 warnings.push((
1671 InlineConfigWarning {
1672 line_number,
1673 rule_name: canonical.clone(),
1674 comment_type: "configure-file".to_string(),
1675 suggestion: suggest_similar_key(key, &valid_keys_vec),
1676 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1677 },
1678 offset,
1679 ));
1680 }
1681 }
1682 }
1683 }
1684 if rule_config.as_bool() == Some(true) {
1685 flag_enable(rule_name, "configure-file", line_number, offset);
1686 }
1687 }
1688 }
1689
1690 let mut named: Vec<(DirectiveKind, Vec<&str>, usize, usize)> = Vec::new();
1695 let mut line_start = 0;
1696 for (idx, line) in content.split('\n').enumerate() {
1697 for directive in parse_inline_directives(line) {
1698 named.push((
1699 directive.kind,
1700 directive.rules,
1701 idx + 1,
1702 line_start + directive.span.start,
1703 ));
1704 }
1705 line_start += line.len() + 1;
1706 }
1707 let code = std::cell::OnceCell::new();
1710 let code_test = std::cell::OnceCell::new();
1711 let multiline_in_code = |open: usize| {
1712 code_test
1713 .get_or_init(|| {
1714 let ranges = code.get_or_init(|| crate::lint_context::code_ranges(content, flavor));
1715 CommentCodeTest::new(content, &ranges.blocks, &ranges.spans)
1716 })
1717 .contains(open)
1718 };
1719 for directive in scan_multiline_directives(content, multiline_in_code) {
1720 named.push((directive.kind, directive.rules, directive.start_line, directive.open));
1721 }
1722 for (kind, rules, line_number, offset) in named {
1723 let Some(comment_type) = directive_comment_type(kind) else {
1724 continue;
1725 };
1726 for rule_name in rules {
1727 if validate_names && !is_valid_rule_name(rule_name) {
1728 warnings.push((
1729 InlineConfigWarning {
1730 line_number,
1731 rule_name: rule_name.to_string(),
1732 comment_type: comment_type.to_string(),
1733 suggestion: suggest(rule_name),
1734 problem: InlineConfigProblem::UnknownRule,
1735 },
1736 offset,
1737 ));
1738 }
1739 if matches!(kind, DirectiveKind::Enable | DirectiveKind::EnableFile) {
1740 flag_enable(rule_name, comment_type, line_number, offset);
1741 }
1742 }
1743 }
1744 warnings.sort_by_key(|(warning, _)| warning.line_number);
1745 enables.sort_by_key(|(warning, _)| warning.line_number);
1746 (warnings, enables)
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751 use super::*;
1752
1753 #[test]
1754 fn combined_validation_preserves_warning_order_and_literal_code() {
1755 let source = concat!(
1756 "<!-- rumdl-enable MD009 -->\n",
1757 "<!-- rumdl-disable MD999 -->\n",
1758 "<!-- rumdl-configure-file {\"MD011\":true,\"MD013\":{\"line-lenght\":40}} -->\n",
1759 "<!-- rumdl-enable MD013 -->\n",
1760 "```markdown\n",
1761 "<!-- rumdl-enable MD010 -->\n",
1762 "<!-- rumdl-disable MD998 -->\n",
1763 "```\n",
1764 "`<!-- rumdl-enable MD012 -->`\n",
1765 );
1766 let active = HashSet::from(["MD013".to_string()]);
1767 let ignored = active.clone();
1768 for ending in ["\n", "\r\n"] {
1769 let content = source.replace('\n', ending);
1770 for flavor in [
1771 MarkdownFlavor::Standard,
1772 MarkdownFlavor::MkDocs,
1773 MarkdownFlavor::MDX,
1774 MarkdownFlavor::Pandoc,
1775 MarkdownFlavor::Quarto,
1776 MarkdownFlavor::Obsidian,
1777 MarkdownFlavor::Kramdown,
1778 MarkdownFlavor::AzureDevOps,
1779 MarkdownFlavor::MyST,
1780 MarkdownFlavor::Hugo,
1781 MarkdownFlavor::MDG,
1782 MarkdownFlavor::GhAw,
1783 ] {
1784 let mut separate = validate_inline_config_rules(&content, flavor);
1785 separate.extend(validate_inline_enables_against_active_rules(
1786 &content, flavor, &active, &ignored,
1787 ));
1788 let combined = validate_inline_configuration(&content, flavor, &active, &ignored);
1789 assert_eq!(format!("{combined:?}"), format!("{separate:?}"), "{flavor:?}");
1790 assert_eq!(
1791 combined
1792 .iter()
1793 .map(|warning| (warning.rule_name.as_str(), warning.line_number))
1794 .collect::<Vec<_>>(),
1795 vec![("MD999", 2), ("MD013", 3), ("MD009", 1), ("MD011", 3), ("MD013", 4)],
1796 "{flavor:?}",
1797 );
1798 }
1799 }
1800 }
1801
1802 const COMPACT_OPENERS: &[&str] = &["<!--", "<!-- ", "<!-- ", "<!--\t", "<!-- \t "];
1805 const COMPACT_CLOSERS: &[&str] = &["-->", " -->", " -->", "\t-->"];
1806
1807 #[test]
1808 fn test_parse_inline_directives_whitespace_around_delimiters_is_optional() {
1809 let cases: &[(&str, DirectiveKind, &[&str])] = &[
1810 ("rumdl-disable MD013", DirectiveKind::Disable, &["MD013"]),
1811 (
1812 "markdownlint-disable MD013 MD009",
1813 DirectiveKind::Disable,
1814 &["MD013", "MD009"],
1815 ),
1816 ("markdownlint-disable", DirectiveKind::Disable, &[]),
1817 ("rumdl-enable", DirectiveKind::Enable, &[]),
1818 ("markdownlint-enable MD013", DirectiveKind::Enable, &["MD013"]),
1819 ("rumdl-disable-line MD013", DirectiveKind::DisableLine, &["MD013"]),
1820 (
1821 "markdownlint-disable-next-line MD013",
1822 DirectiveKind::DisableNextLine,
1823 &["MD013"],
1824 ),
1825 (
1826 "markdownlint-disable-file MD013",
1827 DirectiveKind::DisableFile,
1828 &["MD013"],
1829 ),
1830 ("rumdl-enable-file", DirectiveKind::EnableFile, &[]),
1831 ("markdownlint-capture", DirectiveKind::Capture, &[]),
1832 ("rumdl-restore", DirectiveKind::Restore, &[]),
1833 ("markdownlint-configure-file {}", DirectiveKind::ConfigureFile, &["{}"]),
1834 ];
1835 for &(body, kind, rules) in cases {
1836 for open in COMPACT_OPENERS {
1837 for close in COMPACT_CLOSERS {
1838 let comment = format!("{open}{body}{close}");
1839 let line = format!("text {comment} tail");
1841 let directives = parse_inline_directives(&line);
1842 assert_eq!(directives.len(), 1, "{line:?}");
1843 assert_eq!(directives[0].kind, kind, "{line:?}");
1844 assert_eq!(directives[0].rules, rules, "{line:?}");
1845 assert_eq!(&line[directives[0].span.clone()], comment, "{line:?}");
1846 }
1847 }
1848 }
1849 }
1850
1851 #[test]
1852 fn test_parse_inline_directives_compact_not_a_directive() {
1853 for line in [
1854 "<!--markdownlint-disablefoo -->",
1855 "<!--rumdl-disableMD013-->",
1856 "<!--rumdl-enablebar-->",
1857 "<!--rumdl-captures-->",
1858 "<!--RUMDL-disable MD009 -->",
1859 "<!--Markdownlint-Disable-->",
1860 "<!--note rumdl-disable MD009 -->",
1861 "<!---- rumdl-disable MD009 -->",
1862 "<!--rumdl-disable MD009",
1863 "<!--rumdl-",
1864 "<!--",
1865 ] {
1866 assert!(parse_inline_directives(line).is_empty(), "{line:?}");
1867 }
1868 }
1869
1870 #[test]
1871 fn test_parse_inline_directives_finds_compact_directive_after_other_comment() {
1872 let line = "<!-- other <!--rumdl-disable MD009--> <!--markdownlint-enable MD009-->";
1873 let directives = parse_inline_directives(line);
1874 assert_eq!(directives.len(), 2, "{line:?}");
1875 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1876 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1877 }
1878
1879 #[test]
1880 fn test_scan_configure_file_compact_and_multiline() {
1881 for text in [
1882 "<!--rumdl-configure-file {\"MD013\":false}-->",
1883 "<!--markdownlint-configure-file {\"MD013\":false} -->",
1884 "<!--\trumdl-configure-file\n{\n\"MD013\":false\n}\n-->",
1885 "<!--\nrumdl-configure-file {\"MD013\":false}-->",
1886 "x <!-- other --> <!--rumdl-configure-file {\"MD013\":false}-->",
1887 ] {
1888 let found = scan_configure_file_comments(text);
1889 assert_eq!(found.len(), 1, "{text:?}");
1890 assert_eq!(found[0].1, serde_json::json!({"MD013": false}), "{text:?}");
1891 assert_eq!(&text[found[0].0..found[0].0 + 4], "<!--", "{text:?}");
1892 }
1893 for text in [
1894 "<!--rumdl-configure-files {\"MD013\":false}-->",
1895 "<!--rumdl-configure-file{\"MD013\":false}-->",
1896 "<!--RUMDL-configure-file {\"MD013\":false}-->",
1897 "<!--rumdl-configure-file {\"MD013\":false}",
1898 ] {
1899 assert!(scan_configure_file_comments(text).is_empty(), "{text:?}");
1900 }
1901 let two = "<!--rumdl-configure-file {\"MD013\":false}--><!--rumdl-configure-file {\"MD009\":false}-->";
1903 assert_eq!(scan_configure_file_comments(two).len(), 2);
1904 }
1905
1906 #[test]
1907 fn test_from_content_compact_directives_match_spaced_form() {
1908 let pairs: &[(&str, &str)] = &[
1910 (
1911 "<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n",
1912 "<!-- rumdl-disable MD013 -->\nA\n<!-- rumdl-enable MD013 -->\nB\n",
1913 ),
1914 (
1915 "A <!--markdownlint-disable-line MD013-->\nB\n",
1916 "A <!-- markdownlint-disable-line MD013 -->\nB\n",
1917 ),
1918 (
1919 "<!--markdownlint-disable-next-line MD013-->\nA\nB\n",
1920 "<!-- markdownlint-disable-next-line MD013 -->\nA\nB\n",
1921 ),
1922 (
1923 "<!--rumdl-disable-file MD013-->\nA\n",
1924 "<!-- rumdl-disable-file MD013 -->\nA\n",
1925 ),
1926 (
1927 "<!--rumdl-disable-file-->\n<!--rumdl-enable-file MD013-->\nA\n",
1928 "<!-- rumdl-disable-file -->\n<!-- rumdl-enable-file MD013 -->\nA\n",
1929 ),
1930 (
1931 "<!--markdownlint-disable MD013-->\n<!--markdownlint-capture-->\n<!--markdownlint-enable-->\nA\n<!--markdownlint-restore-->\nB\n",
1932 "<!-- markdownlint-disable MD013 -->\n<!-- markdownlint-capture -->\n<!-- markdownlint-enable -->\nA\n<!-- markdownlint-restore -->\nB\n",
1933 ),
1934 ("<!--prettier-ignore-->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
1935 ("<!--prettier-ignore -->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
1936 (
1937 "<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n",
1938 "<!-- rumdl-configure-file {\"MD013\":{\"line-length\":10}} -->\nA\n",
1939 ),
1940 (
1941 "<!--markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
1942 "<!-- markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
1943 ),
1944 (
1945 "> <!--rumdl-disable-next-line MD013-->\n> A\n",
1946 "> <!-- rumdl-disable-next-line MD013 -->\n> A\n",
1947 ),
1948 (
1949 "- item\n\n <!--rumdl-disable-next-line MD013-->\n A\n",
1950 "- item\n\n <!-- rumdl-disable-next-line MD013 -->\n A\n",
1951 ),
1952 ];
1953 for (compact, spaced) in pairs {
1954 let a = InlineConfig::from_content(compact);
1955 let b = InlineConfig::from_content(spaced);
1956 let lines = compact.split('\n').count() + 2;
1957 for line in 1..=lines {
1958 for rule in ["MD013", "MD009"] {
1959 assert_eq!(
1960 a.disabling_layer(rule, line),
1961 b.disabling_layer(rule, line),
1962 "{compact:?} vs {spaced:?}, {rule} at line {line}"
1963 );
1964 }
1965 }
1966 assert_eq!(a.get_all_rule_configs(), b.get_all_rule_configs(), "{compact:?}");
1967 }
1968 let c = InlineConfig::from_content("<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n");
1970 assert!(c.is_rule_disabled("MD013", 2));
1971 assert!(!c.is_rule_disabled("MD013", 4));
1972 let c = InlineConfig::from_content("<!--prettier-ignore-->\nA\nB\n");
1973 assert!(c.is_rule_disabled("MD013", 2));
1974 assert!(!c.is_rule_disabled("MD013", 3));
1975 let c = InlineConfig::from_content("<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n");
1976 assert!(c.get_rule_config("MD013").is_some());
1977 }
1978
1979 #[test]
1980 fn test_from_content_compact_directive_in_code_configures_nothing() {
1981 for content in [
1982 "`<!--rumdl-disable MD013-->`\nA\n",
1983 "```\n<!--rumdl-disable MD013-->\n```\nA\n",
1984 " <!--rumdl-disable MD013-->\n\nA\n",
1985 "`<!--rumdl-disable-file MD013-->`\nA\n",
1986 "`<!--prettier-ignore-->`\nA\n",
1987 "`<!--rumdl-configure-file {\"MD013\":false}-->`\nA\n",
1988 ] {
1989 let config = InlineConfig::from_content(content);
1990 for line in 1..=6 {
1991 assert!(!config.is_rule_disabled("MD013", line), "{content:?} line {line}");
1992 }
1993 assert!(config.get_all_rule_configs().is_empty(), "{content:?}");
1994 }
1995 }
1996
1997 #[test]
1998 fn test_validate_inline_config_reports_unknown_rule_in_compact_directive() {
1999 let warnings = validate_inline_config_rules("<!--rumdl-disable MD998-->\n", MarkdownFlavor::Standard);
2000 assert_eq!(warnings.len(), 1, "{warnings:?}");
2001 assert_eq!(warnings[0].line_number, 1);
2002 }
2003
2004 #[test]
2007 fn candidate_scan_keeps_nested_and_multiline_directives() {
2008 for source in [
2009 "<!-- rumdl-disable MD009 -->",
2010 "prose <!-- markdownlint-enable-file MD013 -->",
2011 "<!-- rumdl-configure-file {\n\"MD013\":false\n} -->",
2012 "<!-- other <!-- rumdl-disable-next-line MD009 -->",
2013 "<!-- prettier-ignore -->",
2014 "<!--rumdl-disable MD009 -->",
2015 "<!--markdownlint-disable-->",
2016 "<!--\trumdl-enable-->",
2017 "<!--prettier-ignore-->",
2018 "<!-- prettier-ignore-->",
2019 "<!--prettier-ignore -->",
2020 "<!--\nrumdl-configure-file {} -->",
2021 ] {
2022 assert!(has_inline_config_markers(source), "{source:?}");
2023 }
2024 for source in [
2025 "rumdl prose",
2026 "<!-- unrelated --> rumdl",
2027 "<!-- RUMDL-disable MD009 -->",
2028 "<!--RUMDL-disable MD009 -->",
2029 "<!---- rumdl-disable MD009 -->",
2030 "<!--prettier-ignore-start-->",
2031 "<!-- prettier-ignore-end -->",
2032 "<!--x--> rumdl-disable",
2033 ] {
2034 assert!(!has_inline_config_markers(source), "{source:?}");
2035 assert!(validate_inline_config_rules(source, MarkdownFlavor::Standard).is_empty());
2036 }
2037 }
2038
2039 #[test]
2040 fn test_parse_inline_directives_all_kinds() {
2041 let cases: &[(&str, DirectiveKind)] = &[
2043 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
2044 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
2045 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
2046 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
2047 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
2048 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
2049 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
2050 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
2051 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
2052 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
2054 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
2055 (
2056 "<!-- markdownlint-disable-next-line -->",
2057 DirectiveKind::DisableNextLine,
2058 ),
2059 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
2060 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
2061 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
2062 ];
2063 for (input, expected_kind) in cases {
2064 let directives = parse_inline_directives(input);
2065 assert_eq!(
2066 directives.len(),
2067 1,
2068 "Expected 1 directive for {input:?}, got {directives:?}"
2069 );
2070 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
2071 }
2072 }
2073
2074 #[test]
2075 fn test_parse_inline_directives_disambiguation() {
2076 let line = "<!-- rumdl-disable-line MD001 -->";
2078 let directives = parse_inline_directives(line);
2079 assert_eq!(directives.len(), 1);
2080 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
2081
2082 let line = "<!-- rumdl-disable-next-line -->";
2083 let directives = parse_inline_directives(line);
2084 assert_eq!(directives.len(), 1);
2085 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
2086
2087 let line = "<!-- rumdl-disable-file MD001 -->";
2088 let directives = parse_inline_directives(line);
2089 assert_eq!(directives.len(), 1);
2090 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
2091
2092 let line = "<!-- rumdl-enable-file -->";
2093 let directives = parse_inline_directives(line);
2094 assert_eq!(directives.len(), 1);
2095 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
2096 }
2097
2098 #[test]
2099 fn test_parse_inline_directives_no_space_before_close() {
2100 let directives = parse_inline_directives("<!-- rumdl-disable-->");
2102 assert_eq!(directives.len(), 1);
2103 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2104 assert!(directives[0].rules.is_empty());
2105
2106 let directives = parse_inline_directives("<!-- rumdl-enable-->");
2107 assert_eq!(directives.len(), 1);
2108 assert_eq!(directives[0].kind, DirectiveKind::Enable);
2109 }
2110
2111 #[test]
2112 fn test_parse_inline_directives_multiple_on_one_line() {
2113 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
2114 let directives = parse_inline_directives(line);
2115 assert_eq!(directives.len(), 2);
2116 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2117 assert_eq!(directives[0].rules, vec!["MD001"]);
2118 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2119 assert_eq!(directives[1].rules, vec!["MD001"]);
2120 }
2121
2122 #[test]
2123 fn test_parse_inline_directives_global_disable_then_specific_enable() {
2124 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
2125 let directives = parse_inline_directives(line);
2126 assert_eq!(directives.len(), 2);
2127 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2128 assert!(directives[0].rules.is_empty());
2129 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2130 assert_eq!(directives[1].rules, vec!["MD001"]);
2131 }
2132
2133 #[test]
2134 fn test_parse_inline_directives_word_boundary() {
2135 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
2137 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
2139 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
2141 }
2142
2143 #[test]
2144 fn test_parse_inline_directives_no_closing_tag() {
2145 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
2147 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
2148 }
2149
2150 #[test]
2151 fn test_parse_inline_directives_not_a_comment() {
2152 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
2153 assert!(parse_inline_directives("Some regular text").is_empty());
2154 assert!(parse_inline_directives("").is_empty());
2155 }
2156
2157 #[test]
2158 fn test_parse_inline_directives_case_sensitive() {
2159 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
2160 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
2161 }
2162
2163 #[test]
2164 fn test_parse_inline_directives_rules_extraction() {
2165 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
2166 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
2167
2168 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
2170 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
2171
2172 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
2174 assert_eq!(directives[0].rules, vec!["MD001"]);
2175 }
2176
2177 #[test]
2178 fn test_parse_inline_directives_embedded_in_text() {
2179 let line = "Some text <!-- rumdl-disable MD001 --> more text";
2180 let directives = parse_inline_directives(line);
2181 assert_eq!(directives.len(), 1);
2182 assert_eq!(directives[0].rules, vec!["MD001"]);
2183
2184 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
2185 let directives = parse_inline_directives(line);
2186 assert_eq!(directives.len(), 1);
2187 assert_eq!(directives[0].rules, vec!["MD001"]);
2188 }
2189
2190 #[test]
2191 fn test_parse_inline_directives_mixed_tools_same_line() {
2192 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
2193 let directives = parse_inline_directives(line);
2194 assert_eq!(directives.len(), 2);
2195 assert_eq!(directives[0].kind, DirectiveKind::Disable);
2196 assert_eq!(directives[0].rules, vec!["MD001"]);
2197 assert_eq!(directives[1].kind, DirectiveKind::Enable);
2198 assert_eq!(directives[1].rules, vec!["MD002"]);
2199 }
2200
2201 #[test]
2204 fn test_parse_disable_comment() {
2205 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
2207 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2208
2209 assert_eq!(
2211 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
2212 Some(vec!["MD001", "MD002"])
2213 );
2214
2215 assert_eq!(parse_disable_comment("Some regular text"), None);
2217 }
2218
2219 #[test]
2220 fn test_parse_disable_line_comment() {
2221 assert_eq!(
2223 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
2224 Some(vec![])
2225 );
2226
2227 assert_eq!(
2229 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
2230 Some(vec!["MD013"])
2231 );
2232
2233 assert_eq!(parse_disable_line_comment("Some regular text"), None);
2235 }
2236
2237 #[test]
2238 fn test_inline_config_from_content() {
2239 let content = r#"# Test Document
2240
2241<!-- markdownlint-disable MD013 -->
2242This is a very long line that would normally trigger MD013 but it's disabled
2243
2244<!-- markdownlint-enable MD013 -->
2245This line will be checked again
2246
2247<!-- markdownlint-disable-next-line MD001 -->
2248# This heading will not be checked for MD001
2249## But this one will
2250
2251Some text <!-- markdownlint-disable-line MD013 -->
2252
2253<!-- markdownlint-capture -->
2254<!-- markdownlint-disable MD001 MD002 -->
2255# Heading with MD001 disabled
2256<!-- markdownlint-restore -->
2257# Heading with MD001 enabled again
2258"#;
2259
2260 let config = InlineConfig::from_content(content);
2261
2262 assert!(config.is_rule_disabled("MD013", 4));
2264
2265 assert!(!config.is_rule_disabled("MD013", 7));
2267
2268 assert!(config.is_rule_disabled("MD001", 10));
2270
2271 assert!(!config.is_rule_disabled("MD001", 11));
2273
2274 assert!(config.is_rule_disabled("MD013", 13));
2276
2277 assert!(!config.is_rule_disabled("MD001", 19));
2279 }
2280
2281 #[test]
2282 fn test_capture_restore() {
2283 let content = r#"<!-- markdownlint-disable MD001 -->
2284<!-- markdownlint-capture -->
2285<!-- markdownlint-disable MD002 MD003 -->
2286<!-- markdownlint-restore -->
2287Some content after restore
2288"#;
2289
2290 let config = InlineConfig::from_content(content);
2291
2292 assert!(config.is_rule_disabled("MD001", 5));
2294 assert!(!config.is_rule_disabled("MD002", 5));
2295 assert!(!config.is_rule_disabled("MD003", 5));
2296 }
2297
2298 #[test]
2299 fn test_validate_inline_config_rules_unknown_rule() {
2300 let content = "<!-- rumdl-disable abc -->\nSome content";
2301 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2302 assert_eq!(warnings.len(), 1);
2303 assert_eq!(warnings[0].line_number, 1);
2304 assert_eq!(warnings[0].rule_name, "abc");
2305 assert_eq!(warnings[0].comment_type, "disable");
2306 }
2307
2308 #[test]
2309 fn test_validate_inline_config_rules_valid_rule() {
2310 let content = "<!-- rumdl-disable MD001 -->\nSome content";
2311 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2312 assert!(
2313 warnings.is_empty(),
2314 "MD001 is a valid rule, should not produce warnings"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_validate_inline_config_rules_alias() {
2320 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
2321 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2322 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
2323 }
2324
2325 #[test]
2326 fn test_validate_inline_config_rules_multiple_unknown() {
2327 let content = r#"<!-- rumdl-disable abc xyz -->
2328<!-- rumdl-disable-line foo -->
2329<!-- markdownlint-disable-next-line bar -->
2330"#;
2331 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2332 assert_eq!(warnings.len(), 4);
2333 assert_eq!(warnings[0].rule_name, "abc");
2334 assert_eq!(warnings[1].rule_name, "xyz");
2335 assert_eq!(warnings[2].rule_name, "foo");
2336 assert_eq!(warnings[3].rule_name, "bar");
2337 }
2338
2339 #[test]
2340 fn test_validate_inline_config_rules_suggestion() {
2341 let content = "<!-- rumdl-disable MD00 -->\n";
2343 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2344 assert_eq!(warnings.len(), 1);
2345 assert!(warnings[0].suggestion.is_some());
2347 }
2348
2349 #[test]
2350 fn test_validate_inline_config_rules_file_comments() {
2351 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
2352 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2353 assert_eq!(warnings.len(), 2);
2354 assert_eq!(warnings[0].comment_type, "disable-file");
2355 assert_eq!(warnings[1].comment_type, "enable-file");
2356 }
2357
2358 #[test]
2359 fn test_validate_inline_config_rules_global_disable() {
2360 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
2362 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2363 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
2364 }
2365
2366 #[test]
2367 fn test_validate_inline_config_rules_mixed_valid_invalid() {
2368 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
2370 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2371 assert_eq!(warnings.len(), 2);
2372 assert_eq!(warnings[0].rule_name, "abc");
2373 assert_eq!(warnings[1].rule_name, "xyz");
2374 }
2375
2376 #[test]
2377 fn test_validate_inline_config_rules_configure_file() {
2378 let content =
2380 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
2381 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2382 assert_eq!(warnings.len(), 1);
2383 assert_eq!(warnings[0].rule_name, "nonexistent");
2384 assert_eq!(warnings[0].comment_type, "configure-file");
2385 }
2386
2387 #[test]
2388 fn test_validate_inline_config_rules_markdownlint_variants() {
2389 let content = r#"<!-- markdownlint-disable unknown_rule -->
2391<!-- markdownlint-enable another_fake -->
2392<!-- markdownlint-disable-line bad_rule -->
2393<!-- markdownlint-disable-next-line fake_rule -->
2394<!-- markdownlint-disable-file missing_rule -->
2395<!-- markdownlint-enable-file nonexistent -->
2396"#;
2397 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2398 assert_eq!(warnings.len(), 6);
2399 assert_eq!(warnings[0].rule_name, "unknown_rule");
2400 assert_eq!(warnings[1].rule_name, "another_fake");
2401 assert_eq!(warnings[2].rule_name, "bad_rule");
2402 assert_eq!(warnings[3].rule_name, "fake_rule");
2403 assert_eq!(warnings[4].rule_name, "missing_rule");
2404 assert_eq!(warnings[5].rule_name, "nonexistent");
2405 }
2406
2407 #[test]
2411 fn test_validate_inline_config_rules_ignores_code_blocks() {
2412 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
2413 assert!(
2414 validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
2415 "a directive inside a fence is documentation, not configuration"
2416 );
2417
2418 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
2419 assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
2420
2421 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
2422 assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
2423
2424 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2425 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2426 }
2427
2428 #[test]
2433 fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
2434 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
2435 assert_eq!(
2436 validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
2437 1,
2438 "an admonition holds its content at an indent that is not code"
2439 );
2440 assert!(
2441 validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
2442 "without admonitions the same lines are an indented code block"
2443 );
2444
2445 let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
2446 assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
2447 assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
2448 }
2449
2450 #[test]
2453 fn test_validate_inline_enables_reports_inside_an_indented_container() {
2454 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
2455 let active = active_set(&["MD009"]);
2456 assert_eq!(
2457 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
2458 .len(),
2459 1,
2460 "the enable is live in an admonition body, and configuration never enabled MD013"
2461 );
2462 assert!(
2463 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
2464 .is_empty(),
2465 "without admonitions the same lines are an indented code block"
2466 );
2467 }
2468
2469 #[test]
2472 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
2473 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
2474 assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
2475
2476 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
2477 assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
2478
2479 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
2480 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
2481 }
2482
2483 #[test]
2484 fn test_validate_inline_config_rules_markdownlint_configure_file() {
2485 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
2486 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2487 assert_eq!(warnings.len(), 1);
2488 assert_eq!(warnings[0].rule_name, "fake_rule");
2489 assert_eq!(warnings[0].comment_type, "configure-file");
2490 }
2491
2492 #[test]
2493 fn test_get_rule_config_from_configure_file() {
2494 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
2495
2496This is a test line."#;
2497
2498 let inline_config = InlineConfig::from_content(content);
2499 let config_override = inline_config.get_rule_config("MD013");
2500
2501 assert!(config_override.is_some(), "MD013 config should be found");
2502 let json = config_override.unwrap();
2503 assert!(json.is_object(), "Config should be an object");
2504 let obj = json.as_object().unwrap();
2505 assert!(obj.contains_key("line_length"), "Should have line_length key");
2506 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2507 }
2508
2509 #[test]
2510 fn test_get_rule_config_tables_false() {
2511 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
2513
2514 let inline_config = InlineConfig::from_content(content);
2515 let config_override = inline_config.get_rule_config("MD013");
2516
2517 assert!(config_override.is_some(), "MD013 config should be found");
2518 let json = config_override.unwrap();
2519 let obj = json.as_object().unwrap();
2520 assert!(obj.contains_key("tables"), "Should have tables key");
2521 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
2522 }
2523
2524 fn active_set(names: &[&str]) -> HashSet<String> {
2538 names.iter().map(|s| (*s).to_string()).collect()
2539 }
2540
2541 fn no_ignores() -> HashSet<String> {
2542 HashSet::new()
2543 }
2544
2545 #[test]
2546 fn test_inline_enable_of_inactive_rule_warns() {
2547 let active = active_set(&["MD013", "MD022"]);
2548 let content = "<!-- rumdl-enable MD012 -->\n";
2549
2550 let warnings =
2551 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2552
2553 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2554 assert_eq!(warnings[0].rule_name, "MD012");
2555 assert_eq!(warnings[0].comment_type, "enable");
2556 assert_eq!(
2557 warnings[0].problem,
2558 InlineConfigProblem::EnableHasNoEffect {
2559 reason: EnableNoEffectReason::NotEnabled
2560 }
2561 );
2562 assert_eq!(warnings[0].line_number, 1);
2563 }
2564
2565 #[test]
2566 fn test_inline_enable_of_active_rule_does_not_warn() {
2567 let active = active_set(&["MD012", "MD013"]);
2570 let content = "<!-- rumdl-enable MD012 -->\n";
2571
2572 let warnings =
2573 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2574
2575 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
2576 }
2577
2578 #[test]
2579 fn test_inline_enable_of_per_file_ignored_rule_warns() {
2580 let active = active_set(&["MD012", "MD013"]);
2583 let ignored = active_set(&["MD012"]);
2584 let content = "<!-- rumdl-enable MD012 -->\n";
2585
2586 let warnings =
2587 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2588
2589 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
2590 assert_eq!(warnings[0].rule_name, "MD012");
2591 assert_eq!(
2592 warnings[0].problem,
2593 InlineConfigProblem::EnableHasNoEffect {
2594 reason: EnableNoEffectReason::IgnoredForFile
2595 }
2596 );
2597 assert!(
2598 warnings[0].format_message().contains("per-file-ignores"),
2599 "the message must name the setting in play: {}",
2600 warnings[0].format_message()
2601 );
2602 }
2603
2604 #[test]
2605 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
2606 let active = active_set(&["MD013"]);
2609 let ignored = active_set(&["MD012"]);
2610 let content = "<!-- rumdl-enable MD012 -->\n";
2611
2612 let warnings =
2613 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2614
2615 assert_eq!(warnings.len(), 1, "{warnings:?}");
2616 assert_eq!(
2617 warnings[0].problem,
2618 InlineConfigProblem::EnableHasNoEffect {
2619 reason: EnableNoEffectReason::NotEnabled
2620 }
2621 );
2622 }
2623
2624 #[test]
2625 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
2626 let active = active_set(&["MD012", "MD013"]);
2629 let ignored = active_set(&["MD013"]);
2630 let content = "<!-- rumdl-enable MD012 -->\n";
2631
2632 let warnings =
2633 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2634
2635 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
2636 }
2637
2638 #[test]
2639 fn test_enable_inside_code_block_does_not_warn() {
2640 let active = active_set(&["MD012", "MD013"]);
2643 let ignored = active_set(&["MD012"]);
2644
2645 for content in [
2646 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
2647 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
2648 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
2649 ] {
2650 let warnings =
2651 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2652 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
2653 }
2654
2655 for content in [
2656 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
2657 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
2658 ] {
2659 let warnings =
2660 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2661 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
2662 }
2663 }
2664
2665 #[test]
2666 fn test_bare_enable_all_does_not_warn() {
2667 let active = active_set(&["MD013"]);
2669 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
2670 let warnings = validate_inline_enables_against_active_rules(
2671 content,
2672 MarkdownFlavor::Standard,
2673 &active,
2674 &active_set(&["MD013"]),
2675 );
2676 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
2677 }
2678 }
2679
2680 #[test]
2681 fn test_enable_file_and_alias_of_inactive_rule_warn() {
2682 let active = active_set(&["MD013"]);
2683 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
2685
2686 let warnings =
2687 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2688
2689 assert_eq!(warnings.len(), 1, "{warnings:?}");
2690 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
2691 assert_eq!(warnings[0].comment_type, "enable-file");
2692 }
2693
2694 #[test]
2695 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
2696 let active = active_set(&["MD013"]);
2697 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2698 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2699
2700 let warn_true =
2701 validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
2702 let warn_false =
2703 validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
2704
2705 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
2706 assert_eq!(warn_true[0].rule_name, "MD012");
2707 assert!(
2708 warn_false.is_empty(),
2709 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
2710 );
2711 }
2712
2713 #[test]
2714 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
2715 let active = active_set(&["MD013"]);
2718 let content = "<!-- rumdl-enable NotARule -->\n";
2719
2720 let warnings =
2721 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
2722
2723 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
2724 }
2725
2726 #[test]
2727 fn test_disable_directive_never_warns_as_no_effect() {
2728 let active = active_set(&["MD012", "MD013"]);
2731 let ignored = active_set(&["MD012"]);
2732 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
2733
2734 let warnings =
2735 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2736
2737 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
2738 }
2739
2740 #[test]
2746 fn test_unknown_option_key_in_configure_file_warns() {
2747 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2748
2749 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2750
2751 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2752 assert_eq!(warnings[0].rule_name, "MD013");
2753 assert_eq!(
2754 warnings[0].problem,
2755 InlineConfigProblem::UnknownOption {
2756 key: "line_lenght".to_string()
2757 }
2758 );
2759 assert!(
2760 warnings[0].suggestion.is_some(),
2761 "a near-miss key should suggest the real one"
2762 );
2763 assert!(
2764 warnings[0].format_message().contains("Unknown option for rule MD013"),
2765 "message was: {}",
2766 warnings[0].format_message()
2767 );
2768 }
2769
2770 #[test]
2771 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2772 for content in [
2775 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2776 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2777 ] {
2778 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2779 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2780 }
2781 }
2782
2783 #[test]
2784 fn test_unknown_rule_does_not_also_warn_about_its_options() {
2785 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2788
2789 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2790
2791 assert_eq!(
2792 warnings.len(),
2793 1,
2794 "expected only the unknown-rule warning: {warnings:?}"
2795 );
2796 assert_eq!(warnings[0].rule_name, "nonexistent");
2797 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2798 }
2799
2800 #[test]
2801 fn test_boolean_rule_value_has_no_option_warnings() {
2802 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2803
2804 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2805
2806 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2807 }
2808
2809 #[test]
2810 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2811 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2812
2813 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2814
2815 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2816 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2817 }
2818
2819 #[test]
2820 fn test_configure_file_spanning_multiple_lines_applies() {
2821 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2822
2823 let inline_config = InlineConfig::from_content(content);
2824 let config_override = inline_config.get_rule_config("MD013");
2825
2826 assert!(config_override.is_some(), "a multi-line configure-file must apply");
2827 let obj = config_override.unwrap().as_object().unwrap();
2828 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2829 }
2830
2831 #[test]
2832 fn test_every_configure_file_comment_applies_not_just_the_first() {
2833 let content = "<!-- markdownlint-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n\n<!-- markdownlint-configure-file\n{\n \"MD007\": { \"indent\": 4 }\n}\n-->\n\n# Head\n";
2836
2837 let inline_config = InlineConfig::from_content(content);
2838
2839 assert!(
2840 inline_config.get_rule_config("MD013").is_some(),
2841 "first (single-line) configure-file dropped"
2842 );
2843 assert!(
2844 inline_config.get_rule_config("MD007").is_some(),
2845 "second (multi-line) configure-file dropped"
2846 );
2847 }
2848
2849 #[test]
2850 fn test_multiline_configure_file_in_code_block_is_ignored() {
2851 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2854
2855 let inline_config = InlineConfig::from_content(content);
2856
2857 assert!(
2858 inline_config.get_rule_config("MD013").is_none(),
2859 "configure-file inside a fenced code block must not apply"
2860 );
2861 }
2862
2863 #[test]
2864 fn test_multiline_configure_file_bool_and_alias_still_honored() {
2865 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2868
2869 let inline_config = InlineConfig::from_content(content);
2870
2871 assert!(
2872 inline_config.is_rule_disabled("MD012", 1),
2873 "boolean alias key must disable the rule"
2874 );
2875 let obj = inline_config
2876 .get_rule_config("MD013")
2877 .expect("alias-keyed config should resolve to MD013")
2878 .as_object()
2879 .unwrap();
2880 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2881 }
2882
2883 #[test]
2884 fn test_multiline_configure_file_warning_reports_start_line() {
2885 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2888
2889 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2890
2891 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2892 assert_eq!(warnings[0].rule_name, "nonexistent");
2893 assert_eq!(warnings[0].comment_type, "configure-file");
2894 assert_eq!(
2895 warnings[0].line_number, 3,
2896 "warning must point at the line the comment starts on"
2897 );
2898 }
2899
2900 #[test]
2901 fn test_configure_file_bool_false_disables_rule() {
2902 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2905
2906 let inline_config = InlineConfig::from_content(content);
2907
2908 assert!(inline_config.is_rule_disabled("MD012", 1));
2909 assert!(
2910 inline_config.get_rule_config("MD012").is_none(),
2911 "a boolean should not be stored as rule options"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_configure_file_bool_false_disables_rule_by_alias() {
2917 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2918
2919 let inline_config = InlineConfig::from_content(content);
2920
2921 assert!(inline_config.is_rule_disabled("MD012", 1));
2922 }
2923
2924 #[test]
2925 fn test_configure_file_bool_true_leaves_rule_enabled() {
2926 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2927
2928 let inline_config = InlineConfig::from_content(content);
2929
2930 assert!(!inline_config.is_rule_disabled("MD012", 1));
2931 }
2932
2933 #[test]
2934 fn test_get_rule_config_from_configure_file_alias_key() {
2935 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2937
2938 let inline_config = InlineConfig::from_content(content);
2939 let config_override = inline_config.get_rule_config("MD013");
2940
2941 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2942 let obj = config_override.unwrap().as_object().unwrap();
2943 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2944 }
2945
2946 #[test]
2949 fn test_parse_disable_does_not_match_disable_line() {
2950 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2952 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2953 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2954 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2955 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2956 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2957 }
2958
2959 #[test]
2960 fn test_parse_enable_does_not_match_enable_file() {
2961 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2962 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2963 }
2964
2965 #[test]
2966 fn test_parse_disable_comment_edge_cases() {
2967 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2969
2970 assert_eq!(
2972 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2973 Some(vec!["MD001", "MD002"])
2974 );
2975
2976 assert_eq!(
2978 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2979 Some(vec!["MD001"])
2980 );
2981
2982 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2984
2985 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2987
2988 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2990
2991 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2993
2994 assert_eq!(
2996 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2997 Some(vec!["MD001", "MD001", "MD002"])
2998 );
2999
3000 assert_eq!(
3002 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
3003 Some(vec!["MD001"])
3004 );
3005
3006 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
3008 let comment = format!("<!-- rumdl-disable {many_rules} -->");
3009 let parsed = parse_disable_comment(&comment);
3010 assert!(parsed.is_some());
3011 assert_eq!(parsed.unwrap().len(), 100);
3012
3013 assert_eq!(
3015 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
3016 Some(vec!["MD001-test"])
3017 );
3018 assert_eq!(
3019 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
3020 Some(vec!["custom_rule"])
3021 );
3022 }
3023
3024 #[test]
3025 fn test_parse_enable_comment_edge_cases() {
3026 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
3027 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
3028 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
3029 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
3030 }
3031
3032 #[test]
3035 fn test_disable_inside_fenced_code_block_ignored() {
3036 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
3037 let config = InlineConfig::from_content(content);
3038 assert!(!config.is_rule_disabled("MD001", 6));
3040 }
3041
3042 #[test]
3043 fn test_disable_inside_tilde_fence_ignored() {
3044 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
3045 let config = InlineConfig::from_content(content);
3046 assert!(!config.is_rule_disabled("MD001", 6));
3047 }
3048
3049 #[test]
3050 fn test_disable_before_code_block_persists_after() {
3051 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
3053 let config = InlineConfig::from_content(content);
3054 assert!(config.is_rule_disabled("MD001", 5));
3055 }
3056
3057 #[test]
3058 fn test_enable_inside_code_block_ignored() {
3059 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
3061 let config = InlineConfig::from_content(content);
3062 assert!(config.is_rule_disabled("MD001", 5));
3063 }
3064
3065 #[test]
3066 fn test_disable_inside_indented_code_block_ignored() {
3067 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
3071 let config = InlineConfig::from_content(content);
3072 assert!(!config.is_rule_disabled("MD001", 5));
3073
3074 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
3076 let config = InlineConfig::from_content(unindented);
3077 assert!(config.is_rule_disabled("MD001", 5));
3078 }
3079
3080 #[test]
3081 fn test_configure_file_inside_indented_code_block_ignored() {
3082 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3085 let config = InlineConfig::from_content(content);
3086 assert!(config.get_rule_config("MD013").is_none());
3087
3088 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
3089 let config = InlineConfig::from_content(unindented);
3090 assert!(config.get_rule_config("MD013").is_some());
3091 }
3092
3093 #[test]
3096 fn test_disable_inside_code_span_ignored() {
3097 let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
3100 let config = InlineConfig::from_content(content);
3101 assert!(!config.is_rule_disabled("MD001", 5));
3102
3103 let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3105 let config = InlineConfig::from_content(bare);
3106 assert!(config.is_rule_disabled("MD001", 5));
3107 }
3108
3109 #[test]
3110 fn test_directive_beside_one_in_a_code_span_still_applies() {
3111 let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
3114 let config = InlineConfig::from_content(content);
3115 assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
3116 assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
3117 }
3118
3119 #[test]
3120 fn test_disable_file_inside_code_span_ignored() {
3121 let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
3122 let config = InlineConfig::from_content(content);
3123 assert!(!config.is_rule_disabled("MD013", 3));
3124
3125 let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
3126 let config = InlineConfig::from_content(bare);
3127 assert!(config.is_rule_disabled("MD013", 3));
3128 }
3129
3130 #[test]
3131 fn test_configure_file_inside_code_span_ignored() {
3132 let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
3135 let config = InlineConfig::from_content(content);
3136 assert!(config.get_rule_config("MD013").is_none());
3137
3138 let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
3139 let config = InlineConfig::from_content(disabling);
3140 assert!(!config.is_rule_disabled("MD013", 3));
3141 }
3142
3143 #[test]
3144 fn test_disable_inside_multi_line_code_span_ignored() {
3145 let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
3147 let config = InlineConfig::from_content(content);
3148 assert!(!config.is_rule_disabled("MD001", 5));
3149 }
3150
3151 #[test]
3152 fn test_comment_opening_a_line_inside_backticks_still_applies() {
3153 let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
3157 let config = InlineConfig::from_content(content);
3158 assert!(config.is_rule_disabled("MD001", 5));
3159 }
3160
3161 #[test]
3162 fn test_disable_inside_double_backtick_code_span_ignored() {
3163 let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
3164 let config = InlineConfig::from_content(content);
3165 assert!(!config.is_rule_disabled("MD001", 3));
3166 }
3167
3168 #[test]
3169 fn test_code_ranges_line_up_on_a_crlf_document() {
3170 let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
3175 let config = InlineConfig::from_content(fenced);
3176 assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
3177
3178 let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
3179 let config = InlineConfig::from_content(spanned);
3180 assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
3181
3182 let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
3184 let config = InlineConfig::from_content(bare);
3185 assert!(config.is_rule_disabled("MD001", 5));
3186 }
3187
3188 #[test]
3189 fn test_unmatched_backtick_does_not_open_a_code_span() {
3190 let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
3192 let config = InlineConfig::from_content(content);
3193 assert!(config.is_rule_disabled("MD001", 3));
3194 }
3195
3196 #[test]
3197 fn test_validate_inline_config_rules_ignores_code_spans() {
3198 let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
3201 assert!(
3202 validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
3203 "a directive shown in a code span warned"
3204 );
3205
3206 let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
3207 assert_eq!(
3208 validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
3209 1,
3210 "the same name outside a span is still validated"
3211 );
3212 }
3213
3214 #[test]
3215 fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
3216 let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
3217 assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
3218 }
3219
3220 #[test]
3221 fn test_enable_inside_code_span_does_not_warn() {
3222 let active = active_set(&["MD012", "MD013"]);
3224 let ignored = active_set(&["MD012"]);
3225
3226 for content in [
3227 "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
3228 "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
3229 ] {
3230 let warnings =
3231 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
3232 assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
3233 }
3234 }
3235
3236 #[test]
3239 fn test_markdownlint_disable_rumdl_enable_interop() {
3240 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
3241 let config = InlineConfig::from_content(content);
3242 assert!(config.is_rule_disabled("MD001", 2));
3243 assert!(!config.is_rule_disabled("MD001", 4));
3244 }
3245
3246 #[test]
3247 fn test_rumdl_disable_markdownlint_enable_interop() {
3248 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
3249 let config = InlineConfig::from_content(content);
3250 assert!(config.is_rule_disabled("MD013", 2));
3251 assert!(!config.is_rule_disabled("MD013", 4));
3252 }
3253
3254 #[test]
3257 fn test_global_disable_then_specific_enable() {
3258 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
3259 let config = InlineConfig::from_content(content);
3260 assert!(!config.is_rule_disabled("MD001", 4));
3261 assert!(config.is_rule_disabled("MD002", 4));
3262 assert!(config.is_rule_disabled("MD013", 4));
3263 }
3264
3265 #[test]
3266 fn test_specific_disable_then_global_enable() {
3267 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
3268 let config = InlineConfig::from_content(content);
3269 assert!(config.is_rule_disabled("MD001", 2));
3270 assert!(config.is_rule_disabled("MD002", 2));
3271 assert!(!config.is_rule_disabled("MD001", 4));
3272 assert!(!config.is_rule_disabled("MD002", 4));
3273 }
3274
3275 #[test]
3276 fn test_multiple_rules_disable_enable_independently() {
3277 let content = "\
3278Line 1\n\
3279<!-- rumdl-disable MD001 MD002 -->\n\
3280Line 3\n\
3281<!-- rumdl-enable MD001 -->\n\
3282Line 5\n\
3283<!-- rumdl-disable -->\n\
3284Line 7\n\
3285<!-- rumdl-enable MD002 -->\n\
3286Line 9\n";
3287 let config = InlineConfig::from_content(content);
3288
3289 assert!(!config.is_rule_disabled("MD001", 1));
3291 assert!(!config.is_rule_disabled("MD002", 1));
3292
3293 assert!(config.is_rule_disabled("MD001", 3));
3295 assert!(config.is_rule_disabled("MD002", 3));
3296
3297 assert!(!config.is_rule_disabled("MD001", 5));
3299 assert!(config.is_rule_disabled("MD002", 5));
3300
3301 assert!(config.is_rule_disabled("MD001", 7));
3303 assert!(config.is_rule_disabled("MD002", 7));
3304
3305 assert!(config.is_rule_disabled("MD001", 9));
3307 assert!(!config.is_rule_disabled("MD002", 9));
3308 }
3309
3310 #[test]
3313 fn test_empty_content() {
3314 let config = InlineConfig::from_content("");
3315 assert!(!config.is_rule_disabled("MD001", 1));
3316 }
3317
3318 #[test]
3319 fn test_single_disable_comment_only() {
3320 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
3323 assert!(!config.is_rule_disabled("MD001", 1));
3324 assert!(config.is_rule_disabled("MD001", 2));
3325 assert!(config.is_rule_disabled("MD999", 2));
3326
3327 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
3329 assert!(!config.is_rule_disabled("MD001", 1));
3330 assert!(config.is_rule_disabled("MD001", 2));
3331 assert!(config.is_rule_disabled("MD001", 3));
3332 }
3333
3334 #[test]
3335 fn test_no_inline_markers() {
3336 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
3337 assert!(!config.is_rule_disabled("MD001", 1));
3338 assert!(!config.is_rule_disabled("MD001", 5));
3339 }
3340
3341 #[test]
3344 fn test_export_for_file_index_persistent_transitions() {
3345 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
3346 let config = InlineConfig::from_content(content);
3347 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
3348
3349 assert!(file_disabled.is_empty());
3350 assert!(
3352 persistent.len() >= 2,
3353 "Expected at least 2 transitions, got {}",
3354 persistent.len()
3355 );
3356 }
3357
3358 #[test]
3359 fn test_export_for_file_index_disable_file() {
3360 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
3361 let config = InlineConfig::from_content(content);
3362 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
3363
3364 assert!(file_disabled.contains("MD001"));
3365 }
3366
3367 #[test]
3368 fn test_export_for_file_index_disable_line() {
3369 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
3370 let config = InlineConfig::from_content(content);
3371 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
3372
3373 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
3374 assert!(line_disabled[&2].contains("MD001"));
3375 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
3376 }
3377
3378 fn disabled_lines(content: &str, rule: &str) -> Vec<usize> {
3381 let config = InlineConfig::from_content(content);
3382 let lines = content.split('\n').count();
3383 (1..=lines)
3384 .filter(|&line| config.is_rule_disabled(rule, line))
3385 .collect()
3386 }
3387
3388 #[test]
3389 fn multi_line_disable_takes_effect_after_the_closing_line() {
3390 let content = "<!--\nrumdl-disable MD013\n-->\nlong\nlong\n";
3392 assert_eq!(disabled_lines(content, "MD013"), [4, 5, 6]);
3393 }
3394
3395 #[test]
3396 fn multi_line_markdownlint_forms_and_rule_lists_split_over_lines() {
3397 let content = "<!--\nmarkdownlint-disable MD013\nMD001\n MD009\n-->\nx\n";
3398 for rule in ["MD013", "MD001", "MD009"] {
3399 assert_eq!(disabled_lines(content, rule), [6, 7], "{rule}");
3400 }
3401 let all = "<!-- rumdl-disable\n-->\nx\n";
3402 assert_eq!(disabled_lines(all, "MD013"), [3, 4]);
3403 }
3404
3405 #[test]
3406 fn multi_line_enable_restores_and_capture_restore_nest() {
3407 let content = "<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-enable MD013\n-->\nb\n";
3408 assert_eq!(disabled_lines(content, "MD013"), [2, 3, 4, 5]);
3409 let content = "<!--\nrumdl-capture\n-->\n<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-restore\n-->\nb\n";
3410 assert_eq!(disabled_lines(content, "MD013"), [5, 6, 7, 8]);
3411 }
3412
3413 #[test]
3414 fn multi_line_line_and_next_line_forms_use_the_closing_line() {
3415 let content = "<!--\nrumdl-disable-line MD013\n-->\nx\n";
3416 assert_eq!(disabled_lines(content, "MD013"), [3]);
3417 let content = "<!--\nrumdl-disable-next-line MD013\n-->\nx\ny\n";
3418 assert_eq!(disabled_lines(content, "MD013"), [4]);
3419 }
3420
3421 #[test]
3422 fn multi_line_file_forms_apply_to_the_whole_document() {
3423 let content = "x\n<!--\nrumdl-disable-file MD013\n-->\ny\n";
3424 assert_eq!(disabled_lines(content, "MD013"), [1, 2, 3, 4, 5, 6]);
3425 }
3426
3427 #[test]
3428 fn single_line_comment_after_a_multi_line_one_on_its_closing_line_applies_in_order() {
3429 let content = "<!--\nrumdl-disable MD013\n--> <!-- rumdl-enable MD013 -->\nx\n";
3431 assert!(disabled_lines(content, "MD013").is_empty());
3432 let content = "<!--\nrumdl-enable MD013\n--> <!-- rumdl-disable MD013 -->\nx\n";
3434 assert_eq!(disabled_lines(content, "MD013"), [4, 5]);
3435 }
3436
3437 #[test]
3438 fn multi_line_directive_inside_a_code_block_or_span_configures_nothing() {
3439 let fenced = "```\n<!--\nrumdl-disable MD013\n-->\n```\nx\n";
3440 assert!(disabled_lines(fenced, "MD013").is_empty());
3441 let span = "`<!--\nrumdl-disable MD013\n-->`\nx\n";
3442 assert!(disabled_lines(span, "MD013").is_empty());
3443 }
3444
3445 #[test]
3446 fn multi_line_comment_with_other_leading_text_is_not_a_directive() {
3447 let content = "<!--\nnote\nrumdl-disable MD013\n-->\nx\n";
3448 assert!(disabled_lines(content, "MD013").is_empty());
3449 }
3450
3451 #[test]
3452 fn single_line_directives_are_not_counted_twice() {
3453 let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-disable-next-line MD001 -->\nx\n";
3454 let config = InlineConfig::from_content(content);
3455 let (_, transitions, line_disabled) = config.export_for_file_index();
3456 assert_eq!(transitions.len(), 2, "{transitions:?}");
3458 assert_eq!(line_disabled.len(), 1, "{line_disabled:?}");
3459 }
3460
3461 #[test]
3462 fn multi_line_disable_site_reports_the_opening_line_and_the_closing_line_scope() {
3463 let content = "x\n<!--\nrumdl-disable-next-line MD013\n-->\ny\n";
3464 let sites = collect_disable_sites(content, &[], &[]);
3465 assert_eq!(sites.len(), 1, "{sites:?}");
3466 assert_eq!(sites[0].line, 2);
3467 assert_eq!(sites[0].kind, "disable-next-line");
3468 assert_eq!(sites[0].scope, DisableScope::Line(5));
3469 assert_eq!(sites[0].span.start, 0);
3470 assert_eq!(sites[0].span.end, 4, "clipped to the opening line");
3471 }
3472
3473 #[test]
3474 fn multi_line_directive_unknown_rule_is_reported_on_the_opening_line() {
3475 let content = "x\n<!--\nrumdl-disable MD9999\n-->\n";
3476 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
3477 assert_eq!(warnings.len(), 1, "{warnings:?}");
3478 assert_eq!(warnings[0].line_number, 2);
3479 assert_eq!(warnings[0].rule_name, "MD9999");
3480 }
3481
3482 #[test]
3485 fn comment_whitespace_is_unicode_whitespace_plus_bom() {
3486 for ch in [
3487 '\u{20}', '\t', '\n', '\u{a0}', '\u{3000}', '\u{85}', '\u{2028}', '\u{feff}',
3488 ] {
3489 assert!(is_comment_space(ch), "{ch:?}");
3490 }
3491 for ch in ['\u{200b}', 'a', '-'] {
3492 assert!(!is_comment_space(ch), "{ch:?}");
3493 }
3494 }
3495
3496 #[test]
3497 fn bom_counts_as_space_in_every_part_of_a_directive() {
3498 let content = "<!--\u{feff}rumdl-disable\u{feff}MD013\u{feff}MD001\u{feff}-->\nx\n";
3499 assert_eq!(disabled_lines(content, "MD013"), [2, 3]);
3500 assert_eq!(disabled_lines(content, "MD001"), [2, 3]);
3501 let zero_width = "<!--\u{200b}rumdl-disable MD013 -->\nx\n";
3502 assert!(disabled_lines(zero_width, "MD013").is_empty());
3503 }
3504
3505 #[test]
3506 fn prettier_ignore_accepts_the_same_whitespace() {
3507 assert!(prettier_ignore_len("prettier-ignore\u{feff}-->").is_some());
3508 assert!(prettier_ignore_len("prettier-ignore\u{200b}-->").is_none());
3509 }
3510
3511 fn is_tool_comment(text: &str) -> bool {
3514 !config_comment_ranges(text, |_| false).is_empty()
3515 }
3516
3517 #[test]
3518 fn tool_comment_recognises_every_tool_in_any_spelling() {
3519 for text in [
3520 "<!-- rumdl-disable MD013 -->",
3521 "<!--markdownlint-disable-->",
3522 "<!-- vale off -->",
3523 "<!--vale off-->",
3524 "<!-- vale on -->",
3525 "<!-- vale Style.Rule = NO -->",
3526 "<!-- vale style = X -->",
3527 "<!-- lint disable no-javascript -->",
3528 "<!--lint disable x-->",
3529 "<!-- lint enable x -->",
3530 "<!--\tlint ignore x\t-->",
3531 "<!--\u{feff}lint disable x-->",
3532 "<!--\nlint disable x\n-->",
3533 ] {
3534 assert!(is_tool_comment(text), "{text:?}");
3535 }
3536 for text in [
3537 "<!-- vale offset javascript -->",
3538 "<!-- vale onboarding javascript -->",
3539 "<!-- vale style guide javascript -->",
3540 "<!-- lint disabled x -->",
3541 "<!-- linting disable x -->",
3542 "<!-- lint -->",
3543 "<!-- valet off -->",
3544 "<!-- vale -->",
3545 "<!-- plain comment -->",
3546 ] {
3547 assert!(!is_tool_comment(text), "{text:?}");
3548 }
3549 }
3550
3551 #[test]
3552 fn config_comment_ranges_cover_tool_comments_only() {
3553 let content = "a\n<!--\nlint disable x\n-->\nb\n<!-- vale off -->\n<!--\nplain\n-->\n";
3554 let ranges = config_comment_ranges(content, |_| false);
3555 let texts: Vec<&str> = ranges.iter().map(|range| &content[range.clone()]).collect();
3556 assert_eq!(texts, ["<!--\nlint disable x\n-->", "<!-- vale off -->"]);
3557 }
3558
3559 #[test]
3560 fn config_comment_ranges_stop_at_the_comment_edges() {
3561 let content = "prose <!--vale off--> tail\n";
3562 let ranges = config_comment_ranges(content, |_| false);
3563 assert_eq!(&content[ranges[0].clone()], "<!--vale off-->");
3564 }
3565
3566 #[test]
3567 fn config_comment_ranges_skip_code_openers_and_end_an_unclosed_one_at_its_line() {
3568 let content = "<!-- vale off\nprose\n";
3569 assert_eq!(config_comment_ranges(content, |_| false), vec![0..13]);
3570 assert!(config_comment_ranges(content, |_| true).is_empty());
3571 }
3572
3573 #[test]
3574 fn blockquote_tracker_classifies_each_line_once_in_order() {
3575 let text = "<!-- a --> <!-- b -->\n> q <!-- c --> <!-- d -->\n- > l <!-- e -->\nx > y <!-- f -->\n1 > 0 <!-- g -->\n\n> <!-- h -->";
3576 let mut tracker = BlockquoteTracker::new();
3577 let got: Vec<usize> = text
3578 .match_indices(COMMENT_OPEN)
3579 .map(|(open, _)| tracker.quote_depth_at(text, open))
3580 .collect();
3581 assert_eq!(got, [0, 0, 1, 1, 1, 0, 0, 1]);
3582 }
3583
3584 #[test]
3585 fn quote_prefix_needs_real_container_syntax() {
3586 for (line, depth) in [
3587 ("> x", 1),
3588 (">> x", 2),
3589 (" > x", 1),
3590 ("- > x", 1),
3591 ("1. > x", 1),
3592 ("10) - > x", 1),
3593 ("> - > x", 2),
3594 (">x", 1),
3595 ("x", 0),
3596 ("1 > 0", 0),
3597 ("a > b", 0),
3598 ("-> x", 0),
3599 ("1.> x", 0),
3600 ("-x > y", 0),
3601 ("- x > y", 0),
3602 ("1234567890. > x", 0),
3603 ("", 0),
3604 ("- x", 0),
3605 ] {
3606 assert_eq!(line_quote_depth(line), depth, "{line:?}");
3607 }
3608 }
3609
3610 #[test]
3611 fn config_comment_ranges_unterminated_openers_linear_complexity() {
3612 let cost = |count: usize| {
3615 let content = "<!-- vale off\n".repeat(count);
3616 let mut best = std::time::Duration::MAX;
3617 for _ in 0..5 {
3618 let start = std::time::Instant::now();
3619 let ranges = config_comment_ranges(std::hint::black_box(&content), |_| false);
3620 best = best.min(start.elapsed());
3621 assert_eq!(ranges.len(), count);
3622 }
3623 best.as_secs_f64()
3624 };
3625 let (small, large) = (cost(20_000), cost(80_000));
3626 assert!(
3627 large / small < 8.0,
3628 "4x the openers took {:.1}x the time (linear is about 4x, quadratic about 16x)",
3629 large / small
3630 );
3631 }
3632
3633 #[test]
3634 fn offset_in_ranges_matches_a_linear_scan() {
3635 let ranges = vec![2..5, 5..6, 9..12, 20..21];
3636 for offset in 0..25 {
3637 assert_eq!(
3638 offset_in_ranges(&ranges, offset),
3639 ranges.iter().any(|range| range.contains(&offset)),
3640 "offset {offset}"
3641 );
3642 }
3643 assert!(!offset_in_ranges(&[], 0));
3644 }
3645
3646 #[test]
3647 fn a_code_opener_does_not_swallow_the_directive_after_it() {
3648 let content = "```\n<!-- rumdl-disable\n```\n\n<!--\nrumdl-disable MD013\n-->\n";
3649 let kept = scan_multiline_directives(content, |open| open < 20);
3650 assert_eq!(kept.len(), 1);
3651 assert_eq!(kept[0].start_line, 5);
3652 }
3653
3654 #[test]
3655 fn comment_body_skips_blockquote_markers_on_later_lines_only() {
3656 assert_eq!(comment_body("\n> rumdl-disable", 1), "rumdl-disable");
3657 assert_eq!(comment_body("\n>> \t> rumdl-disable", 3), "rumdl-disable");
3658 assert_eq!(
3659 comment_body("> rumdl-disable", 1),
3660 "> rumdl-disable",
3661 "same line: not a marker"
3662 );
3663 assert_eq!(
3664 comment_body("\n> rumdl-disable", 0),
3665 "> rumdl-disable",
3666 "outside a blockquote the `>` is comment text"
3667 );
3668 assert_eq!(
3669 comment_body("\n> > rumdl-disable", 1),
3670 "> rumdl-disable",
3671 "a marker beyond the enclosing depth is text"
3672 );
3673 assert_eq!(comment_body(" \n ", 1), "");
3674 }
3675
3676 #[test]
3677 fn multi_line_directive_in_a_blockquote_splits_rules_across_marked_lines() {
3678 let content = "> <!-- rumdl-disable MD013\n> MD009\n> -->\n> text\n";
3679 let found = scan_multiline_directives(content, |_| false);
3680 assert_eq!(found.len(), 1);
3681 assert_eq!(found[0].rules, ["MD013", "MD009"]);
3682 assert_eq!((found[0].start_line, found[0].end_line), (1, 3));
3683 }
3684
3685 #[test]
3686 fn an_unclosed_configure_file_example_in_code_does_not_swallow_the_directive_after_it() {
3687 let content = "```\n<!-- rumdl-configure-file\n```\n\n<!--\nrumdl-disable MD013\n-->\ntext\n";
3688 let code_end = content.find("\n\n<!--\n").unwrap();
3689 let found = scan_multiline_directives(content, |open| open < code_end);
3690 assert_eq!(found.len(), 1);
3691 assert_eq!(found[0].rules, ["MD013"]);
3692 }
3693}