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
38fn has_inline_config_markers(content: &str) -> bool {
39 if !content.contains("<!--") {
40 return false;
41 }
42 content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
43}
44
45pub type FileIndexExport = (
48 HashSet<String>,
49 Vec<(usize, HashSet<String>, HashSet<String>)>,
50 HashMap<usize, HashSet<String>>,
51);
52
53#[derive(Debug, Clone)]
57struct StateTransition {
58 line: usize,
60 disabled: HashSet<String>,
62 enabled: HashSet<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DisableLayer {
72 File,
74 Block,
76 Line,
78}
79
80#[derive(Debug, Clone)]
81pub struct InlineConfig {
82 transitions: Vec<StateTransition>,
85 line_disabled_rules: HashMap<usize, HashSet<String>>,
87 file_disabled_rules: HashSet<String>,
89 file_enabled_rules: HashSet<String>,
91 file_rule_config: HashMap<String, JsonValue>,
94}
95
96impl Default for InlineConfig {
97 fn default() -> Self {
98 Self::new()
99 }
100}
101
102impl InlineConfig {
103 pub fn new() -> Self {
104 Self {
105 transitions: Vec::new(),
106 line_disabled_rules: HashMap::new(),
107 file_disabled_rules: HashSet::new(),
108 file_enabled_rules: HashSet::new(),
109 file_rule_config: HashMap::new(),
110 }
111 }
112
113 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
116 if self.transitions.is_empty() {
117 return None;
118 }
119 match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
121 Ok(idx) => Some(&self.transitions[idx]),
122 Err(idx) => {
123 if idx > 0 {
124 Some(&self.transitions[idx - 1])
125 } else {
126 None
127 }
128 }
129 }
130 }
131
132 pub fn from_content(content: &str) -> Self {
134 if !has_inline_config_markers(content) {
135 return Self::new();
136 }
137
138 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
141 Self::from_content_with_code_blocks_internal(content, &parsed.code_blocks, &parsed.code_spans)
142 }
143
144 pub fn from_content_with_code_blocks(
146 content: &str,
147 code_blocks: &[(usize, usize)],
148 code_spans: &[(usize, usize)],
149 ) -> Self {
150 if !has_inline_config_markers(content) {
151 return Self::new();
152 }
153
154 Self::from_content_with_code_blocks_internal(content, code_blocks, code_spans)
155 }
156
157 fn from_content_with_code_blocks_internal(
158 content: &str,
159 code_blocks: &[(usize, usize)],
160 code_spans: &[(usize, usize)],
161 ) -> Self {
162 let mut config = Self::new();
163 let lines: Vec<&str> = content.split('\n').collect();
166
167 for (offset, json_config) in scan_configure_file_comments(content) {
172 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
173 continue;
174 }
175 let Some(obj) = json_config.as_object() else {
176 continue;
177 };
178 for (rule_name, rule_config) in obj {
179 let normalized = normalize_rule_name(rule_name);
183 if let Some(enabled) = rule_config.as_bool() {
184 if enabled {
185 config.file_disabled_rules.remove(&normalized);
186 } else {
187 config.file_disabled_rules.insert(normalized);
188 }
189 continue;
190 }
191 config.file_rule_config.insert(normalized, rule_config.clone());
195 }
196 }
197
198 let mut line_positions = Vec::with_capacity(lines.len());
200 let mut pos = 0;
201 for line in &lines {
202 line_positions.push(pos);
203 pos += line.len() + 1; }
205
206 let mut currently_disabled: HashSet<String> = HashSet::new();
208 let mut currently_enabled: HashSet<String> = HashSet::new();
209 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
210
211 let mut prev_disabled: HashSet<String> = HashSet::new();
213 let mut prev_enabled: HashSet<String> = HashSet::new();
214
215 config.transitions.push(StateTransition {
217 line: 1,
218 disabled: HashSet::new(),
219 enabled: HashSet::new(),
220 });
221
222 for (idx, line) in lines.iter().enumerate() {
223 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
228 config.transitions.push(StateTransition {
229 line: line_num,
230 disabled: currently_disabled.clone(),
231 enabled: currently_enabled.clone(),
232 });
233 prev_disabled.clone_from(¤tly_disabled);
234 prev_enabled.clone_from(¤tly_enabled);
235 }
236
237 if line_in_code_block(line_positions[idx], line, code_blocks) {
239 continue;
240 }
241
242 let directives = directives_outside_code_spans(line, line_positions[idx], code_spans);
245
246 let has_prettier_ignore = line
248 .match_indices("<!-- prettier-ignore -->")
249 .any(|(offset, _)| !offset_in_code_span(line_positions[idx] + offset, code_spans));
250
251 for directive in &directives {
253 match directive.kind {
254 DirectiveKind::DisableFile => {
255 if directive.rules.is_empty() {
256 config.file_disabled_rules.clear();
257 config.file_disabled_rules.insert("*".to_string());
258 } else if config.file_disabled_rules.contains("*") {
259 for rule in &directive.rules {
260 config.file_enabled_rules.remove(&normalize_rule_name(rule));
261 }
262 } else {
263 for rule in &directive.rules {
264 config.file_disabled_rules.insert(normalize_rule_name(rule));
265 }
266 }
267 }
268 DirectiveKind::EnableFile => {
269 if directive.rules.is_empty() {
270 config.file_disabled_rules.clear();
271 config.file_enabled_rules.clear();
272 } else if config.file_disabled_rules.contains("*") {
273 for rule in &directive.rules {
274 config.file_enabled_rules.insert(normalize_rule_name(rule));
275 }
276 } else {
277 for rule in &directive.rules {
278 config.file_disabled_rules.remove(&normalize_rule_name(rule));
279 }
280 }
281 }
282 _ => {}
284 }
285 }
286
287 for directive in &directives {
289 match directive.kind {
290 DirectiveKind::DisableNextLine => {
291 let next_line = line_num + 1;
292 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
293 if directive.rules.is_empty() {
294 line_rules.insert("*".to_string());
295 } else {
296 for rule in &directive.rules {
297 line_rules.insert(normalize_rule_name(rule));
298 }
299 }
300 }
301 DirectiveKind::DisableLine => {
302 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
303 if directive.rules.is_empty() {
304 line_rules.insert("*".to_string());
305 } else {
306 for rule in &directive.rules {
307 line_rules.insert(normalize_rule_name(rule));
308 }
309 }
310 }
311 DirectiveKind::Disable => {
312 if directive.rules.is_empty() {
313 currently_disabled.clear();
314 currently_disabled.insert("*".to_string());
315 currently_enabled.clear();
316 } else if currently_disabled.contains("*") {
317 for rule in &directive.rules {
318 currently_enabled.remove(&normalize_rule_name(rule));
319 }
320 } else {
321 for rule in &directive.rules {
322 currently_disabled.insert(normalize_rule_name(rule));
323 }
324 }
325 }
326 DirectiveKind::Enable => {
327 if directive.rules.is_empty() {
328 currently_disabled.clear();
329 currently_enabled.clear();
330 } else if currently_disabled.contains("*") {
331 for rule in &directive.rules {
332 currently_enabled.insert(normalize_rule_name(rule));
333 }
334 } else {
335 for rule in &directive.rules {
336 currently_disabled.remove(&normalize_rule_name(rule));
337 }
338 }
339 }
340 DirectiveKind::Capture => {
341 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
342 }
343 DirectiveKind::Restore => {
344 if let Some((disabled, enabled)) = capture_stack.pop() {
345 currently_disabled = disabled;
346 currently_enabled = enabled;
347 }
348 }
349 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
351 }
352 }
353
354 if has_prettier_ignore {
356 let next_line = line_num + 1;
357 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
358 line_rules.insert("*".to_string());
359 }
360 }
361
362 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
364 config.transitions.push(StateTransition {
365 line: lines.len() + 1,
366 disabled: currently_disabled,
367 enabled: currently_enabled,
368 });
369 }
370
371 config
372 }
373
374 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
376 self.disabling_layer(rule_name, line_number).is_some()
377 }
378
379 pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
386 if self.file_disabled_rules.contains("*") {
387 return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
389 } else if self.file_disabled_rules.contains(rule_name) {
390 return Some(DisableLayer::File);
391 }
392
393 if let Some(transition) = self.find_transition(line_number) {
395 let disabled = if transition.disabled.contains("*") {
396 !transition.enabled.contains(rule_name)
397 } else {
398 transition.disabled.contains(rule_name)
399 };
400 if disabled {
401 return Some(DisableLayer::Block);
402 }
403 }
404
405 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
407 && (line_rules.contains("*") || line_rules.contains(rule_name))
408 {
409 return Some(DisableLayer::Line);
410 }
411
412 None
413 }
414
415 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
417 let mut disabled = HashSet::new();
418
419 if let Some(transition) = self.find_transition(line_number) {
421 if transition.disabled.contains("*") {
422 disabled.insert("*".to_string());
423 } else {
424 for rule in &transition.disabled {
425 disabled.insert(rule.clone());
426 }
427 }
428 }
429
430 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
432 for rule in line_rules {
433 disabled.insert(rule.clone());
434 }
435 }
436
437 disabled
438 }
439
440 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
442 self.file_rule_config.get(rule_name)
443 }
444
445 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
447 &self.file_rule_config
448 }
449
450 pub fn export_for_file_index(&self) -> FileIndexExport {
454 let file_disabled = self.file_disabled_rules.clone();
455
456 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
457 .transitions
458 .iter()
459 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
460 .collect();
461
462 let line_disabled = self.line_disabled_rules.clone();
463
464 (file_disabled, persistent_transitions, line_disabled)
465 }
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum DirectiveKind {
479 Disable,
480 DisableLine,
481 DisableNextLine,
482 DisableFile,
483 Enable,
484 EnableFile,
485 Capture,
486 Restore,
487 ConfigureFile,
488}
489
490#[derive(Debug, Clone, PartialEq)]
492pub struct InlineDirective<'a> {
493 pub kind: DirectiveKind,
494 pub rules: Vec<&'a str>,
495 pub span: std::ops::Range<usize>,
497}
498
499const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
501
502const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
506 (DirectiveKind::DisableNextLine, "disable-next-line"),
507 (DirectiveKind::DisableLine, "disable-line"),
508 (DirectiveKind::DisableFile, "disable-file"),
509 (DirectiveKind::Disable, "disable"),
510 (DirectiveKind::EnableFile, "enable-file"),
511 (DirectiveKind::Enable, "enable"),
512 (DirectiveKind::ConfigureFile, "configure-file"),
513 (DirectiveKind::Capture, "capture"),
514 (DirectiveKind::Restore, "restore"),
515];
516
517fn try_parse_directive(s: &str) -> Option<(DirectiveKind, Vec<&str>, usize)> {
521 for tool in TOOL_PREFIXES {
522 if !s.starts_with(tool) {
523 continue;
524 }
525 let after_tool = &s[tool.len()..];
526
527 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
528 if !after_tool.starts_with(keyword) {
529 continue;
530 }
531 let after_kw = &after_tool[keyword.len()..];
532
533 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
536 continue;
537 }
538
539 let close_offset = after_kw.find("-->")?;
541
542 let rules_str = after_kw[..close_offset].trim();
543 let rules = if rules_str.is_empty() {
544 Vec::new()
545 } else {
546 rules_str.split_whitespace().collect()
547 };
548
549 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((kind, rules, consumed));
551 }
552
553 return None;
555 }
556 None
557}
558
559pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
565 let mut results = Vec::new();
566 let mut pos = 0;
567
568 while pos < line.len() {
569 let remaining = &line[pos..];
570 let Some(open_offset) = remaining.find("<!-- ") else {
571 break;
572 };
573 let comment_start = pos + open_offset;
574 let after_open = &line[comment_start + 5..]; if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
577 let comment_end = comment_start + 5 + consumed;
578 results.push(InlineDirective {
579 kind,
580 rules,
581 span: comment_start..comment_end,
582 });
583 pos = comment_end;
584 } else {
585 pos = comment_start + 5;
586 }
587 }
588
589 results
590}
591
592fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
598 parse_inline_directives(line)
599 .into_iter()
600 .find(|d| d.kind == kind)
601 .map(|d| d.rules)
602}
603
604pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
606 find_directive_rules(line, DirectiveKind::Disable)
607}
608
609pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
611 find_directive_rules(line, DirectiveKind::Enable)
612}
613
614pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
616 find_directive_rules(line, DirectiveKind::DisableLine)
617}
618
619pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
621 find_directive_rules(line, DirectiveKind::DisableNextLine)
622}
623
624pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
626 find_directive_rules(line, DirectiveKind::DisableFile)
627}
628
629pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
631 find_directive_rules(line, DirectiveKind::EnableFile)
632}
633
634pub fn is_capture_comment(line: &str) -> bool {
636 parse_inline_directives(line)
637 .iter()
638 .any(|d| d.kind == DirectiveKind::Capture)
639}
640
641pub fn is_restore_comment(line: &str) -> bool {
643 parse_inline_directives(line)
644 .iter()
645 .any(|d| d.kind == DirectiveKind::Restore)
646}
647
648const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
649
650fn offset_within(offset: usize, ranges: &[(usize, usize)]) -> bool {
652 ranges.iter().any(|&(start, end)| offset >= start && offset < end)
653}
654
655fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
657 offset_within(offset, code_blocks)
658}
659
660fn offset_in_code_span(offset: usize, code_spans: &[(usize, usize)]) -> bool {
667 offset_within(offset, code_spans)
668}
669
670fn directives_outside_code_spans<'a>(
675 line: &'a str,
676 line_start: usize,
677 code_spans: &[(usize, usize)],
678) -> Vec<InlineDirective<'a>> {
679 let mut directives = parse_inline_directives(line);
680 if !code_spans.is_empty() {
681 directives.retain(|directive| !offset_in_code_span(line_start + directive.span.start, code_spans));
682 }
683 directives
684}
685
686fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
694 let probe = line
695 .find(|c: char| !c.is_whitespace())
696 .map_or(line_start, |indent| line_start + indent);
697 offset_in_code_block(probe, code_blocks)
698}
699
700fn line_of_offset(text: &str, offset: usize) -> usize {
702 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
703}
704
705type LocatedWarning = (InlineConfigWarning, usize);
710
711fn drop_warnings_inside_code(
720 content: &str,
721 flavor: MarkdownFlavor,
722 mut warnings: Vec<LocatedWarning>,
723) -> Vec<InlineConfigWarning> {
724 if warnings.is_empty() {
725 return Vec::new();
726 }
727 let code = crate::lint_context::code_ranges(content, flavor);
728 if !code.blocks.is_empty() {
729 let line_spans: Vec<(usize, &str)> = {
734 let mut spans = Vec::new();
735 let mut start = 0;
736 for line in content.split('\n') {
737 spans.push((start, line));
738 start += line.len() + 1;
739 }
740 spans
741 };
742
743 warnings.retain(|(warning, _)| {
744 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
745 return true;
746 };
747 !line_in_code_block(line_start, line, &code.blocks)
748 });
749 }
750 if !code.spans.is_empty() {
751 warnings.retain(|&(_, offset)| !offset_in_code_span(offset, &code.spans));
752 }
753
754 warnings.into_iter().map(|(warning, _)| warning).collect()
755}
756
757fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
768 let mut found = Vec::new();
769 let mut pos = 0;
770
771 while let Some(open_offset) = text[pos..].find("<!-- ") {
772 let comment_start = pos + open_offset;
773 let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
777
778 for tool in TOOL_PREFIXES {
779 let Some(after_tool) = after_open.strip_prefix(tool) else {
780 continue;
781 };
782 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
783 break;
784 };
785 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
788 break;
789 }
790 let Some(close_offset) = after_kw.find("-->") else {
791 break;
792 };
793
794 let json_str = after_kw[..close_offset].trim();
795 if !json_str.is_empty()
796 && let Ok(value) = serde_json::from_str(json_str)
797 {
798 found.push((comment_start, value));
799 }
800 pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
801 break;
802 }
803 }
804
805 found
806}
807
808pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
812 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum DisableScope {
820 Line(usize),
822 Block(usize),
824 File,
826}
827
828impl DisableScope {
829 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
841 match (self, layer) {
842 (Self::Line(target), DisableLayer::Line) => target == line,
843 (Self::Block(start), DisableLayer::Block) => start < line,
845 (Self::File, DisableLayer::File) => true,
846 _ => false,
847 }
848 }
849}
850
851#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct DisableSite {
854 pub line: usize,
856 pub span: std::ops::Range<usize>,
858 pub kind: &'static str,
860 pub rules: Vec<String>,
862 pub scope: DisableScope,
864}
865
866pub fn collect_disable_sites(
878 content: &str,
879 code_blocks: &[(usize, usize)],
880 code_spans: &[(usize, usize)],
881) -> Vec<DisableSite> {
882 if !has_inline_config_markers(content) {
883 return Vec::new();
884 }
885
886 let line_spans: Vec<(usize, &str)> = {
889 let mut spans = Vec::new();
890 let mut start = 0;
891 for line in content.split('\n') {
892 spans.push((start, line));
893 start += line.len() + 1;
894 }
895 spans
896 };
897
898 let mut sites = Vec::new();
899
900 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
901 if line_in_code_block(line_start, line, code_blocks) {
902 continue;
903 }
904 let line_num = idx + 1;
905 for directive in directives_outside_code_spans(line, line_start, code_spans) {
906 let (kind, scope) = match directive.kind {
907 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
908 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
909 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
910 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
911 DirectiveKind::Enable
912 | DirectiveKind::EnableFile
913 | DirectiveKind::Capture
914 | DirectiveKind::Restore
915 | DirectiveKind::ConfigureFile => continue,
916 };
917 sites.push(DisableSite {
918 line: line_num,
919 span: directive.span,
920 kind,
921 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
922 scope,
923 });
924 }
925 }
926
927 for (offset, json_config) in scan_configure_file_comments(content) {
931 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
932 continue;
933 }
934 let Some(obj) = json_config.as_object() else {
935 continue;
936 };
937 let rules: Vec<String> = obj
938 .iter()
939 .filter(|(_, value)| value.as_bool() == Some(false))
940 .map(|(name, _)| name.clone())
941 .collect();
942 if rules.is_empty() {
943 continue;
944 }
945 let line_num = line_of_offset(content, offset);
946 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
947 continue;
948 };
949 let comment_end = content[offset..]
950 .find("-->")
951 .map_or(content.len(), |close| offset + close + 3);
952 sites.push(DisableSite {
953 line: line_num,
954 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
955 kind: "configure-file",
956 rules,
957 scope: DisableScope::File,
958 });
959 }
960
961 sites.sort_by_key(|site| (site.line, site.span.start));
962 sites
963}
964
965#[derive(Debug, Clone, PartialEq, Eq)]
967pub enum InlineConfigProblem {
968 UnknownRule,
970 UnknownOption { key: String },
973 EnableHasNoEffect { reason: EnableNoEffectReason },
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub enum EnableNoEffectReason {
985 NotEnabled,
987 IgnoredForFile,
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct InlineConfigWarning {
995 pub line_number: usize,
997 pub rule_name: String,
999 pub comment_type: String,
1001 pub suggestion: Option<String>,
1003 pub problem: InlineConfigProblem,
1005}
1006
1007impl InlineConfigWarning {
1008 pub fn format_message(&self) -> String {
1010 match &self.problem {
1013 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1014 Some(ref suggestion) => format!(
1015 "Unknown option for rule {}: {} (did you mean: {}?)",
1016 self.rule_name, key, suggestion
1017 ),
1018 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1019 },
1020 InlineConfigProblem::UnknownRule => match self.suggestion {
1021 Some(ref suggestion) => format!(
1022 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1023 self.comment_type, self.rule_name, suggestion
1024 ),
1025 None => format!(
1026 "Unknown rule in inline {} comment: {}",
1027 self.comment_type, self.rule_name
1028 ),
1029 },
1030 InlineConfigProblem::EnableHasNoEffect {
1031 reason: EnableNoEffectReason::NotEnabled,
1032 } => format!(
1033 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1034 self.rule_name, self.comment_type
1035 ),
1036 InlineConfigProblem::EnableHasNoEffect {
1037 reason: EnableNoEffectReason::IgnoredForFile,
1038 } => format!(
1039 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1040 self.rule_name, self.comment_type
1041 ),
1042 }
1043 }
1044
1045 pub fn print_warning(&self, file_path: &str) {
1047 eprintln!(
1048 "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
1049 file_path,
1050 self.line_number,
1051 self.format_message()
1052 );
1053 }
1054
1055 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1063 let line_width = content
1064 .lines()
1065 .nth(self.line_number.saturating_sub(1))
1066 .map_or(0, |line| line.chars().count());
1067 crate::rule::LintWarning {
1068 message: self.format_message(),
1069 line: self.line_number,
1070 column: 1,
1071 end_line: self.line_number,
1072 end_column: line_width + 1,
1073 severity: crate::rule::Severity::Warning,
1074 fix: None,
1075 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1076 }
1077 }
1078}
1079
1080pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1085
1086pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1095 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1096
1097 let mut warnings: Vec<LocatedWarning> = Vec::new();
1098 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
1099
1100 let suggest = |rule_name: &str| {
1101 suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1102 };
1103
1104 let registry = crate::config::default_registry();
1108 for (offset, json_config) in scan_configure_file_comments(content) {
1109 let Some(obj) = json_config.as_object() else {
1110 continue;
1111 };
1112 let line_number = line_of_offset(content, offset);
1113 for (rule_name, rule_config) in obj {
1114 if !is_valid_rule_name(rule_name) {
1115 warnings.push((
1116 InlineConfigWarning {
1117 line_number,
1118 rule_name: rule_name.clone(),
1119 comment_type: "configure-file".to_string(),
1120 suggestion: suggest(rule_name),
1121 problem: InlineConfigProblem::UnknownRule,
1122 },
1123 offset,
1124 ));
1125 continue;
1128 }
1129 let Some(options) = rule_config.as_object() else {
1131 continue;
1132 };
1133 let canonical = normalize_rule_name(rule_name);
1134 let Some(valid_keys) = registry.config_keys_for(&canonical) else {
1135 continue;
1136 };
1137 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1138 for key in options.keys() {
1139 if !valid_keys.contains(key) {
1140 warnings.push((
1141 InlineConfigWarning {
1142 line_number,
1143 rule_name: canonical.clone(),
1144 comment_type: "configure-file".to_string(),
1145 suggestion: suggest_similar_key(key, &valid_keys_vec),
1146 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1147 },
1148 offset,
1149 ));
1150 }
1151 }
1152 }
1153 }
1154
1155 let mut line_start = 0;
1158 for (idx, line) in content.split('\n').enumerate() {
1159 let line_num = idx + 1;
1160
1161 for directive in parse_inline_directives(line) {
1163 let comment_type = match directive.kind {
1164 DirectiveKind::Disable => "disable",
1165 DirectiveKind::Enable => "enable",
1166 DirectiveKind::DisableLine => "disable-line",
1167 DirectiveKind::DisableNextLine => "disable-next-line",
1168 DirectiveKind::DisableFile => "disable-file",
1169 DirectiveKind::EnableFile => "enable-file",
1170 DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
1172 };
1173 let offset = line_start + directive.span.start;
1174 for rule_name in &directive.rules {
1175 if !is_valid_rule_name(rule_name) {
1176 warnings.push((
1177 InlineConfigWarning {
1178 line_number: line_num,
1179 rule_name: (*rule_name).to_string(),
1180 comment_type: comment_type.to_string(),
1181 suggestion: suggest(rule_name),
1182 problem: InlineConfigProblem::UnknownRule,
1183 },
1184 offset,
1185 ));
1186 }
1187 }
1188 }
1189 line_start += line.len() + 1;
1190 }
1191
1192 warnings.sort_by_key(|(warning, _)| warning.line_number);
1195 drop_warnings_inside_code(content, flavor, warnings)
1196}
1197
1198pub fn validate_inline_enables_against_active_rules(
1215 content: &str,
1216 flavor: MarkdownFlavor,
1217 active_rules: &HashSet<String>,
1218 ignored_for_file: &HashSet<String>,
1219) -> Vec<InlineConfigWarning> {
1220 use crate::config::is_valid_rule_name;
1221
1222 let mut warnings: Vec<LocatedWarning> = Vec::new();
1223
1224 let flag = |warnings: &mut Vec<LocatedWarning>, name: &str, comment_type: &str, line: usize, offset: usize| {
1225 if !is_valid_rule_name(name) {
1227 return;
1228 }
1229 let canonical = normalize_rule_name(name);
1230 let reason = if !active_rules.contains(&canonical) {
1234 EnableNoEffectReason::NotEnabled
1235 } else if ignored_for_file.contains(&canonical) {
1236 EnableNoEffectReason::IgnoredForFile
1237 } else {
1238 return;
1239 };
1240 warnings.push((
1241 InlineConfigWarning {
1242 line_number: line,
1243 rule_name: canonical,
1244 comment_type: comment_type.to_string(),
1245 suggestion: None,
1246 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1247 },
1248 offset,
1249 ));
1250 };
1251
1252 for (offset, json_config) in scan_configure_file_comments(content) {
1254 let Some(obj) = json_config.as_object() else {
1255 continue;
1256 };
1257 let line = line_of_offset(content, offset);
1258 for (rule_name, rule_config) in obj {
1259 if rule_config.as_bool() == Some(true) {
1262 flag(&mut warnings, rule_name, "configure-file", line, offset);
1263 }
1264 }
1265 }
1266
1267 let mut line_start = 0;
1271 for (idx, line) in content.split('\n').enumerate() {
1272 for directive in parse_inline_directives(line) {
1273 let comment_type = match directive.kind {
1274 DirectiveKind::Enable => "enable",
1275 DirectiveKind::EnableFile => "enable-file",
1276 _ => continue,
1277 };
1278 let offset = line_start + directive.span.start;
1279 for rule in &directive.rules {
1280 flag(&mut warnings, rule, comment_type, idx + 1, offset);
1281 }
1282 }
1283 line_start += line.len() + 1;
1284 }
1285
1286 warnings.sort_by_key(|(warning, _)| warning.line_number);
1287 drop_warnings_inside_code(content, flavor, warnings)
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293
1294 #[test]
1297 fn test_parse_inline_directives_all_kinds() {
1298 let cases: &[(&str, DirectiveKind)] = &[
1300 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
1301 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
1302 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
1303 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
1304 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
1305 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
1306 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
1307 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
1308 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
1309 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
1311 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
1312 (
1313 "<!-- markdownlint-disable-next-line -->",
1314 DirectiveKind::DisableNextLine,
1315 ),
1316 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
1317 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
1318 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
1319 ];
1320 for (input, expected_kind) in cases {
1321 let directives = parse_inline_directives(input);
1322 assert_eq!(
1323 directives.len(),
1324 1,
1325 "Expected 1 directive for {input:?}, got {directives:?}"
1326 );
1327 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
1328 }
1329 }
1330
1331 #[test]
1332 fn test_parse_inline_directives_disambiguation() {
1333 let line = "<!-- rumdl-disable-line MD001 -->";
1335 let directives = parse_inline_directives(line);
1336 assert_eq!(directives.len(), 1);
1337 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
1338
1339 let line = "<!-- rumdl-disable-next-line -->";
1340 let directives = parse_inline_directives(line);
1341 assert_eq!(directives.len(), 1);
1342 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
1343
1344 let line = "<!-- rumdl-disable-file MD001 -->";
1345 let directives = parse_inline_directives(line);
1346 assert_eq!(directives.len(), 1);
1347 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
1348
1349 let line = "<!-- rumdl-enable-file -->";
1350 let directives = parse_inline_directives(line);
1351 assert_eq!(directives.len(), 1);
1352 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
1353 }
1354
1355 #[test]
1356 fn test_parse_inline_directives_no_space_before_close() {
1357 let directives = parse_inline_directives("<!-- rumdl-disable-->");
1359 assert_eq!(directives.len(), 1);
1360 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1361 assert!(directives[0].rules.is_empty());
1362
1363 let directives = parse_inline_directives("<!-- rumdl-enable-->");
1364 assert_eq!(directives.len(), 1);
1365 assert_eq!(directives[0].kind, DirectiveKind::Enable);
1366 }
1367
1368 #[test]
1369 fn test_parse_inline_directives_multiple_on_one_line() {
1370 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1371 let directives = parse_inline_directives(line);
1372 assert_eq!(directives.len(), 2);
1373 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1374 assert_eq!(directives[0].rules, vec!["MD001"]);
1375 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1376 assert_eq!(directives[1].rules, vec!["MD001"]);
1377 }
1378
1379 #[test]
1380 fn test_parse_inline_directives_global_disable_then_specific_enable() {
1381 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1382 let directives = parse_inline_directives(line);
1383 assert_eq!(directives.len(), 2);
1384 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1385 assert!(directives[0].rules.is_empty());
1386 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1387 assert_eq!(directives[1].rules, vec!["MD001"]);
1388 }
1389
1390 #[test]
1391 fn test_parse_inline_directives_word_boundary() {
1392 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1394 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1396 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1398 }
1399
1400 #[test]
1401 fn test_parse_inline_directives_no_closing_tag() {
1402 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1404 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1405 }
1406
1407 #[test]
1408 fn test_parse_inline_directives_not_a_comment() {
1409 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1410 assert!(parse_inline_directives("Some regular text").is_empty());
1411 assert!(parse_inline_directives("").is_empty());
1412 }
1413
1414 #[test]
1415 fn test_parse_inline_directives_case_sensitive() {
1416 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1417 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1418 }
1419
1420 #[test]
1421 fn test_parse_inline_directives_rules_extraction() {
1422 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1423 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1424
1425 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1427 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1428
1429 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
1431 assert_eq!(directives[0].rules, vec!["MD001"]);
1432 }
1433
1434 #[test]
1435 fn test_parse_inline_directives_embedded_in_text() {
1436 let line = "Some text <!-- rumdl-disable MD001 --> more text";
1437 let directives = parse_inline_directives(line);
1438 assert_eq!(directives.len(), 1);
1439 assert_eq!(directives[0].rules, vec!["MD001"]);
1440
1441 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1442 let directives = parse_inline_directives(line);
1443 assert_eq!(directives.len(), 1);
1444 assert_eq!(directives[0].rules, vec!["MD001"]);
1445 }
1446
1447 #[test]
1448 fn test_parse_inline_directives_mixed_tools_same_line() {
1449 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1450 let directives = parse_inline_directives(line);
1451 assert_eq!(directives.len(), 2);
1452 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1453 assert_eq!(directives[0].rules, vec!["MD001"]);
1454 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1455 assert_eq!(directives[1].rules, vec!["MD002"]);
1456 }
1457
1458 #[test]
1461 fn test_parse_disable_comment() {
1462 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1464 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1465
1466 assert_eq!(
1468 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1469 Some(vec!["MD001", "MD002"])
1470 );
1471
1472 assert_eq!(parse_disable_comment("Some regular text"), None);
1474 }
1475
1476 #[test]
1477 fn test_parse_disable_line_comment() {
1478 assert_eq!(
1480 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1481 Some(vec![])
1482 );
1483
1484 assert_eq!(
1486 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1487 Some(vec!["MD013"])
1488 );
1489
1490 assert_eq!(parse_disable_line_comment("Some regular text"), None);
1492 }
1493
1494 #[test]
1495 fn test_inline_config_from_content() {
1496 let content = r#"# Test Document
1497
1498<!-- markdownlint-disable MD013 -->
1499This is a very long line that would normally trigger MD013 but it's disabled
1500
1501<!-- markdownlint-enable MD013 -->
1502This line will be checked again
1503
1504<!-- markdownlint-disable-next-line MD001 -->
1505# This heading will not be checked for MD001
1506## But this one will
1507
1508Some text <!-- markdownlint-disable-line MD013 -->
1509
1510<!-- markdownlint-capture -->
1511<!-- markdownlint-disable MD001 MD002 -->
1512# Heading with MD001 disabled
1513<!-- markdownlint-restore -->
1514# Heading with MD001 enabled again
1515"#;
1516
1517 let config = InlineConfig::from_content(content);
1518
1519 assert!(config.is_rule_disabled("MD013", 4));
1521
1522 assert!(!config.is_rule_disabled("MD013", 7));
1524
1525 assert!(config.is_rule_disabled("MD001", 10));
1527
1528 assert!(!config.is_rule_disabled("MD001", 11));
1530
1531 assert!(config.is_rule_disabled("MD013", 13));
1533
1534 assert!(!config.is_rule_disabled("MD001", 19));
1536 }
1537
1538 #[test]
1539 fn test_capture_restore() {
1540 let content = r#"<!-- markdownlint-disable MD001 -->
1541<!-- markdownlint-capture -->
1542<!-- markdownlint-disable MD002 MD003 -->
1543<!-- markdownlint-restore -->
1544Some content after restore
1545"#;
1546
1547 let config = InlineConfig::from_content(content);
1548
1549 assert!(config.is_rule_disabled("MD001", 5));
1551 assert!(!config.is_rule_disabled("MD002", 5));
1552 assert!(!config.is_rule_disabled("MD003", 5));
1553 }
1554
1555 #[test]
1556 fn test_validate_inline_config_rules_unknown_rule() {
1557 let content = "<!-- rumdl-disable abc -->\nSome content";
1558 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1559 assert_eq!(warnings.len(), 1);
1560 assert_eq!(warnings[0].line_number, 1);
1561 assert_eq!(warnings[0].rule_name, "abc");
1562 assert_eq!(warnings[0].comment_type, "disable");
1563 }
1564
1565 #[test]
1566 fn test_validate_inline_config_rules_valid_rule() {
1567 let content = "<!-- rumdl-disable MD001 -->\nSome content";
1568 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1569 assert!(
1570 warnings.is_empty(),
1571 "MD001 is a valid rule, should not produce warnings"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_validate_inline_config_rules_alias() {
1577 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1578 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1579 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1580 }
1581
1582 #[test]
1583 fn test_validate_inline_config_rules_multiple_unknown() {
1584 let content = r#"<!-- rumdl-disable abc xyz -->
1585<!-- rumdl-disable-line foo -->
1586<!-- markdownlint-disable-next-line bar -->
1587"#;
1588 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1589 assert_eq!(warnings.len(), 4);
1590 assert_eq!(warnings[0].rule_name, "abc");
1591 assert_eq!(warnings[1].rule_name, "xyz");
1592 assert_eq!(warnings[2].rule_name, "foo");
1593 assert_eq!(warnings[3].rule_name, "bar");
1594 }
1595
1596 #[test]
1597 fn test_validate_inline_config_rules_suggestion() {
1598 let content = "<!-- rumdl-disable MD00 -->\n";
1600 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1601 assert_eq!(warnings.len(), 1);
1602 assert!(warnings[0].suggestion.is_some());
1604 }
1605
1606 #[test]
1607 fn test_validate_inline_config_rules_file_comments() {
1608 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1609 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1610 assert_eq!(warnings.len(), 2);
1611 assert_eq!(warnings[0].comment_type, "disable-file");
1612 assert_eq!(warnings[1].comment_type, "enable-file");
1613 }
1614
1615 #[test]
1616 fn test_validate_inline_config_rules_global_disable() {
1617 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1619 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1620 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1621 }
1622
1623 #[test]
1624 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1625 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1627 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1628 assert_eq!(warnings.len(), 2);
1629 assert_eq!(warnings[0].rule_name, "abc");
1630 assert_eq!(warnings[1].rule_name, "xyz");
1631 }
1632
1633 #[test]
1634 fn test_validate_inline_config_rules_configure_file() {
1635 let content =
1637 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1638 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1639 assert_eq!(warnings.len(), 1);
1640 assert_eq!(warnings[0].rule_name, "nonexistent");
1641 assert_eq!(warnings[0].comment_type, "configure-file");
1642 }
1643
1644 #[test]
1645 fn test_validate_inline_config_rules_markdownlint_variants() {
1646 let content = r#"<!-- markdownlint-disable unknown_rule -->
1648<!-- markdownlint-enable another_fake -->
1649<!-- markdownlint-disable-line bad_rule -->
1650<!-- markdownlint-disable-next-line fake_rule -->
1651<!-- markdownlint-disable-file missing_rule -->
1652<!-- markdownlint-enable-file nonexistent -->
1653"#;
1654 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1655 assert_eq!(warnings.len(), 6);
1656 assert_eq!(warnings[0].rule_name, "unknown_rule");
1657 assert_eq!(warnings[1].rule_name, "another_fake");
1658 assert_eq!(warnings[2].rule_name, "bad_rule");
1659 assert_eq!(warnings[3].rule_name, "fake_rule");
1660 assert_eq!(warnings[4].rule_name, "missing_rule");
1661 assert_eq!(warnings[5].rule_name, "nonexistent");
1662 }
1663
1664 #[test]
1668 fn test_validate_inline_config_rules_ignores_code_blocks() {
1669 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
1670 assert!(
1671 validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
1672 "a directive inside a fence is documentation, not configuration"
1673 );
1674
1675 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
1676 assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
1677
1678 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
1679 assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
1680
1681 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
1682 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1683 }
1684
1685 #[test]
1690 fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
1691 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
1692 assert_eq!(
1693 validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
1694 1,
1695 "an admonition holds its content at an indent that is not code"
1696 );
1697 assert!(
1698 validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
1699 "without admonitions the same lines are an indented code block"
1700 );
1701
1702 let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
1703 assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
1704 assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
1705 }
1706
1707 #[test]
1710 fn test_validate_inline_enables_reports_inside_an_indented_container() {
1711 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
1712 let active = active_set(&["MD009"]);
1713 assert_eq!(
1714 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
1715 .len(),
1716 1,
1717 "the enable is live in an admonition body, and configuration never enabled MD013"
1718 );
1719 assert!(
1720 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
1721 .is_empty(),
1722 "without admonitions the same lines are an indented code block"
1723 );
1724 }
1725
1726 #[test]
1729 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
1730 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
1731 assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
1732
1733 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
1734 assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
1735
1736 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
1737 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1738 }
1739
1740 #[test]
1741 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1742 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1743 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1744 assert_eq!(warnings.len(), 1);
1745 assert_eq!(warnings[0].rule_name, "fake_rule");
1746 assert_eq!(warnings[0].comment_type, "configure-file");
1747 }
1748
1749 #[test]
1750 fn test_get_rule_config_from_configure_file() {
1751 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1752
1753This is a test line."#;
1754
1755 let inline_config = InlineConfig::from_content(content);
1756 let config_override = inline_config.get_rule_config("MD013");
1757
1758 assert!(config_override.is_some(), "MD013 config should be found");
1759 let json = config_override.unwrap();
1760 assert!(json.is_object(), "Config should be an object");
1761 let obj = json.as_object().unwrap();
1762 assert!(obj.contains_key("line_length"), "Should have line_length key");
1763 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1764 }
1765
1766 #[test]
1767 fn test_get_rule_config_tables_false() {
1768 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1770
1771 let inline_config = InlineConfig::from_content(content);
1772 let config_override = inline_config.get_rule_config("MD013");
1773
1774 assert!(config_override.is_some(), "MD013 config should be found");
1775 let json = config_override.unwrap();
1776 let obj = json.as_object().unwrap();
1777 assert!(obj.contains_key("tables"), "Should have tables key");
1778 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1779 }
1780
1781 fn active_set(names: &[&str]) -> HashSet<String> {
1795 names.iter().map(|s| (*s).to_string()).collect()
1796 }
1797
1798 fn no_ignores() -> HashSet<String> {
1799 HashSet::new()
1800 }
1801
1802 #[test]
1803 fn test_inline_enable_of_inactive_rule_warns() {
1804 let active = active_set(&["MD013", "MD022"]);
1805 let content = "<!-- rumdl-enable MD012 -->\n";
1806
1807 let warnings =
1808 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1809
1810 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1811 assert_eq!(warnings[0].rule_name, "MD012");
1812 assert_eq!(warnings[0].comment_type, "enable");
1813 assert_eq!(
1814 warnings[0].problem,
1815 InlineConfigProblem::EnableHasNoEffect {
1816 reason: EnableNoEffectReason::NotEnabled
1817 }
1818 );
1819 assert_eq!(warnings[0].line_number, 1);
1820 }
1821
1822 #[test]
1823 fn test_inline_enable_of_active_rule_does_not_warn() {
1824 let active = active_set(&["MD012", "MD013"]);
1827 let content = "<!-- rumdl-enable MD012 -->\n";
1828
1829 let warnings =
1830 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1831
1832 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1833 }
1834
1835 #[test]
1836 fn test_inline_enable_of_per_file_ignored_rule_warns() {
1837 let active = active_set(&["MD012", "MD013"]);
1840 let ignored = active_set(&["MD012"]);
1841 let content = "<!-- rumdl-enable MD012 -->\n";
1842
1843 let warnings =
1844 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1845
1846 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1847 assert_eq!(warnings[0].rule_name, "MD012");
1848 assert_eq!(
1849 warnings[0].problem,
1850 InlineConfigProblem::EnableHasNoEffect {
1851 reason: EnableNoEffectReason::IgnoredForFile
1852 }
1853 );
1854 assert!(
1855 warnings[0].format_message().contains("per-file-ignores"),
1856 "the message must name the setting in play: {}",
1857 warnings[0].format_message()
1858 );
1859 }
1860
1861 #[test]
1862 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
1863 let active = active_set(&["MD013"]);
1866 let ignored = active_set(&["MD012"]);
1867 let content = "<!-- rumdl-enable MD012 -->\n";
1868
1869 let warnings =
1870 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1871
1872 assert_eq!(warnings.len(), 1, "{warnings:?}");
1873 assert_eq!(
1874 warnings[0].problem,
1875 InlineConfigProblem::EnableHasNoEffect {
1876 reason: EnableNoEffectReason::NotEnabled
1877 }
1878 );
1879 }
1880
1881 #[test]
1882 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
1883 let active = active_set(&["MD012", "MD013"]);
1886 let ignored = active_set(&["MD013"]);
1887 let content = "<!-- rumdl-enable MD012 -->\n";
1888
1889 let warnings =
1890 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1891
1892 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
1893 }
1894
1895 #[test]
1896 fn test_enable_inside_code_block_does_not_warn() {
1897 let active = active_set(&["MD012", "MD013"]);
1900 let ignored = active_set(&["MD012"]);
1901
1902 for content in [
1903 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
1904 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
1905 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
1906 ] {
1907 let warnings =
1908 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1909 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
1910 }
1911
1912 for content in [
1913 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
1914 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
1915 ] {
1916 let warnings =
1917 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1918 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
1919 }
1920 }
1921
1922 #[test]
1923 fn test_bare_enable_all_does_not_warn() {
1924 let active = active_set(&["MD013"]);
1926 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1927 let warnings = validate_inline_enables_against_active_rules(
1928 content,
1929 MarkdownFlavor::Standard,
1930 &active,
1931 &active_set(&["MD013"]),
1932 );
1933 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1934 }
1935 }
1936
1937 #[test]
1938 fn test_enable_file_and_alias_of_inactive_rule_warn() {
1939 let active = active_set(&["MD013"]);
1940 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1942
1943 let warnings =
1944 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1945
1946 assert_eq!(warnings.len(), 1, "{warnings:?}");
1947 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1948 assert_eq!(warnings[0].comment_type, "enable-file");
1949 }
1950
1951 #[test]
1952 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1953 let active = active_set(&["MD013"]);
1954 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1955 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1956
1957 let warn_true =
1958 validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
1959 let warn_false =
1960 validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
1961
1962 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1963 assert_eq!(warn_true[0].rule_name, "MD012");
1964 assert!(
1965 warn_false.is_empty(),
1966 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1967 );
1968 }
1969
1970 #[test]
1971 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1972 let active = active_set(&["MD013"]);
1975 let content = "<!-- rumdl-enable NotARule -->\n";
1976
1977 let warnings =
1978 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1979
1980 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1981 }
1982
1983 #[test]
1984 fn test_disable_directive_never_warns_as_no_effect() {
1985 let active = active_set(&["MD012", "MD013"]);
1988 let ignored = active_set(&["MD012"]);
1989 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1990
1991 let warnings =
1992 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1993
1994 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1995 }
1996
1997 #[test]
2003 fn test_unknown_option_key_in_configure_file_warns() {
2004 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2005
2006 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2007
2008 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2009 assert_eq!(warnings[0].rule_name, "MD013");
2010 assert_eq!(
2011 warnings[0].problem,
2012 InlineConfigProblem::UnknownOption {
2013 key: "line_lenght".to_string()
2014 }
2015 );
2016 assert!(
2017 warnings[0].suggestion.is_some(),
2018 "a near-miss key should suggest the real one"
2019 );
2020 assert!(
2021 warnings[0].format_message().contains("Unknown option for rule MD013"),
2022 "message was: {}",
2023 warnings[0].format_message()
2024 );
2025 }
2026
2027 #[test]
2028 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2029 for content in [
2032 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2033 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2034 ] {
2035 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2036 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2037 }
2038 }
2039
2040 #[test]
2041 fn test_unknown_rule_does_not_also_warn_about_its_options() {
2042 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2045
2046 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2047
2048 assert_eq!(
2049 warnings.len(),
2050 1,
2051 "expected only the unknown-rule warning: {warnings:?}"
2052 );
2053 assert_eq!(warnings[0].rule_name, "nonexistent");
2054 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2055 }
2056
2057 #[test]
2058 fn test_boolean_rule_value_has_no_option_warnings() {
2059 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2060
2061 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2062
2063 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2064 }
2065
2066 #[test]
2067 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2068 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2069
2070 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2071
2072 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2073 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2074 }
2075
2076 #[test]
2077 fn test_configure_file_spanning_multiple_lines_applies() {
2078 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2079
2080 let inline_config = InlineConfig::from_content(content);
2081 let config_override = inline_config.get_rule_config("MD013");
2082
2083 assert!(config_override.is_some(), "a multi-line configure-file must apply");
2084 let obj = config_override.unwrap().as_object().unwrap();
2085 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2086 }
2087
2088 #[test]
2089 fn test_every_configure_file_comment_applies_not_just_the_first() {
2090 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";
2093
2094 let inline_config = InlineConfig::from_content(content);
2095
2096 assert!(
2097 inline_config.get_rule_config("MD013").is_some(),
2098 "first (single-line) configure-file dropped"
2099 );
2100 assert!(
2101 inline_config.get_rule_config("MD007").is_some(),
2102 "second (multi-line) configure-file dropped"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_multiline_configure_file_in_code_block_is_ignored() {
2108 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2111
2112 let inline_config = InlineConfig::from_content(content);
2113
2114 assert!(
2115 inline_config.get_rule_config("MD013").is_none(),
2116 "configure-file inside a fenced code block must not apply"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_multiline_configure_file_bool_and_alias_still_honored() {
2122 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2125
2126 let inline_config = InlineConfig::from_content(content);
2127
2128 assert!(
2129 inline_config.is_rule_disabled("MD012", 1),
2130 "boolean alias key must disable the rule"
2131 );
2132 let obj = inline_config
2133 .get_rule_config("MD013")
2134 .expect("alias-keyed config should resolve to MD013")
2135 .as_object()
2136 .unwrap();
2137 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2138 }
2139
2140 #[test]
2141 fn test_multiline_configure_file_warning_reports_start_line() {
2142 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2145
2146 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2147
2148 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2149 assert_eq!(warnings[0].rule_name, "nonexistent");
2150 assert_eq!(warnings[0].comment_type, "configure-file");
2151 assert_eq!(
2152 warnings[0].line_number, 3,
2153 "warning must point at the line the comment starts on"
2154 );
2155 }
2156
2157 #[test]
2158 fn test_configure_file_bool_false_disables_rule() {
2159 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2162
2163 let inline_config = InlineConfig::from_content(content);
2164
2165 assert!(inline_config.is_rule_disabled("MD012", 1));
2166 assert!(
2167 inline_config.get_rule_config("MD012").is_none(),
2168 "a boolean should not be stored as rule options"
2169 );
2170 }
2171
2172 #[test]
2173 fn test_configure_file_bool_false_disables_rule_by_alias() {
2174 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2175
2176 let inline_config = InlineConfig::from_content(content);
2177
2178 assert!(inline_config.is_rule_disabled("MD012", 1));
2179 }
2180
2181 #[test]
2182 fn test_configure_file_bool_true_leaves_rule_enabled() {
2183 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2184
2185 let inline_config = InlineConfig::from_content(content);
2186
2187 assert!(!inline_config.is_rule_disabled("MD012", 1));
2188 }
2189
2190 #[test]
2191 fn test_get_rule_config_from_configure_file_alias_key() {
2192 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2194
2195 let inline_config = InlineConfig::from_content(content);
2196 let config_override = inline_config.get_rule_config("MD013");
2197
2198 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2199 let obj = config_override.unwrap().as_object().unwrap();
2200 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2201 }
2202
2203 #[test]
2206 fn test_parse_disable_does_not_match_disable_line() {
2207 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2209 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2210 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2211 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2212 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2213 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2214 }
2215
2216 #[test]
2217 fn test_parse_enable_does_not_match_enable_file() {
2218 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2219 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2220 }
2221
2222 #[test]
2223 fn test_parse_disable_comment_edge_cases() {
2224 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2226
2227 assert_eq!(
2229 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2230 Some(vec!["MD001", "MD002"])
2231 );
2232
2233 assert_eq!(
2235 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2236 Some(vec!["MD001"])
2237 );
2238
2239 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2241
2242 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2244
2245 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2247
2248 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2250
2251 assert_eq!(
2253 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2254 Some(vec!["MD001", "MD001", "MD002"])
2255 );
2256
2257 assert_eq!(
2259 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
2260 Some(vec!["MD001"])
2261 );
2262
2263 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
2265 let comment = format!("<!-- rumdl-disable {many_rules} -->");
2266 let parsed = parse_disable_comment(&comment);
2267 assert!(parsed.is_some());
2268 assert_eq!(parsed.unwrap().len(), 100);
2269
2270 assert_eq!(
2272 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
2273 Some(vec!["MD001-test"])
2274 );
2275 assert_eq!(
2276 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
2277 Some(vec!["custom_rule"])
2278 );
2279 }
2280
2281 #[test]
2282 fn test_parse_enable_comment_edge_cases() {
2283 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
2284 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
2285 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
2286 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
2287 }
2288
2289 #[test]
2292 fn test_disable_inside_fenced_code_block_ignored() {
2293 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
2294 let config = InlineConfig::from_content(content);
2295 assert!(!config.is_rule_disabled("MD001", 6));
2297 }
2298
2299 #[test]
2300 fn test_disable_inside_tilde_fence_ignored() {
2301 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
2302 let config = InlineConfig::from_content(content);
2303 assert!(!config.is_rule_disabled("MD001", 6));
2304 }
2305
2306 #[test]
2307 fn test_disable_before_code_block_persists_after() {
2308 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
2310 let config = InlineConfig::from_content(content);
2311 assert!(config.is_rule_disabled("MD001", 5));
2312 }
2313
2314 #[test]
2315 fn test_enable_inside_code_block_ignored() {
2316 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
2318 let config = InlineConfig::from_content(content);
2319 assert!(config.is_rule_disabled("MD001", 5));
2320 }
2321
2322 #[test]
2323 fn test_disable_inside_indented_code_block_ignored() {
2324 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
2328 let config = InlineConfig::from_content(content);
2329 assert!(!config.is_rule_disabled("MD001", 5));
2330
2331 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
2333 let config = InlineConfig::from_content(unindented);
2334 assert!(config.is_rule_disabled("MD001", 5));
2335 }
2336
2337 #[test]
2338 fn test_configure_file_inside_indented_code_block_ignored() {
2339 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2342 let config = InlineConfig::from_content(content);
2343 assert!(config.get_rule_config("MD013").is_none());
2344
2345 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2346 let config = InlineConfig::from_content(unindented);
2347 assert!(config.get_rule_config("MD013").is_some());
2348 }
2349
2350 #[test]
2353 fn test_disable_inside_code_span_ignored() {
2354 let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
2357 let config = InlineConfig::from_content(content);
2358 assert!(!config.is_rule_disabled("MD001", 5));
2359
2360 let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
2362 let config = InlineConfig::from_content(bare);
2363 assert!(config.is_rule_disabled("MD001", 5));
2364 }
2365
2366 #[test]
2367 fn test_directive_beside_one_in_a_code_span_still_applies() {
2368 let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
2371 let config = InlineConfig::from_content(content);
2372 assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
2373 assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
2374 }
2375
2376 #[test]
2377 fn test_disable_file_inside_code_span_ignored() {
2378 let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
2379 let config = InlineConfig::from_content(content);
2380 assert!(!config.is_rule_disabled("MD013", 3));
2381
2382 let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
2383 let config = InlineConfig::from_content(bare);
2384 assert!(config.is_rule_disabled("MD013", 3));
2385 }
2386
2387 #[test]
2388 fn test_configure_file_inside_code_span_ignored() {
2389 let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
2392 let config = InlineConfig::from_content(content);
2393 assert!(config.get_rule_config("MD013").is_none());
2394
2395 let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
2396 let config = InlineConfig::from_content(disabling);
2397 assert!(!config.is_rule_disabled("MD013", 3));
2398 }
2399
2400 #[test]
2401 fn test_disable_inside_multi_line_code_span_ignored() {
2402 let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
2404 let config = InlineConfig::from_content(content);
2405 assert!(!config.is_rule_disabled("MD001", 5));
2406 }
2407
2408 #[test]
2409 fn test_comment_opening_a_line_inside_backticks_still_applies() {
2410 let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
2414 let config = InlineConfig::from_content(content);
2415 assert!(config.is_rule_disabled("MD001", 5));
2416 }
2417
2418 #[test]
2419 fn test_disable_inside_double_backtick_code_span_ignored() {
2420 let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
2421 let config = InlineConfig::from_content(content);
2422 assert!(!config.is_rule_disabled("MD001", 3));
2423 }
2424
2425 #[test]
2426 fn test_code_ranges_line_up_on_a_crlf_document() {
2427 let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
2432 let config = InlineConfig::from_content(fenced);
2433 assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
2434
2435 let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
2436 let config = InlineConfig::from_content(spanned);
2437 assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
2438
2439 let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
2441 let config = InlineConfig::from_content(bare);
2442 assert!(config.is_rule_disabled("MD001", 5));
2443 }
2444
2445 #[test]
2446 fn test_unmatched_backtick_does_not_open_a_code_span() {
2447 let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
2449 let config = InlineConfig::from_content(content);
2450 assert!(config.is_rule_disabled("MD001", 3));
2451 }
2452
2453 #[test]
2454 fn test_validate_inline_config_rules_ignores_code_spans() {
2455 let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
2458 assert!(
2459 validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
2460 "a directive shown in a code span warned"
2461 );
2462
2463 let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2464 assert_eq!(
2465 validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
2466 1,
2467 "the same name outside a span is still validated"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
2473 let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
2474 assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
2475 }
2476
2477 #[test]
2478 fn test_enable_inside_code_span_does_not_warn() {
2479 let active = active_set(&["MD012", "MD013"]);
2481 let ignored = active_set(&["MD012"]);
2482
2483 for content in [
2484 "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
2485 "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
2486 ] {
2487 let warnings =
2488 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2489 assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
2490 }
2491 }
2492
2493 #[test]
2496 fn test_markdownlint_disable_rumdl_enable_interop() {
2497 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
2498 let config = InlineConfig::from_content(content);
2499 assert!(config.is_rule_disabled("MD001", 2));
2500 assert!(!config.is_rule_disabled("MD001", 4));
2501 }
2502
2503 #[test]
2504 fn test_rumdl_disable_markdownlint_enable_interop() {
2505 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
2506 let config = InlineConfig::from_content(content);
2507 assert!(config.is_rule_disabled("MD013", 2));
2508 assert!(!config.is_rule_disabled("MD013", 4));
2509 }
2510
2511 #[test]
2514 fn test_global_disable_then_specific_enable() {
2515 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
2516 let config = InlineConfig::from_content(content);
2517 assert!(!config.is_rule_disabled("MD001", 4));
2518 assert!(config.is_rule_disabled("MD002", 4));
2519 assert!(config.is_rule_disabled("MD013", 4));
2520 }
2521
2522 #[test]
2523 fn test_specific_disable_then_global_enable() {
2524 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
2525 let config = InlineConfig::from_content(content);
2526 assert!(config.is_rule_disabled("MD001", 2));
2527 assert!(config.is_rule_disabled("MD002", 2));
2528 assert!(!config.is_rule_disabled("MD001", 4));
2529 assert!(!config.is_rule_disabled("MD002", 4));
2530 }
2531
2532 #[test]
2533 fn test_multiple_rules_disable_enable_independently() {
2534 let content = "\
2535Line 1\n\
2536<!-- rumdl-disable MD001 MD002 -->\n\
2537Line 3\n\
2538<!-- rumdl-enable MD001 -->\n\
2539Line 5\n\
2540<!-- rumdl-disable -->\n\
2541Line 7\n\
2542<!-- rumdl-enable MD002 -->\n\
2543Line 9\n";
2544 let config = InlineConfig::from_content(content);
2545
2546 assert!(!config.is_rule_disabled("MD001", 1));
2548 assert!(!config.is_rule_disabled("MD002", 1));
2549
2550 assert!(config.is_rule_disabled("MD001", 3));
2552 assert!(config.is_rule_disabled("MD002", 3));
2553
2554 assert!(!config.is_rule_disabled("MD001", 5));
2556 assert!(config.is_rule_disabled("MD002", 5));
2557
2558 assert!(config.is_rule_disabled("MD001", 7));
2560 assert!(config.is_rule_disabled("MD002", 7));
2561
2562 assert!(config.is_rule_disabled("MD001", 9));
2564 assert!(!config.is_rule_disabled("MD002", 9));
2565 }
2566
2567 #[test]
2570 fn test_empty_content() {
2571 let config = InlineConfig::from_content("");
2572 assert!(!config.is_rule_disabled("MD001", 1));
2573 }
2574
2575 #[test]
2576 fn test_single_disable_comment_only() {
2577 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
2580 assert!(!config.is_rule_disabled("MD001", 1));
2581 assert!(config.is_rule_disabled("MD001", 2));
2582 assert!(config.is_rule_disabled("MD999", 2));
2583
2584 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
2586 assert!(!config.is_rule_disabled("MD001", 1));
2587 assert!(config.is_rule_disabled("MD001", 2));
2588 assert!(config.is_rule_disabled("MD001", 3));
2589 }
2590
2591 #[test]
2592 fn test_no_inline_markers() {
2593 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
2594 assert!(!config.is_rule_disabled("MD001", 1));
2595 assert!(!config.is_rule_disabled("MD001", 5));
2596 }
2597
2598 #[test]
2601 fn test_export_for_file_index_persistent_transitions() {
2602 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
2603 let config = InlineConfig::from_content(content);
2604 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
2605
2606 assert!(file_disabled.is_empty());
2607 assert!(
2609 persistent.len() >= 2,
2610 "Expected at least 2 transitions, got {}",
2611 persistent.len()
2612 );
2613 }
2614
2615 #[test]
2616 fn test_export_for_file_index_disable_file() {
2617 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
2618 let config = InlineConfig::from_content(content);
2619 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
2620
2621 assert!(file_disabled.contains("MD001"));
2622 }
2623
2624 #[test]
2625 fn test_export_for_file_index_disable_line() {
2626 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
2627 let config = InlineConfig::from_content(content);
2628 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
2629
2630 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
2631 assert!(line_disabled[&2].contains("MD001"));
2632 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
2633 }
2634}