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 code_blocks = CodeBlockUtils::detect_code_blocks(content);
139 Self::from_content_with_code_blocks_internal(content, &code_blocks)
140 }
141
142 pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
144 if !has_inline_config_markers(content) {
145 return Self::new();
146 }
147
148 Self::from_content_with_code_blocks_internal(content, code_blocks)
149 }
150
151 fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
152 let mut config = Self::new();
153 let lines: Vec<&str> = content.lines().collect();
154
155 for (offset, json_config) in scan_configure_file_comments(content) {
160 if offset_in_code_block(offset, code_blocks) {
161 continue;
162 }
163 let Some(obj) = json_config.as_object() else {
164 continue;
165 };
166 for (rule_name, rule_config) in obj {
167 let normalized = normalize_rule_name(rule_name);
171 if let Some(enabled) = rule_config.as_bool() {
172 if enabled {
173 config.file_disabled_rules.remove(&normalized);
174 } else {
175 config.file_disabled_rules.insert(normalized);
176 }
177 continue;
178 }
179 config.file_rule_config.insert(normalized, rule_config.clone());
183 }
184 }
185
186 let mut line_positions = Vec::with_capacity(lines.len());
188 let mut pos = 0;
189 for line in &lines {
190 line_positions.push(pos);
191 pos += line.len() + 1; }
193
194 let mut currently_disabled: HashSet<String> = HashSet::new();
196 let mut currently_enabled: HashSet<String> = HashSet::new();
197 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
198
199 let mut prev_disabled: HashSet<String> = HashSet::new();
201 let mut prev_enabled: HashSet<String> = HashSet::new();
202
203 config.transitions.push(StateTransition {
205 line: 1,
206 disabled: HashSet::new(),
207 enabled: HashSet::new(),
208 });
209
210 for (idx, line) in lines.iter().enumerate() {
211 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
216 config.transitions.push(StateTransition {
217 line: line_num,
218 disabled: currently_disabled.clone(),
219 enabled: currently_enabled.clone(),
220 });
221 prev_disabled.clone_from(¤tly_disabled);
222 prev_enabled.clone_from(¤tly_enabled);
223 }
224
225 if line_in_code_block(line_positions[idx], line, code_blocks) {
227 continue;
228 }
229
230 let directives = parse_inline_directives(line);
233
234 let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
236
237 for directive in &directives {
239 match directive.kind {
240 DirectiveKind::DisableFile => {
241 if directive.rules.is_empty() {
242 config.file_disabled_rules.clear();
243 config.file_disabled_rules.insert("*".to_string());
244 } else if config.file_disabled_rules.contains("*") {
245 for rule in &directive.rules {
246 config.file_enabled_rules.remove(&normalize_rule_name(rule));
247 }
248 } else {
249 for rule in &directive.rules {
250 config.file_disabled_rules.insert(normalize_rule_name(rule));
251 }
252 }
253 }
254 DirectiveKind::EnableFile => {
255 if directive.rules.is_empty() {
256 config.file_disabled_rules.clear();
257 config.file_enabled_rules.clear();
258 } else if config.file_disabled_rules.contains("*") {
259 for rule in &directive.rules {
260 config.file_enabled_rules.insert(normalize_rule_name(rule));
261 }
262 } else {
263 for rule in &directive.rules {
264 config.file_disabled_rules.remove(&normalize_rule_name(rule));
265 }
266 }
267 }
268 _ => {}
270 }
271 }
272
273 for directive in &directives {
275 match directive.kind {
276 DirectiveKind::DisableNextLine => {
277 let next_line = line_num + 1;
278 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
279 if directive.rules.is_empty() {
280 line_rules.insert("*".to_string());
281 } else {
282 for rule in &directive.rules {
283 line_rules.insert(normalize_rule_name(rule));
284 }
285 }
286 }
287 DirectiveKind::DisableLine => {
288 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
289 if directive.rules.is_empty() {
290 line_rules.insert("*".to_string());
291 } else {
292 for rule in &directive.rules {
293 line_rules.insert(normalize_rule_name(rule));
294 }
295 }
296 }
297 DirectiveKind::Disable => {
298 if directive.rules.is_empty() {
299 currently_disabled.clear();
300 currently_disabled.insert("*".to_string());
301 currently_enabled.clear();
302 } else if currently_disabled.contains("*") {
303 for rule in &directive.rules {
304 currently_enabled.remove(&normalize_rule_name(rule));
305 }
306 } else {
307 for rule in &directive.rules {
308 currently_disabled.insert(normalize_rule_name(rule));
309 }
310 }
311 }
312 DirectiveKind::Enable => {
313 if directive.rules.is_empty() {
314 currently_disabled.clear();
315 currently_enabled.clear();
316 } else if currently_disabled.contains("*") {
317 for rule in &directive.rules {
318 currently_enabled.insert(normalize_rule_name(rule));
319 }
320 } else {
321 for rule in &directive.rules {
322 currently_disabled.remove(&normalize_rule_name(rule));
323 }
324 }
325 }
326 DirectiveKind::Capture => {
327 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
328 }
329 DirectiveKind::Restore => {
330 if let Some((disabled, enabled)) = capture_stack.pop() {
331 currently_disabled = disabled;
332 currently_enabled = enabled;
333 }
334 }
335 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
337 }
338 }
339
340 if has_prettier_ignore {
342 let next_line = line_num + 1;
343 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
344 line_rules.insert("*".to_string());
345 }
346 }
347
348 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
350 config.transitions.push(StateTransition {
351 line: lines.len() + 1,
352 disabled: currently_disabled,
353 enabled: currently_enabled,
354 });
355 }
356
357 config
358 }
359
360 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
362 self.disabling_layer(rule_name, line_number).is_some()
363 }
364
365 pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
372 if self.file_disabled_rules.contains("*") {
373 return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
375 } else if self.file_disabled_rules.contains(rule_name) {
376 return Some(DisableLayer::File);
377 }
378
379 if let Some(transition) = self.find_transition(line_number) {
381 let disabled = if transition.disabled.contains("*") {
382 !transition.enabled.contains(rule_name)
383 } else {
384 transition.disabled.contains(rule_name)
385 };
386 if disabled {
387 return Some(DisableLayer::Block);
388 }
389 }
390
391 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
393 && (line_rules.contains("*") || line_rules.contains(rule_name))
394 {
395 return Some(DisableLayer::Line);
396 }
397
398 None
399 }
400
401 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
403 let mut disabled = HashSet::new();
404
405 if let Some(transition) = self.find_transition(line_number) {
407 if transition.disabled.contains("*") {
408 disabled.insert("*".to_string());
409 } else {
410 for rule in &transition.disabled {
411 disabled.insert(rule.clone());
412 }
413 }
414 }
415
416 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
418 for rule in line_rules {
419 disabled.insert(rule.clone());
420 }
421 }
422
423 disabled
424 }
425
426 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
428 self.file_rule_config.get(rule_name)
429 }
430
431 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
433 &self.file_rule_config
434 }
435
436 pub fn export_for_file_index(&self) -> FileIndexExport {
440 let file_disabled = self.file_disabled_rules.clone();
441
442 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
443 .transitions
444 .iter()
445 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
446 .collect();
447
448 let line_disabled = self.line_disabled_rules.clone();
449
450 (file_disabled, persistent_transitions, line_disabled)
451 }
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum DirectiveKind {
465 Disable,
466 DisableLine,
467 DisableNextLine,
468 DisableFile,
469 Enable,
470 EnableFile,
471 Capture,
472 Restore,
473 ConfigureFile,
474}
475
476#[derive(Debug, Clone, PartialEq)]
478pub struct InlineDirective<'a> {
479 pub kind: DirectiveKind,
480 pub rules: Vec<&'a str>,
481 pub span: std::ops::Range<usize>,
483}
484
485const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
487
488const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
492 (DirectiveKind::DisableNextLine, "disable-next-line"),
493 (DirectiveKind::DisableLine, "disable-line"),
494 (DirectiveKind::DisableFile, "disable-file"),
495 (DirectiveKind::Disable, "disable"),
496 (DirectiveKind::EnableFile, "enable-file"),
497 (DirectiveKind::Enable, "enable"),
498 (DirectiveKind::ConfigureFile, "configure-file"),
499 (DirectiveKind::Capture, "capture"),
500 (DirectiveKind::Restore, "restore"),
501];
502
503fn try_parse_directive(s: &str) -> Option<(DirectiveKind, Vec<&str>, usize)> {
507 for tool in TOOL_PREFIXES {
508 if !s.starts_with(tool) {
509 continue;
510 }
511 let after_tool = &s[tool.len()..];
512
513 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
514 if !after_tool.starts_with(keyword) {
515 continue;
516 }
517 let after_kw = &after_tool[keyword.len()..];
518
519 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
522 continue;
523 }
524
525 let close_offset = after_kw.find("-->")?;
527
528 let rules_str = after_kw[..close_offset].trim();
529 let rules = if rules_str.is_empty() {
530 Vec::new()
531 } else {
532 rules_str.split_whitespace().collect()
533 };
534
535 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((kind, rules, consumed));
537 }
538
539 return None;
541 }
542 None
543}
544
545pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
551 let mut results = Vec::new();
552 let mut pos = 0;
553
554 while pos < line.len() {
555 let remaining = &line[pos..];
556 let Some(open_offset) = remaining.find("<!-- ") else {
557 break;
558 };
559 let comment_start = pos + open_offset;
560 let after_open = &line[comment_start + 5..]; if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
563 let comment_end = comment_start + 5 + consumed;
564 results.push(InlineDirective {
565 kind,
566 rules,
567 span: comment_start..comment_end,
568 });
569 pos = comment_end;
570 } else {
571 pos = comment_start + 5;
572 }
573 }
574
575 results
576}
577
578fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
584 parse_inline_directives(line)
585 .into_iter()
586 .find(|d| d.kind == kind)
587 .map(|d| d.rules)
588}
589
590pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
592 find_directive_rules(line, DirectiveKind::Disable)
593}
594
595pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
597 find_directive_rules(line, DirectiveKind::Enable)
598}
599
600pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
602 find_directive_rules(line, DirectiveKind::DisableLine)
603}
604
605pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
607 find_directive_rules(line, DirectiveKind::DisableNextLine)
608}
609
610pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
612 find_directive_rules(line, DirectiveKind::DisableFile)
613}
614
615pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
617 find_directive_rules(line, DirectiveKind::EnableFile)
618}
619
620pub fn is_capture_comment(line: &str) -> bool {
622 parse_inline_directives(line)
623 .iter()
624 .any(|d| d.kind == DirectiveKind::Capture)
625}
626
627pub fn is_restore_comment(line: &str) -> bool {
629 parse_inline_directives(line)
630 .iter()
631 .any(|d| d.kind == DirectiveKind::Restore)
632}
633
634const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
635
636fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
638 code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
639}
640
641fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
649 let probe = line
650 .find(|c: char| !c.is_whitespace())
651 .map_or(line_start, |indent| line_start + indent);
652 offset_in_code_block(probe, code_blocks)
653}
654
655fn line_of_offset(text: &str, offset: usize) -> usize {
657 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
658}
659
660fn drop_warnings_inside_code_blocks(content: &str, flavor: MarkdownFlavor, warnings: &mut Vec<InlineConfigWarning>) {
668 if warnings.is_empty() {
669 return;
670 }
671 let code_blocks = crate::lint_context::code_block_ranges(content, flavor);
672 if code_blocks.is_empty() {
673 return;
674 }
675
676 let line_spans: Vec<(usize, &str)> = {
679 let mut spans = Vec::new();
680 let mut start = 0;
681 for line in content.split('\n') {
682 spans.push((start, line));
683 start += line.len() + 1;
684 }
685 spans
686 };
687
688 warnings.retain(|warning| {
689 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
690 return true;
691 };
692 !line_in_code_block(line_start, line, &code_blocks)
693 });
694}
695
696fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
707 let mut found = Vec::new();
708 let mut pos = 0;
709
710 while let Some(open_offset) = text[pos..].find("<!-- ") {
711 let comment_start = pos + open_offset;
712 let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
716
717 for tool in TOOL_PREFIXES {
718 let Some(after_tool) = after_open.strip_prefix(tool) else {
719 continue;
720 };
721 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
722 break;
723 };
724 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
727 break;
728 }
729 let Some(close_offset) = after_kw.find("-->") else {
730 break;
731 };
732
733 let json_str = after_kw[..close_offset].trim();
734 if !json_str.is_empty()
735 && let Ok(value) = serde_json::from_str(json_str)
736 {
737 found.push((comment_start, value));
738 }
739 pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
740 break;
741 }
742 }
743
744 found
745}
746
747pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
751 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758pub enum DisableScope {
759 Line(usize),
761 Block(usize),
763 File,
765}
766
767impl DisableScope {
768 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
780 match (self, layer) {
781 (Self::Line(target), DisableLayer::Line) => target == line,
782 (Self::Block(start), DisableLayer::Block) => start < line,
784 (Self::File, DisableLayer::File) => true,
785 _ => false,
786 }
787 }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct DisableSite {
793 pub line: usize,
795 pub span: std::ops::Range<usize>,
797 pub kind: &'static str,
799 pub rules: Vec<String>,
801 pub scope: DisableScope,
803}
804
805pub fn collect_disable_sites(content: &str, code_blocks: &[(usize, usize)]) -> Vec<DisableSite> {
817 if !has_inline_config_markers(content) {
818 return Vec::new();
819 }
820
821 let line_spans: Vec<(usize, &str)> = {
824 let mut spans = Vec::new();
825 let mut start = 0;
826 for line in content.split('\n') {
827 spans.push((start, line));
828 start += line.len() + 1;
829 }
830 spans
831 };
832
833 let mut sites = Vec::new();
834
835 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
836 if line_in_code_block(line_start, line, code_blocks) {
837 continue;
838 }
839 let line_num = idx + 1;
840 for directive in parse_inline_directives(line) {
841 let (kind, scope) = match directive.kind {
842 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
843 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
844 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
845 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
846 DirectiveKind::Enable
847 | DirectiveKind::EnableFile
848 | DirectiveKind::Capture
849 | DirectiveKind::Restore
850 | DirectiveKind::ConfigureFile => continue,
851 };
852 sites.push(DisableSite {
853 line: line_num,
854 span: directive.span,
855 kind,
856 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
857 scope,
858 });
859 }
860 }
861
862 for (offset, json_config) in scan_configure_file_comments(content) {
866 if offset_in_code_block(offset, code_blocks) {
867 continue;
868 }
869 let Some(obj) = json_config.as_object() else {
870 continue;
871 };
872 let rules: Vec<String> = obj
873 .iter()
874 .filter(|(_, value)| value.as_bool() == Some(false))
875 .map(|(name, _)| name.clone())
876 .collect();
877 if rules.is_empty() {
878 continue;
879 }
880 let line_num = line_of_offset(content, offset);
881 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
882 continue;
883 };
884 let comment_end = content[offset..]
885 .find("-->")
886 .map_or(content.len(), |close| offset + close + 3);
887 sites.push(DisableSite {
888 line: line_num,
889 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
890 kind: "configure-file",
891 rules,
892 scope: DisableScope::File,
893 });
894 }
895
896 sites.sort_by_key(|site| (site.line, site.span.start));
897 sites
898}
899
900#[derive(Debug, Clone, PartialEq, Eq)]
902pub enum InlineConfigProblem {
903 UnknownRule,
905 UnknownOption { key: String },
908 EnableHasNoEffect { reason: EnableNoEffectReason },
912}
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
919pub enum EnableNoEffectReason {
920 NotEnabled,
922 IgnoredForFile,
925}
926
927#[derive(Debug, Clone, PartialEq, Eq)]
929pub struct InlineConfigWarning {
930 pub line_number: usize,
932 pub rule_name: String,
934 pub comment_type: String,
936 pub suggestion: Option<String>,
938 pub problem: InlineConfigProblem,
940}
941
942impl InlineConfigWarning {
943 pub fn format_message(&self) -> String {
945 match &self.problem {
948 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
949 Some(ref suggestion) => format!(
950 "Unknown option for rule {}: {} (did you mean: {}?)",
951 self.rule_name, key, suggestion
952 ),
953 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
954 },
955 InlineConfigProblem::UnknownRule => match self.suggestion {
956 Some(ref suggestion) => format!(
957 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
958 self.comment_type, self.rule_name, suggestion
959 ),
960 None => format!(
961 "Unknown rule in inline {} comment: {}",
962 self.comment_type, self.rule_name
963 ),
964 },
965 InlineConfigProblem::EnableHasNoEffect {
966 reason: EnableNoEffectReason::NotEnabled,
967 } => format!(
968 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
969 self.rule_name, self.comment_type
970 ),
971 InlineConfigProblem::EnableHasNoEffect {
972 reason: EnableNoEffectReason::IgnoredForFile,
973 } => format!(
974 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
975 self.rule_name, self.comment_type
976 ),
977 }
978 }
979
980 pub fn print_warning(&self, file_path: &str) {
982 eprintln!(
983 "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
984 file_path,
985 self.line_number,
986 self.format_message()
987 );
988 }
989
990 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
998 let line_width = content
999 .lines()
1000 .nth(self.line_number.saturating_sub(1))
1001 .map_or(0, |line| line.chars().count());
1002 crate::rule::LintWarning {
1003 message: self.format_message(),
1004 line: self.line_number,
1005 column: 1,
1006 end_line: self.line_number,
1007 end_column: line_width + 1,
1008 severity: crate::rule::Severity::Warning,
1009 fix: None,
1010 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1011 }
1012 }
1013}
1014
1015pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1020
1021pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1030 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1031
1032 let mut warnings = Vec::new();
1033 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
1034
1035 let suggest = |rule_name: &str| {
1036 suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1037 };
1038
1039 let registry = crate::config::default_registry();
1043 for (offset, json_config) in scan_configure_file_comments(content) {
1044 let Some(obj) = json_config.as_object() else {
1045 continue;
1046 };
1047 let line_number = line_of_offset(content, offset);
1048 for (rule_name, rule_config) in obj {
1049 if !is_valid_rule_name(rule_name) {
1050 warnings.push(InlineConfigWarning {
1051 line_number,
1052 rule_name: rule_name.clone(),
1053 comment_type: "configure-file".to_string(),
1054 suggestion: suggest(rule_name),
1055 problem: InlineConfigProblem::UnknownRule,
1056 });
1057 continue;
1060 }
1061 let Some(options) = rule_config.as_object() else {
1063 continue;
1064 };
1065 let canonical = normalize_rule_name(rule_name);
1066 let Some(valid_keys) = registry.config_keys_for(&canonical) else {
1067 continue;
1068 };
1069 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1070 for key in options.keys() {
1071 if !valid_keys.contains(key) {
1072 warnings.push(InlineConfigWarning {
1073 line_number,
1074 rule_name: canonical.clone(),
1075 comment_type: "configure-file".to_string(),
1076 suggestion: suggest_similar_key(key, &valid_keys_vec),
1077 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1078 });
1079 }
1080 }
1081 }
1082 }
1083
1084 for (idx, line) in content.lines().enumerate() {
1085 let line_num = idx + 1;
1086
1087 let directives = parse_inline_directives(line);
1089 let mut rule_entries: Vec<(&str, &str)> = Vec::new();
1090
1091 for directive in &directives {
1092 let comment_type = match directive.kind {
1093 DirectiveKind::Disable => "disable",
1094 DirectiveKind::Enable => "enable",
1095 DirectiveKind::DisableLine => "disable-line",
1096 DirectiveKind::DisableNextLine => "disable-next-line",
1097 DirectiveKind::DisableFile => "disable-file",
1098 DirectiveKind::EnableFile => "enable-file",
1099 DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
1101 };
1102 for rule in &directive.rules {
1103 rule_entries.push((rule, comment_type));
1104 }
1105 }
1106
1107 for (rule_name, comment_type) in rule_entries {
1109 if !is_valid_rule_name(rule_name) {
1110 warnings.push(InlineConfigWarning {
1111 line_number: line_num,
1112 rule_name: rule_name.to_string(),
1113 comment_type: comment_type.to_string(),
1114 suggestion: suggest(rule_name),
1115 problem: InlineConfigProblem::UnknownRule,
1116 });
1117 }
1118 }
1119 }
1120
1121 warnings.sort_by_key(|w| w.line_number);
1124 drop_warnings_inside_code_blocks(content, flavor, &mut warnings);
1125 warnings
1126}
1127
1128pub fn validate_inline_enables_against_active_rules(
1145 content: &str,
1146 flavor: MarkdownFlavor,
1147 active_rules: &HashSet<String>,
1148 ignored_for_file: &HashSet<String>,
1149) -> Vec<InlineConfigWarning> {
1150 use crate::config::is_valid_rule_name;
1151
1152 let mut warnings = Vec::new();
1153
1154 let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
1155 if !is_valid_rule_name(name) {
1157 return;
1158 }
1159 let canonical = normalize_rule_name(name);
1160 let reason = if !active_rules.contains(&canonical) {
1164 EnableNoEffectReason::NotEnabled
1165 } else if ignored_for_file.contains(&canonical) {
1166 EnableNoEffectReason::IgnoredForFile
1167 } else {
1168 return;
1169 };
1170 warnings.push(InlineConfigWarning {
1171 line_number: line,
1172 rule_name: canonical,
1173 comment_type: comment_type.to_string(),
1174 suggestion: None,
1175 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1176 });
1177 };
1178
1179 for (offset, json_config) in scan_configure_file_comments(content) {
1181 let Some(obj) = json_config.as_object() else {
1182 continue;
1183 };
1184 let line = line_of_offset(content, offset);
1185 for (rule_name, rule_config) in obj {
1186 if rule_config.as_bool() == Some(true) {
1189 flag(&mut warnings, rule_name, "configure-file", line);
1190 }
1191 }
1192 }
1193
1194 for (idx, line) in content.lines().enumerate() {
1196 for directive in parse_inline_directives(line) {
1197 let comment_type = match directive.kind {
1198 DirectiveKind::Enable => "enable",
1199 DirectiveKind::EnableFile => "enable-file",
1200 _ => continue,
1201 };
1202 for rule in &directive.rules {
1203 flag(&mut warnings, rule, comment_type, idx + 1);
1204 }
1205 }
1206 }
1207
1208 warnings.sort_by_key(|w| w.line_number);
1209 drop_warnings_inside_code_blocks(content, flavor, &mut warnings);
1210 warnings
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216
1217 #[test]
1220 fn test_parse_inline_directives_all_kinds() {
1221 let cases: &[(&str, DirectiveKind)] = &[
1223 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
1224 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
1225 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
1226 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
1227 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
1228 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
1229 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
1230 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
1231 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
1232 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
1234 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
1235 (
1236 "<!-- markdownlint-disable-next-line -->",
1237 DirectiveKind::DisableNextLine,
1238 ),
1239 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
1240 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
1241 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
1242 ];
1243 for (input, expected_kind) in cases {
1244 let directives = parse_inline_directives(input);
1245 assert_eq!(
1246 directives.len(),
1247 1,
1248 "Expected 1 directive for {input:?}, got {directives:?}"
1249 );
1250 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
1251 }
1252 }
1253
1254 #[test]
1255 fn test_parse_inline_directives_disambiguation() {
1256 let line = "<!-- rumdl-disable-line MD001 -->";
1258 let directives = parse_inline_directives(line);
1259 assert_eq!(directives.len(), 1);
1260 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
1261
1262 let line = "<!-- rumdl-disable-next-line -->";
1263 let directives = parse_inline_directives(line);
1264 assert_eq!(directives.len(), 1);
1265 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
1266
1267 let line = "<!-- rumdl-disable-file MD001 -->";
1268 let directives = parse_inline_directives(line);
1269 assert_eq!(directives.len(), 1);
1270 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
1271
1272 let line = "<!-- rumdl-enable-file -->";
1273 let directives = parse_inline_directives(line);
1274 assert_eq!(directives.len(), 1);
1275 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
1276 }
1277
1278 #[test]
1279 fn test_parse_inline_directives_no_space_before_close() {
1280 let directives = parse_inline_directives("<!-- rumdl-disable-->");
1282 assert_eq!(directives.len(), 1);
1283 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1284 assert!(directives[0].rules.is_empty());
1285
1286 let directives = parse_inline_directives("<!-- rumdl-enable-->");
1287 assert_eq!(directives.len(), 1);
1288 assert_eq!(directives[0].kind, DirectiveKind::Enable);
1289 }
1290
1291 #[test]
1292 fn test_parse_inline_directives_multiple_on_one_line() {
1293 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1294 let directives = parse_inline_directives(line);
1295 assert_eq!(directives.len(), 2);
1296 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1297 assert_eq!(directives[0].rules, vec!["MD001"]);
1298 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1299 assert_eq!(directives[1].rules, vec!["MD001"]);
1300 }
1301
1302 #[test]
1303 fn test_parse_inline_directives_global_disable_then_specific_enable() {
1304 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1305 let directives = parse_inline_directives(line);
1306 assert_eq!(directives.len(), 2);
1307 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1308 assert!(directives[0].rules.is_empty());
1309 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1310 assert_eq!(directives[1].rules, vec!["MD001"]);
1311 }
1312
1313 #[test]
1314 fn test_parse_inline_directives_word_boundary() {
1315 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1317 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1319 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1321 }
1322
1323 #[test]
1324 fn test_parse_inline_directives_no_closing_tag() {
1325 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1327 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1328 }
1329
1330 #[test]
1331 fn test_parse_inline_directives_not_a_comment() {
1332 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1333 assert!(parse_inline_directives("Some regular text").is_empty());
1334 assert!(parse_inline_directives("").is_empty());
1335 }
1336
1337 #[test]
1338 fn test_parse_inline_directives_case_sensitive() {
1339 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1340 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1341 }
1342
1343 #[test]
1344 fn test_parse_inline_directives_rules_extraction() {
1345 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1346 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1347
1348 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1350 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1351
1352 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
1354 assert_eq!(directives[0].rules, vec!["MD001"]);
1355 }
1356
1357 #[test]
1358 fn test_parse_inline_directives_embedded_in_text() {
1359 let line = "Some text <!-- rumdl-disable MD001 --> more text";
1360 let directives = parse_inline_directives(line);
1361 assert_eq!(directives.len(), 1);
1362 assert_eq!(directives[0].rules, vec!["MD001"]);
1363
1364 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1365 let directives = parse_inline_directives(line);
1366 assert_eq!(directives.len(), 1);
1367 assert_eq!(directives[0].rules, vec!["MD001"]);
1368 }
1369
1370 #[test]
1371 fn test_parse_inline_directives_mixed_tools_same_line() {
1372 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1373 let directives = parse_inline_directives(line);
1374 assert_eq!(directives.len(), 2);
1375 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1376 assert_eq!(directives[0].rules, vec!["MD001"]);
1377 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1378 assert_eq!(directives[1].rules, vec!["MD002"]);
1379 }
1380
1381 #[test]
1384 fn test_parse_disable_comment() {
1385 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1387 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1388
1389 assert_eq!(
1391 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1392 Some(vec!["MD001", "MD002"])
1393 );
1394
1395 assert_eq!(parse_disable_comment("Some regular text"), None);
1397 }
1398
1399 #[test]
1400 fn test_parse_disable_line_comment() {
1401 assert_eq!(
1403 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1404 Some(vec![])
1405 );
1406
1407 assert_eq!(
1409 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1410 Some(vec!["MD013"])
1411 );
1412
1413 assert_eq!(parse_disable_line_comment("Some regular text"), None);
1415 }
1416
1417 #[test]
1418 fn test_inline_config_from_content() {
1419 let content = r#"# Test Document
1420
1421<!-- markdownlint-disable MD013 -->
1422This is a very long line that would normally trigger MD013 but it's disabled
1423
1424<!-- markdownlint-enable MD013 -->
1425This line will be checked again
1426
1427<!-- markdownlint-disable-next-line MD001 -->
1428# This heading will not be checked for MD001
1429## But this one will
1430
1431Some text <!-- markdownlint-disable-line MD013 -->
1432
1433<!-- markdownlint-capture -->
1434<!-- markdownlint-disable MD001 MD002 -->
1435# Heading with MD001 disabled
1436<!-- markdownlint-restore -->
1437# Heading with MD001 enabled again
1438"#;
1439
1440 let config = InlineConfig::from_content(content);
1441
1442 assert!(config.is_rule_disabled("MD013", 4));
1444
1445 assert!(!config.is_rule_disabled("MD013", 7));
1447
1448 assert!(config.is_rule_disabled("MD001", 10));
1450
1451 assert!(!config.is_rule_disabled("MD001", 11));
1453
1454 assert!(config.is_rule_disabled("MD013", 13));
1456
1457 assert!(!config.is_rule_disabled("MD001", 19));
1459 }
1460
1461 #[test]
1462 fn test_capture_restore() {
1463 let content = r#"<!-- markdownlint-disable MD001 -->
1464<!-- markdownlint-capture -->
1465<!-- markdownlint-disable MD002 MD003 -->
1466<!-- markdownlint-restore -->
1467Some content after restore
1468"#;
1469
1470 let config = InlineConfig::from_content(content);
1471
1472 assert!(config.is_rule_disabled("MD001", 5));
1474 assert!(!config.is_rule_disabled("MD002", 5));
1475 assert!(!config.is_rule_disabled("MD003", 5));
1476 }
1477
1478 #[test]
1479 fn test_validate_inline_config_rules_unknown_rule() {
1480 let content = "<!-- rumdl-disable abc -->\nSome content";
1481 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1482 assert_eq!(warnings.len(), 1);
1483 assert_eq!(warnings[0].line_number, 1);
1484 assert_eq!(warnings[0].rule_name, "abc");
1485 assert_eq!(warnings[0].comment_type, "disable");
1486 }
1487
1488 #[test]
1489 fn test_validate_inline_config_rules_valid_rule() {
1490 let content = "<!-- rumdl-disable MD001 -->\nSome content";
1491 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1492 assert!(
1493 warnings.is_empty(),
1494 "MD001 is a valid rule, should not produce warnings"
1495 );
1496 }
1497
1498 #[test]
1499 fn test_validate_inline_config_rules_alias() {
1500 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1501 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1502 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1503 }
1504
1505 #[test]
1506 fn test_validate_inline_config_rules_multiple_unknown() {
1507 let content = r#"<!-- rumdl-disable abc xyz -->
1508<!-- rumdl-disable-line foo -->
1509<!-- markdownlint-disable-next-line bar -->
1510"#;
1511 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1512 assert_eq!(warnings.len(), 4);
1513 assert_eq!(warnings[0].rule_name, "abc");
1514 assert_eq!(warnings[1].rule_name, "xyz");
1515 assert_eq!(warnings[2].rule_name, "foo");
1516 assert_eq!(warnings[3].rule_name, "bar");
1517 }
1518
1519 #[test]
1520 fn test_validate_inline_config_rules_suggestion() {
1521 let content = "<!-- rumdl-disable MD00 -->\n";
1523 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1524 assert_eq!(warnings.len(), 1);
1525 assert!(warnings[0].suggestion.is_some());
1527 }
1528
1529 #[test]
1530 fn test_validate_inline_config_rules_file_comments() {
1531 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1532 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1533 assert_eq!(warnings.len(), 2);
1534 assert_eq!(warnings[0].comment_type, "disable-file");
1535 assert_eq!(warnings[1].comment_type, "enable-file");
1536 }
1537
1538 #[test]
1539 fn test_validate_inline_config_rules_global_disable() {
1540 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1542 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1543 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1544 }
1545
1546 #[test]
1547 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1548 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1550 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1551 assert_eq!(warnings.len(), 2);
1552 assert_eq!(warnings[0].rule_name, "abc");
1553 assert_eq!(warnings[1].rule_name, "xyz");
1554 }
1555
1556 #[test]
1557 fn test_validate_inline_config_rules_configure_file() {
1558 let content =
1560 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1561 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1562 assert_eq!(warnings.len(), 1);
1563 assert_eq!(warnings[0].rule_name, "nonexistent");
1564 assert_eq!(warnings[0].comment_type, "configure-file");
1565 }
1566
1567 #[test]
1568 fn test_validate_inline_config_rules_markdownlint_variants() {
1569 let content = r#"<!-- markdownlint-disable unknown_rule -->
1571<!-- markdownlint-enable another_fake -->
1572<!-- markdownlint-disable-line bad_rule -->
1573<!-- markdownlint-disable-next-line fake_rule -->
1574<!-- markdownlint-disable-file missing_rule -->
1575<!-- markdownlint-enable-file nonexistent -->
1576"#;
1577 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1578 assert_eq!(warnings.len(), 6);
1579 assert_eq!(warnings[0].rule_name, "unknown_rule");
1580 assert_eq!(warnings[1].rule_name, "another_fake");
1581 assert_eq!(warnings[2].rule_name, "bad_rule");
1582 assert_eq!(warnings[3].rule_name, "fake_rule");
1583 assert_eq!(warnings[4].rule_name, "missing_rule");
1584 assert_eq!(warnings[5].rule_name, "nonexistent");
1585 }
1586
1587 #[test]
1591 fn test_validate_inline_config_rules_ignores_code_blocks() {
1592 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
1593 assert!(
1594 validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
1595 "a directive inside a fence is documentation, not configuration"
1596 );
1597
1598 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
1599 assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
1600
1601 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
1602 assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
1603
1604 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
1605 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1606 }
1607
1608 #[test]
1613 fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
1614 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
1615 assert_eq!(
1616 validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
1617 1,
1618 "an admonition holds its content at an indent that is not code"
1619 );
1620 assert!(
1621 validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
1622 "without admonitions the same lines are an indented code block"
1623 );
1624
1625 let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
1626 assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
1627 assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
1628 }
1629
1630 #[test]
1633 fn test_validate_inline_enables_reports_inside_an_indented_container() {
1634 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
1635 let active = active_set(&["MD009"]);
1636 assert_eq!(
1637 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
1638 .len(),
1639 1,
1640 "the enable is live in an admonition body, and configuration never enabled MD013"
1641 );
1642 assert!(
1643 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
1644 .is_empty(),
1645 "without admonitions the same lines are an indented code block"
1646 );
1647 }
1648
1649 #[test]
1652 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
1653 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
1654 assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
1655
1656 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
1657 assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
1658
1659 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
1660 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1661 }
1662
1663 #[test]
1664 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1665 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1666 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1667 assert_eq!(warnings.len(), 1);
1668 assert_eq!(warnings[0].rule_name, "fake_rule");
1669 assert_eq!(warnings[0].comment_type, "configure-file");
1670 }
1671
1672 #[test]
1673 fn test_get_rule_config_from_configure_file() {
1674 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1675
1676This is a test line."#;
1677
1678 let inline_config = InlineConfig::from_content(content);
1679 let config_override = inline_config.get_rule_config("MD013");
1680
1681 assert!(config_override.is_some(), "MD013 config should be found");
1682 let json = config_override.unwrap();
1683 assert!(json.is_object(), "Config should be an object");
1684 let obj = json.as_object().unwrap();
1685 assert!(obj.contains_key("line_length"), "Should have line_length key");
1686 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1687 }
1688
1689 #[test]
1690 fn test_get_rule_config_tables_false() {
1691 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1693
1694 let inline_config = InlineConfig::from_content(content);
1695 let config_override = inline_config.get_rule_config("MD013");
1696
1697 assert!(config_override.is_some(), "MD013 config should be found");
1698 let json = config_override.unwrap();
1699 let obj = json.as_object().unwrap();
1700 assert!(obj.contains_key("tables"), "Should have tables key");
1701 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1702 }
1703
1704 fn active_set(names: &[&str]) -> HashSet<String> {
1718 names.iter().map(|s| (*s).to_string()).collect()
1719 }
1720
1721 fn no_ignores() -> HashSet<String> {
1722 HashSet::new()
1723 }
1724
1725 #[test]
1726 fn test_inline_enable_of_inactive_rule_warns() {
1727 let active = active_set(&["MD013", "MD022"]);
1728 let content = "<!-- rumdl-enable MD012 -->\n";
1729
1730 let warnings =
1731 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1732
1733 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1734 assert_eq!(warnings[0].rule_name, "MD012");
1735 assert_eq!(warnings[0].comment_type, "enable");
1736 assert_eq!(
1737 warnings[0].problem,
1738 InlineConfigProblem::EnableHasNoEffect {
1739 reason: EnableNoEffectReason::NotEnabled
1740 }
1741 );
1742 assert_eq!(warnings[0].line_number, 1);
1743 }
1744
1745 #[test]
1746 fn test_inline_enable_of_active_rule_does_not_warn() {
1747 let active = active_set(&["MD012", "MD013"]);
1750 let content = "<!-- rumdl-enable MD012 -->\n";
1751
1752 let warnings =
1753 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1754
1755 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1756 }
1757
1758 #[test]
1759 fn test_inline_enable_of_per_file_ignored_rule_warns() {
1760 let active = active_set(&["MD012", "MD013"]);
1763 let ignored = active_set(&["MD012"]);
1764 let content = "<!-- rumdl-enable MD012 -->\n";
1765
1766 let warnings =
1767 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1768
1769 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1770 assert_eq!(warnings[0].rule_name, "MD012");
1771 assert_eq!(
1772 warnings[0].problem,
1773 InlineConfigProblem::EnableHasNoEffect {
1774 reason: EnableNoEffectReason::IgnoredForFile
1775 }
1776 );
1777 assert!(
1778 warnings[0].format_message().contains("per-file-ignores"),
1779 "the message must name the setting in play: {}",
1780 warnings[0].format_message()
1781 );
1782 }
1783
1784 #[test]
1785 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
1786 let active = active_set(&["MD013"]);
1789 let ignored = active_set(&["MD012"]);
1790 let content = "<!-- rumdl-enable MD012 -->\n";
1791
1792 let warnings =
1793 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1794
1795 assert_eq!(warnings.len(), 1, "{warnings:?}");
1796 assert_eq!(
1797 warnings[0].problem,
1798 InlineConfigProblem::EnableHasNoEffect {
1799 reason: EnableNoEffectReason::NotEnabled
1800 }
1801 );
1802 }
1803
1804 #[test]
1805 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
1806 let active = active_set(&["MD012", "MD013"]);
1809 let ignored = active_set(&["MD013"]);
1810 let content = "<!-- rumdl-enable MD012 -->\n";
1811
1812 let warnings =
1813 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1814
1815 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
1816 }
1817
1818 #[test]
1819 fn test_enable_inside_code_block_does_not_warn() {
1820 let active = active_set(&["MD012", "MD013"]);
1823 let ignored = active_set(&["MD012"]);
1824
1825 for content in [
1826 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
1827 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
1828 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
1829 ] {
1830 let warnings =
1831 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1832 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
1833 }
1834
1835 for content in [
1836 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
1837 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
1838 ] {
1839 let warnings =
1840 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1841 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
1842 }
1843 }
1844
1845 #[test]
1846 fn test_bare_enable_all_does_not_warn() {
1847 let active = active_set(&["MD013"]);
1849 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1850 let warnings = validate_inline_enables_against_active_rules(
1851 content,
1852 MarkdownFlavor::Standard,
1853 &active,
1854 &active_set(&["MD013"]),
1855 );
1856 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1857 }
1858 }
1859
1860 #[test]
1861 fn test_enable_file_and_alias_of_inactive_rule_warn() {
1862 let active = active_set(&["MD013"]);
1863 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1865
1866 let warnings =
1867 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1868
1869 assert_eq!(warnings.len(), 1, "{warnings:?}");
1870 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1871 assert_eq!(warnings[0].comment_type, "enable-file");
1872 }
1873
1874 #[test]
1875 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1876 let active = active_set(&["MD013"]);
1877 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1878 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1879
1880 let warn_true =
1881 validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
1882 let warn_false =
1883 validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
1884
1885 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1886 assert_eq!(warn_true[0].rule_name, "MD012");
1887 assert!(
1888 warn_false.is_empty(),
1889 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1895 let active = active_set(&["MD013"]);
1898 let content = "<!-- rumdl-enable NotARule -->\n";
1899
1900 let warnings =
1901 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1902
1903 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1904 }
1905
1906 #[test]
1907 fn test_disable_directive_never_warns_as_no_effect() {
1908 let active = active_set(&["MD012", "MD013"]);
1911 let ignored = active_set(&["MD012"]);
1912 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1913
1914 let warnings =
1915 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1916
1917 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1918 }
1919
1920 #[test]
1926 fn test_unknown_option_key_in_configure_file_warns() {
1927 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
1928
1929 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1930
1931 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1932 assert_eq!(warnings[0].rule_name, "MD013");
1933 assert_eq!(
1934 warnings[0].problem,
1935 InlineConfigProblem::UnknownOption {
1936 key: "line_lenght".to_string()
1937 }
1938 );
1939 assert!(
1940 warnings[0].suggestion.is_some(),
1941 "a near-miss key should suggest the real one"
1942 );
1943 assert!(
1944 warnings[0].format_message().contains("Unknown option for rule MD013"),
1945 "message was: {}",
1946 warnings[0].format_message()
1947 );
1948 }
1949
1950 #[test]
1951 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
1952 for content in [
1955 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
1956 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
1957 ] {
1958 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1959 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
1960 }
1961 }
1962
1963 #[test]
1964 fn test_unknown_rule_does_not_also_warn_about_its_options() {
1965 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
1968
1969 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1970
1971 assert_eq!(
1972 warnings.len(),
1973 1,
1974 "expected only the unknown-rule warning: {warnings:?}"
1975 );
1976 assert_eq!(warnings[0].rule_name, "nonexistent");
1977 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
1978 }
1979
1980 #[test]
1981 fn test_boolean_rule_value_has_no_option_warnings() {
1982 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
1983
1984 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1985
1986 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
1987 }
1988
1989 #[test]
1990 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
1991 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
1992
1993 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1994
1995 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1996 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
1997 }
1998
1999 #[test]
2000 fn test_configure_file_spanning_multiple_lines_applies() {
2001 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2002
2003 let inline_config = InlineConfig::from_content(content);
2004 let config_override = inline_config.get_rule_config("MD013");
2005
2006 assert!(config_override.is_some(), "a multi-line configure-file must apply");
2007 let obj = config_override.unwrap().as_object().unwrap();
2008 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2009 }
2010
2011 #[test]
2012 fn test_every_configure_file_comment_applies_not_just_the_first() {
2013 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";
2016
2017 let inline_config = InlineConfig::from_content(content);
2018
2019 assert!(
2020 inline_config.get_rule_config("MD013").is_some(),
2021 "first (single-line) configure-file dropped"
2022 );
2023 assert!(
2024 inline_config.get_rule_config("MD007").is_some(),
2025 "second (multi-line) configure-file dropped"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_multiline_configure_file_in_code_block_is_ignored() {
2031 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2034
2035 let inline_config = InlineConfig::from_content(content);
2036
2037 assert!(
2038 inline_config.get_rule_config("MD013").is_none(),
2039 "configure-file inside a fenced code block must not apply"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_multiline_configure_file_bool_and_alias_still_honored() {
2045 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2048
2049 let inline_config = InlineConfig::from_content(content);
2050
2051 assert!(
2052 inline_config.is_rule_disabled("MD012", 1),
2053 "boolean alias key must disable the rule"
2054 );
2055 let obj = inline_config
2056 .get_rule_config("MD013")
2057 .expect("alias-keyed config should resolve to MD013")
2058 .as_object()
2059 .unwrap();
2060 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2061 }
2062
2063 #[test]
2064 fn test_multiline_configure_file_warning_reports_start_line() {
2065 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2068
2069 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2070
2071 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2072 assert_eq!(warnings[0].rule_name, "nonexistent");
2073 assert_eq!(warnings[0].comment_type, "configure-file");
2074 assert_eq!(
2075 warnings[0].line_number, 3,
2076 "warning must point at the line the comment starts on"
2077 );
2078 }
2079
2080 #[test]
2081 fn test_configure_file_bool_false_disables_rule() {
2082 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2085
2086 let inline_config = InlineConfig::from_content(content);
2087
2088 assert!(inline_config.is_rule_disabled("MD012", 1));
2089 assert!(
2090 inline_config.get_rule_config("MD012").is_none(),
2091 "a boolean should not be stored as rule options"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_configure_file_bool_false_disables_rule_by_alias() {
2097 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2098
2099 let inline_config = InlineConfig::from_content(content);
2100
2101 assert!(inline_config.is_rule_disabled("MD012", 1));
2102 }
2103
2104 #[test]
2105 fn test_configure_file_bool_true_leaves_rule_enabled() {
2106 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2107
2108 let inline_config = InlineConfig::from_content(content);
2109
2110 assert!(!inline_config.is_rule_disabled("MD012", 1));
2111 }
2112
2113 #[test]
2114 fn test_get_rule_config_from_configure_file_alias_key() {
2115 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2117
2118 let inline_config = InlineConfig::from_content(content);
2119 let config_override = inline_config.get_rule_config("MD013");
2120
2121 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2122 let obj = config_override.unwrap().as_object().unwrap();
2123 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2124 }
2125
2126 #[test]
2129 fn test_parse_disable_does_not_match_disable_line() {
2130 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2132 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2133 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2134 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2135 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2136 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2137 }
2138
2139 #[test]
2140 fn test_parse_enable_does_not_match_enable_file() {
2141 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2142 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2143 }
2144
2145 #[test]
2146 fn test_parse_disable_comment_edge_cases() {
2147 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2149
2150 assert_eq!(
2152 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2153 Some(vec!["MD001", "MD002"])
2154 );
2155
2156 assert_eq!(
2158 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2159 Some(vec!["MD001"])
2160 );
2161
2162 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2164
2165 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2167
2168 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2170
2171 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2173
2174 assert_eq!(
2176 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2177 Some(vec!["MD001", "MD001", "MD002"])
2178 );
2179
2180 assert_eq!(
2182 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
2183 Some(vec!["MD001"])
2184 );
2185
2186 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
2188 let comment = format!("<!-- rumdl-disable {many_rules} -->");
2189 let parsed = parse_disable_comment(&comment);
2190 assert!(parsed.is_some());
2191 assert_eq!(parsed.unwrap().len(), 100);
2192
2193 assert_eq!(
2195 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
2196 Some(vec!["MD001-test"])
2197 );
2198 assert_eq!(
2199 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
2200 Some(vec!["custom_rule"])
2201 );
2202 }
2203
2204 #[test]
2205 fn test_parse_enable_comment_edge_cases() {
2206 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
2207 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
2208 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
2209 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
2210 }
2211
2212 #[test]
2215 fn test_disable_inside_fenced_code_block_ignored() {
2216 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
2217 let config = InlineConfig::from_content(content);
2218 assert!(!config.is_rule_disabled("MD001", 6));
2220 }
2221
2222 #[test]
2223 fn test_disable_inside_tilde_fence_ignored() {
2224 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
2225 let config = InlineConfig::from_content(content);
2226 assert!(!config.is_rule_disabled("MD001", 6));
2227 }
2228
2229 #[test]
2230 fn test_disable_before_code_block_persists_after() {
2231 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
2233 let config = InlineConfig::from_content(content);
2234 assert!(config.is_rule_disabled("MD001", 5));
2235 }
2236
2237 #[test]
2238 fn test_enable_inside_code_block_ignored() {
2239 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
2241 let config = InlineConfig::from_content(content);
2242 assert!(config.is_rule_disabled("MD001", 5));
2243 }
2244
2245 #[test]
2246 fn test_disable_inside_indented_code_block_ignored() {
2247 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
2251 let config = InlineConfig::from_content(content);
2252 assert!(!config.is_rule_disabled("MD001", 5));
2253
2254 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
2256 let config = InlineConfig::from_content(unindented);
2257 assert!(config.is_rule_disabled("MD001", 5));
2258 }
2259
2260 #[test]
2261 fn test_configure_file_inside_indented_code_block_ignored() {
2262 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2265 let config = InlineConfig::from_content(content);
2266 assert!(config.get_rule_config("MD013").is_none());
2267
2268 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2269 let config = InlineConfig::from_content(unindented);
2270 assert!(config.get_rule_config("MD013").is_some());
2271 }
2272
2273 #[test]
2276 fn test_markdownlint_disable_rumdl_enable_interop() {
2277 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
2278 let config = InlineConfig::from_content(content);
2279 assert!(config.is_rule_disabled("MD001", 2));
2280 assert!(!config.is_rule_disabled("MD001", 4));
2281 }
2282
2283 #[test]
2284 fn test_rumdl_disable_markdownlint_enable_interop() {
2285 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
2286 let config = InlineConfig::from_content(content);
2287 assert!(config.is_rule_disabled("MD013", 2));
2288 assert!(!config.is_rule_disabled("MD013", 4));
2289 }
2290
2291 #[test]
2294 fn test_global_disable_then_specific_enable() {
2295 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
2296 let config = InlineConfig::from_content(content);
2297 assert!(!config.is_rule_disabled("MD001", 4));
2298 assert!(config.is_rule_disabled("MD002", 4));
2299 assert!(config.is_rule_disabled("MD013", 4));
2300 }
2301
2302 #[test]
2303 fn test_specific_disable_then_global_enable() {
2304 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
2305 let config = InlineConfig::from_content(content);
2306 assert!(config.is_rule_disabled("MD001", 2));
2307 assert!(config.is_rule_disabled("MD002", 2));
2308 assert!(!config.is_rule_disabled("MD001", 4));
2309 assert!(!config.is_rule_disabled("MD002", 4));
2310 }
2311
2312 #[test]
2313 fn test_multiple_rules_disable_enable_independently() {
2314 let content = "\
2315Line 1\n\
2316<!-- rumdl-disable MD001 MD002 -->\n\
2317Line 3\n\
2318<!-- rumdl-enable MD001 -->\n\
2319Line 5\n\
2320<!-- rumdl-disable -->\n\
2321Line 7\n\
2322<!-- rumdl-enable MD002 -->\n\
2323Line 9\n";
2324 let config = InlineConfig::from_content(content);
2325
2326 assert!(!config.is_rule_disabled("MD001", 1));
2328 assert!(!config.is_rule_disabled("MD002", 1));
2329
2330 assert!(config.is_rule_disabled("MD001", 3));
2332 assert!(config.is_rule_disabled("MD002", 3));
2333
2334 assert!(!config.is_rule_disabled("MD001", 5));
2336 assert!(config.is_rule_disabled("MD002", 5));
2337
2338 assert!(config.is_rule_disabled("MD001", 7));
2340 assert!(config.is_rule_disabled("MD002", 7));
2341
2342 assert!(config.is_rule_disabled("MD001", 9));
2344 assert!(!config.is_rule_disabled("MD002", 9));
2345 }
2346
2347 #[test]
2350 fn test_empty_content() {
2351 let config = InlineConfig::from_content("");
2352 assert!(!config.is_rule_disabled("MD001", 1));
2353 }
2354
2355 #[test]
2356 fn test_single_disable_comment_only() {
2357 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
2360 assert!(!config.is_rule_disabled("MD001", 1));
2361 assert!(config.is_rule_disabled("MD001", 2));
2362 assert!(config.is_rule_disabled("MD999", 2));
2363
2364 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
2366 assert!(!config.is_rule_disabled("MD001", 1));
2367 assert!(config.is_rule_disabled("MD001", 2));
2368 assert!(config.is_rule_disabled("MD001", 3));
2369 }
2370
2371 #[test]
2372 fn test_no_inline_markers() {
2373 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
2374 assert!(!config.is_rule_disabled("MD001", 1));
2375 assert!(!config.is_rule_disabled("MD001", 5));
2376 }
2377
2378 #[test]
2381 fn test_export_for_file_index_persistent_transitions() {
2382 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
2383 let config = InlineConfig::from_content(content);
2384 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
2385
2386 assert!(file_disabled.is_empty());
2387 assert!(
2389 persistent.len() >= 2,
2390 "Expected at least 2 transitions, got {}",
2391 persistent.len()
2392 );
2393 }
2394
2395 #[test]
2396 fn test_export_for_file_index_disable_file() {
2397 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
2398 let config = InlineConfig::from_content(content);
2399 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
2400
2401 assert!(file_disabled.contains("MD001"));
2402 }
2403
2404 #[test]
2405 fn test_export_for_file_index_disable_line() {
2406 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
2407 let config = InlineConfig::from_content(content);
2408 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
2409
2410 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
2411 assert!(line_disabled[&2].contains("MD001"));
2412 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
2413 }
2414}