1use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
27use crate::utils::code_block_utils::CodeBlockUtils;
28use serde_json::Value as JsonValue;
29use std::collections::{HashMap, HashSet};
30
31pub(crate) fn normalize_rule_name(rule: &str) -> String {
34 markdownlint_to_rumdl_rule_key(rule).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
35}
36
37fn has_inline_config_markers(content: &str) -> bool {
38 if !content.contains("<!--") {
39 return false;
40 }
41 content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
42}
43
44pub type FileIndexExport = (
47 HashSet<String>,
48 Vec<(usize, HashSet<String>, HashSet<String>)>,
49 HashMap<usize, HashSet<String>>,
50);
51
52#[derive(Debug, Clone)]
56struct StateTransition {
57 line: usize,
59 disabled: HashSet<String>,
61 enabled: HashSet<String>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DisableLayer {
71 File,
73 Block,
75 Line,
77}
78
79#[derive(Debug, Clone)]
80pub struct InlineConfig {
81 transitions: Vec<StateTransition>,
84 line_disabled_rules: HashMap<usize, HashSet<String>>,
86 file_disabled_rules: HashSet<String>,
88 file_enabled_rules: HashSet<String>,
90 file_rule_config: HashMap<String, JsonValue>,
93}
94
95impl Default for InlineConfig {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl InlineConfig {
102 pub fn new() -> Self {
103 Self {
104 transitions: Vec::new(),
105 line_disabled_rules: HashMap::new(),
106 file_disabled_rules: HashSet::new(),
107 file_enabled_rules: HashSet::new(),
108 file_rule_config: HashMap::new(),
109 }
110 }
111
112 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
115 if self.transitions.is_empty() {
116 return None;
117 }
118 match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
120 Ok(idx) => Some(&self.transitions[idx]),
121 Err(idx) => {
122 if idx > 0 {
123 Some(&self.transitions[idx - 1])
124 } else {
125 None
126 }
127 }
128 }
129 }
130
131 pub fn from_content(content: &str) -> Self {
133 if !has_inline_config_markers(content) {
134 return Self::new();
135 }
136
137 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
138 Self::from_content_with_code_blocks_internal(content, &code_blocks)
139 }
140
141 pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
143 if !has_inline_config_markers(content) {
144 return Self::new();
145 }
146
147 Self::from_content_with_code_blocks_internal(content, code_blocks)
148 }
149
150 fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
151 let mut config = Self::new();
152 let lines: Vec<&str> = content.lines().collect();
153
154 for (offset, json_config) in scan_configure_file_comments(content) {
159 if offset_in_code_block(offset, code_blocks) {
160 continue;
161 }
162 let Some(obj) = json_config.as_object() else {
163 continue;
164 };
165 for (rule_name, rule_config) in obj {
166 let normalized = normalize_rule_name(rule_name);
170 if let Some(enabled) = rule_config.as_bool() {
171 if enabled {
172 config.file_disabled_rules.remove(&normalized);
173 } else {
174 config.file_disabled_rules.insert(normalized);
175 }
176 continue;
177 }
178 config.file_rule_config.insert(normalized, rule_config.clone());
182 }
183 }
184
185 let mut line_positions = Vec::with_capacity(lines.len());
187 let mut pos = 0;
188 for line in &lines {
189 line_positions.push(pos);
190 pos += line.len() + 1; }
192
193 let mut currently_disabled: HashSet<String> = HashSet::new();
195 let mut currently_enabled: HashSet<String> = HashSet::new();
196 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
197
198 let mut prev_disabled: HashSet<String> = HashSet::new();
200 let mut prev_enabled: HashSet<String> = HashSet::new();
201
202 config.transitions.push(StateTransition {
204 line: 1,
205 disabled: HashSet::new(),
206 enabled: HashSet::new(),
207 });
208
209 for (idx, line) in lines.iter().enumerate() {
210 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
215 config.transitions.push(StateTransition {
216 line: line_num,
217 disabled: currently_disabled.clone(),
218 enabled: currently_enabled.clone(),
219 });
220 prev_disabled.clone_from(¤tly_disabled);
221 prev_enabled.clone_from(¤tly_enabled);
222 }
223
224 if line_in_code_block(line_positions[idx], line, code_blocks) {
226 continue;
227 }
228
229 let directives = parse_inline_directives(line);
232
233 let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
235
236 for directive in &directives {
238 match directive.kind {
239 DirectiveKind::DisableFile => {
240 if directive.rules.is_empty() {
241 config.file_disabled_rules.clear();
242 config.file_disabled_rules.insert("*".to_string());
243 } else if config.file_disabled_rules.contains("*") {
244 for rule in &directive.rules {
245 config.file_enabled_rules.remove(&normalize_rule_name(rule));
246 }
247 } else {
248 for rule in &directive.rules {
249 config.file_disabled_rules.insert(normalize_rule_name(rule));
250 }
251 }
252 }
253 DirectiveKind::EnableFile => {
254 if directive.rules.is_empty() {
255 config.file_disabled_rules.clear();
256 config.file_enabled_rules.clear();
257 } else if config.file_disabled_rules.contains("*") {
258 for rule in &directive.rules {
259 config.file_enabled_rules.insert(normalize_rule_name(rule));
260 }
261 } else {
262 for rule in &directive.rules {
263 config.file_disabled_rules.remove(&normalize_rule_name(rule));
264 }
265 }
266 }
267 _ => {}
269 }
270 }
271
272 for directive in &directives {
274 match directive.kind {
275 DirectiveKind::DisableNextLine => {
276 let next_line = line_num + 1;
277 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
278 if directive.rules.is_empty() {
279 line_rules.insert("*".to_string());
280 } else {
281 for rule in &directive.rules {
282 line_rules.insert(normalize_rule_name(rule));
283 }
284 }
285 }
286 DirectiveKind::DisableLine => {
287 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
288 if directive.rules.is_empty() {
289 line_rules.insert("*".to_string());
290 } else {
291 for rule in &directive.rules {
292 line_rules.insert(normalize_rule_name(rule));
293 }
294 }
295 }
296 DirectiveKind::Disable => {
297 if directive.rules.is_empty() {
298 currently_disabled.clear();
299 currently_disabled.insert("*".to_string());
300 currently_enabled.clear();
301 } else if currently_disabled.contains("*") {
302 for rule in &directive.rules {
303 currently_enabled.remove(&normalize_rule_name(rule));
304 }
305 } else {
306 for rule in &directive.rules {
307 currently_disabled.insert(normalize_rule_name(rule));
308 }
309 }
310 }
311 DirectiveKind::Enable => {
312 if directive.rules.is_empty() {
313 currently_disabled.clear();
314 currently_enabled.clear();
315 } else if currently_disabled.contains("*") {
316 for rule in &directive.rules {
317 currently_enabled.insert(normalize_rule_name(rule));
318 }
319 } else {
320 for rule in &directive.rules {
321 currently_disabled.remove(&normalize_rule_name(rule));
322 }
323 }
324 }
325 DirectiveKind::Capture => {
326 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
327 }
328 DirectiveKind::Restore => {
329 if let Some((disabled, enabled)) = capture_stack.pop() {
330 currently_disabled = disabled;
331 currently_enabled = enabled;
332 }
333 }
334 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
336 }
337 }
338
339 if has_prettier_ignore {
341 let next_line = line_num + 1;
342 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
343 line_rules.insert("*".to_string());
344 }
345 }
346
347 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
349 config.transitions.push(StateTransition {
350 line: lines.len() + 1,
351 disabled: currently_disabled,
352 enabled: currently_enabled,
353 });
354 }
355
356 config
357 }
358
359 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
361 self.disabling_layer(rule_name, line_number).is_some()
362 }
363
364 pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
371 if self.file_disabled_rules.contains("*") {
372 return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
374 } else if self.file_disabled_rules.contains(rule_name) {
375 return Some(DisableLayer::File);
376 }
377
378 if let Some(transition) = self.find_transition(line_number) {
380 let disabled = if transition.disabled.contains("*") {
381 !transition.enabled.contains(rule_name)
382 } else {
383 transition.disabled.contains(rule_name)
384 };
385 if disabled {
386 return Some(DisableLayer::Block);
387 }
388 }
389
390 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
392 && (line_rules.contains("*") || line_rules.contains(rule_name))
393 {
394 return Some(DisableLayer::Line);
395 }
396
397 None
398 }
399
400 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
402 let mut disabled = HashSet::new();
403
404 if let Some(transition) = self.find_transition(line_number) {
406 if transition.disabled.contains("*") {
407 disabled.insert("*".to_string());
408 } else {
409 for rule in &transition.disabled {
410 disabled.insert(rule.clone());
411 }
412 }
413 }
414
415 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
417 for rule in line_rules {
418 disabled.insert(rule.clone());
419 }
420 }
421
422 disabled
423 }
424
425 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
427 self.file_rule_config.get(rule_name)
428 }
429
430 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
432 &self.file_rule_config
433 }
434
435 pub fn export_for_file_index(&self) -> FileIndexExport {
439 let file_disabled = self.file_disabled_rules.clone();
440
441 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
442 .transitions
443 .iter()
444 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
445 .collect();
446
447 let line_disabled = self.line_disabled_rules.clone();
448
449 (file_disabled, persistent_transitions, line_disabled)
450 }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum DirectiveKind {
464 Disable,
465 DisableLine,
466 DisableNextLine,
467 DisableFile,
468 Enable,
469 EnableFile,
470 Capture,
471 Restore,
472 ConfigureFile,
473}
474
475#[derive(Debug, Clone, PartialEq)]
477pub struct InlineDirective<'a> {
478 pub kind: DirectiveKind,
479 pub rules: Vec<&'a str>,
480 pub span: std::ops::Range<usize>,
482}
483
484const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
486
487const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
491 (DirectiveKind::DisableNextLine, "disable-next-line"),
492 (DirectiveKind::DisableLine, "disable-line"),
493 (DirectiveKind::DisableFile, "disable-file"),
494 (DirectiveKind::Disable, "disable"),
495 (DirectiveKind::EnableFile, "enable-file"),
496 (DirectiveKind::Enable, "enable"),
497 (DirectiveKind::ConfigureFile, "configure-file"),
498 (DirectiveKind::Capture, "capture"),
499 (DirectiveKind::Restore, "restore"),
500];
501
502fn try_parse_directive(s: &str) -> Option<(DirectiveKind, Vec<&str>, usize)> {
506 for tool in TOOL_PREFIXES {
507 if !s.starts_with(tool) {
508 continue;
509 }
510 let after_tool = &s[tool.len()..];
511
512 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
513 if !after_tool.starts_with(keyword) {
514 continue;
515 }
516 let after_kw = &after_tool[keyword.len()..];
517
518 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
521 continue;
522 }
523
524 let close_offset = after_kw.find("-->")?;
526
527 let rules_str = after_kw[..close_offset].trim();
528 let rules = if rules_str.is_empty() {
529 Vec::new()
530 } else {
531 rules_str.split_whitespace().collect()
532 };
533
534 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((kind, rules, consumed));
536 }
537
538 return None;
540 }
541 None
542}
543
544pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
550 let mut results = Vec::new();
551 let mut pos = 0;
552
553 while pos < line.len() {
554 let remaining = &line[pos..];
555 let Some(open_offset) = remaining.find("<!-- ") else {
556 break;
557 };
558 let comment_start = pos + open_offset;
559 let after_open = &line[comment_start + 5..]; if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
562 let comment_end = comment_start + 5 + consumed;
563 results.push(InlineDirective {
564 kind,
565 rules,
566 span: comment_start..comment_end,
567 });
568 pos = comment_end;
569 } else {
570 pos = comment_start + 5;
571 }
572 }
573
574 results
575}
576
577fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
583 parse_inline_directives(line)
584 .into_iter()
585 .find(|d| d.kind == kind)
586 .map(|d| d.rules)
587}
588
589pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
591 find_directive_rules(line, DirectiveKind::Disable)
592}
593
594pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
596 find_directive_rules(line, DirectiveKind::Enable)
597}
598
599pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
601 find_directive_rules(line, DirectiveKind::DisableLine)
602}
603
604pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
606 find_directive_rules(line, DirectiveKind::DisableNextLine)
607}
608
609pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
611 find_directive_rules(line, DirectiveKind::DisableFile)
612}
613
614pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
616 find_directive_rules(line, DirectiveKind::EnableFile)
617}
618
619pub fn is_capture_comment(line: &str) -> bool {
621 parse_inline_directives(line)
622 .iter()
623 .any(|d| d.kind == DirectiveKind::Capture)
624}
625
626pub fn is_restore_comment(line: &str) -> bool {
628 parse_inline_directives(line)
629 .iter()
630 .any(|d| d.kind == DirectiveKind::Restore)
631}
632
633const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
634
635fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
637 code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
638}
639
640fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
648 let probe = line
649 .find(|c: char| !c.is_whitespace())
650 .map_or(line_start, |indent| line_start + indent);
651 offset_in_code_block(probe, code_blocks)
652}
653
654fn line_of_offset(text: &str, offset: usize) -> usize {
656 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
657}
658
659fn drop_warnings_inside_code_blocks(content: &str, warnings: &mut Vec<InlineConfigWarning>) {
666 if warnings.is_empty() {
667 return;
668 }
669 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
670 if code_blocks.is_empty() {
671 return;
672 }
673
674 let line_spans: Vec<(usize, &str)> = {
677 let mut spans = Vec::new();
678 let mut start = 0;
679 for line in content.split('\n') {
680 spans.push((start, line));
681 start += line.len() + 1;
682 }
683 spans
684 };
685
686 warnings.retain(|warning| {
687 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
688 return true;
689 };
690 !line_in_code_block(line_start, line, &code_blocks)
691 });
692}
693
694fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
705 let mut found = Vec::new();
706 let mut pos = 0;
707
708 while let Some(open_offset) = text[pos..].find("<!-- ") {
709 let comment_start = pos + open_offset;
710 let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
714
715 for tool in TOOL_PREFIXES {
716 let Some(after_tool) = after_open.strip_prefix(tool) else {
717 continue;
718 };
719 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
720 break;
721 };
722 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
725 break;
726 }
727 let Some(close_offset) = after_kw.find("-->") else {
728 break;
729 };
730
731 let json_str = after_kw[..close_offset].trim();
732 if !json_str.is_empty()
733 && let Ok(value) = serde_json::from_str(json_str)
734 {
735 found.push((comment_start, value));
736 }
737 pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
738 break;
739 }
740 }
741
742 found
743}
744
745pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
749 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum DisableScope {
757 Line(usize),
759 Block(usize),
761 File,
763}
764
765impl DisableScope {
766 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
778 match (self, layer) {
779 (Self::Line(target), DisableLayer::Line) => target == line,
780 (Self::Block(start), DisableLayer::Block) => start < line,
782 (Self::File, DisableLayer::File) => true,
783 _ => false,
784 }
785 }
786}
787
788#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct DisableSite {
791 pub line: usize,
793 pub span: std::ops::Range<usize>,
795 pub kind: &'static str,
797 pub rules: Vec<String>,
799 pub scope: DisableScope,
801}
802
803pub fn collect_disable_sites(content: &str) -> Vec<DisableSite> {
809 if !has_inline_config_markers(content) {
810 return Vec::new();
811 }
812
813 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
814
815 let line_spans: Vec<(usize, &str)> = {
818 let mut spans = Vec::new();
819 let mut start = 0;
820 for line in content.split('\n') {
821 spans.push((start, line));
822 start += line.len() + 1;
823 }
824 spans
825 };
826
827 let mut sites = Vec::new();
828
829 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
830 if line_in_code_block(line_start, line, &code_blocks) {
831 continue;
832 }
833 let line_num = idx + 1;
834 for directive in parse_inline_directives(line) {
835 let (kind, scope) = match directive.kind {
836 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
837 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
838 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
839 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
840 DirectiveKind::Enable
841 | DirectiveKind::EnableFile
842 | DirectiveKind::Capture
843 | DirectiveKind::Restore
844 | DirectiveKind::ConfigureFile => continue,
845 };
846 sites.push(DisableSite {
847 line: line_num,
848 span: directive.span,
849 kind,
850 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
851 scope,
852 });
853 }
854 }
855
856 for (offset, json_config) in scan_configure_file_comments(content) {
860 if offset_in_code_block(offset, &code_blocks) {
861 continue;
862 }
863 let Some(obj) = json_config.as_object() else {
864 continue;
865 };
866 let rules: Vec<String> = obj
867 .iter()
868 .filter(|(_, value)| value.as_bool() == Some(false))
869 .map(|(name, _)| name.clone())
870 .collect();
871 if rules.is_empty() {
872 continue;
873 }
874 let line_num = line_of_offset(content, offset);
875 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
876 continue;
877 };
878 let comment_end = content[offset..]
879 .find("-->")
880 .map_or(content.len(), |close| offset + close + 3);
881 sites.push(DisableSite {
882 line: line_num,
883 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
884 kind: "configure-file",
885 rules,
886 scope: DisableScope::File,
887 });
888 }
889
890 sites.sort_by_key(|site| (site.line, site.span.start));
891 sites
892}
893
894#[derive(Debug, Clone, PartialEq, Eq)]
896pub enum InlineConfigProblem {
897 UnknownRule,
899 UnknownOption { key: String },
902 EnableHasNoEffect { reason: EnableNoEffectReason },
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
913pub enum EnableNoEffectReason {
914 NotEnabled,
916 IgnoredForFile,
919}
920
921#[derive(Debug, Clone, PartialEq, Eq)]
923pub struct InlineConfigWarning {
924 pub line_number: usize,
926 pub rule_name: String,
928 pub comment_type: String,
930 pub suggestion: Option<String>,
932 pub problem: InlineConfigProblem,
934}
935
936impl InlineConfigWarning {
937 pub fn format_message(&self) -> String {
939 match &self.problem {
942 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
943 Some(ref suggestion) => format!(
944 "Unknown option for rule {}: {} (did you mean: {}?)",
945 self.rule_name, key, suggestion
946 ),
947 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
948 },
949 InlineConfigProblem::UnknownRule => match self.suggestion {
950 Some(ref suggestion) => format!(
951 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
952 self.comment_type, self.rule_name, suggestion
953 ),
954 None => format!(
955 "Unknown rule in inline {} comment: {}",
956 self.comment_type, self.rule_name
957 ),
958 },
959 InlineConfigProblem::EnableHasNoEffect {
960 reason: EnableNoEffectReason::NotEnabled,
961 } => format!(
962 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
963 self.rule_name, self.comment_type
964 ),
965 InlineConfigProblem::EnableHasNoEffect {
966 reason: EnableNoEffectReason::IgnoredForFile,
967 } => format!(
968 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
969 self.rule_name, self.comment_type
970 ),
971 }
972 }
973
974 pub fn print_warning(&self, file_path: &str) {
976 eprintln!(
977 "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
978 file_path,
979 self.line_number,
980 self.format_message()
981 );
982 }
983
984 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
992 let line_width = content
993 .lines()
994 .nth(self.line_number.saturating_sub(1))
995 .map_or(0, |line| line.chars().count());
996 crate::rule::LintWarning {
997 message: self.format_message(),
998 line: self.line_number,
999 column: 1,
1000 end_line: self.line_number,
1001 end_column: line_width + 1,
1002 severity: crate::rule::Severity::Warning,
1003 fix: None,
1004 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1005 }
1006 }
1007}
1008
1009pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1014
1015pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
1023 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1024
1025 let mut warnings = Vec::new();
1026 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
1027
1028 let suggest = |rule_name: &str| {
1029 suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1030 };
1031
1032 let registry = crate::config::default_registry();
1036 for (offset, json_config) in scan_configure_file_comments(content) {
1037 let Some(obj) = json_config.as_object() else {
1038 continue;
1039 };
1040 let line_number = line_of_offset(content, offset);
1041 for (rule_name, rule_config) in obj {
1042 if !is_valid_rule_name(rule_name) {
1043 warnings.push(InlineConfigWarning {
1044 line_number,
1045 rule_name: rule_name.clone(),
1046 comment_type: "configure-file".to_string(),
1047 suggestion: suggest(rule_name),
1048 problem: InlineConfigProblem::UnknownRule,
1049 });
1050 continue;
1053 }
1054 let Some(options) = rule_config.as_object() else {
1056 continue;
1057 };
1058 let canonical = normalize_rule_name(rule_name);
1059 let Some(valid_keys) = registry.config_keys_for(&canonical) else {
1060 continue;
1061 };
1062 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1063 for key in options.keys() {
1064 if !valid_keys.contains(key) {
1065 warnings.push(InlineConfigWarning {
1066 line_number,
1067 rule_name: canonical.clone(),
1068 comment_type: "configure-file".to_string(),
1069 suggestion: suggest_similar_key(key, &valid_keys_vec),
1070 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1071 });
1072 }
1073 }
1074 }
1075 }
1076
1077 for (idx, line) in content.lines().enumerate() {
1078 let line_num = idx + 1;
1079
1080 let directives = parse_inline_directives(line);
1082 let mut rule_entries: Vec<(&str, &str)> = Vec::new();
1083
1084 for directive in &directives {
1085 let comment_type = match directive.kind {
1086 DirectiveKind::Disable => "disable",
1087 DirectiveKind::Enable => "enable",
1088 DirectiveKind::DisableLine => "disable-line",
1089 DirectiveKind::DisableNextLine => "disable-next-line",
1090 DirectiveKind::DisableFile => "disable-file",
1091 DirectiveKind::EnableFile => "enable-file",
1092 DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
1094 };
1095 for rule in &directive.rules {
1096 rule_entries.push((rule, comment_type));
1097 }
1098 }
1099
1100 for (rule_name, comment_type) in rule_entries {
1102 if !is_valid_rule_name(rule_name) {
1103 warnings.push(InlineConfigWarning {
1104 line_number: line_num,
1105 rule_name: rule_name.to_string(),
1106 comment_type: comment_type.to_string(),
1107 suggestion: suggest(rule_name),
1108 problem: InlineConfigProblem::UnknownRule,
1109 });
1110 }
1111 }
1112 }
1113
1114 warnings.sort_by_key(|w| w.line_number);
1117 drop_warnings_inside_code_blocks(content, &mut warnings);
1118 warnings
1119}
1120
1121pub fn validate_inline_enables_against_active_rules(
1138 content: &str,
1139 active_rules: &HashSet<String>,
1140 ignored_for_file: &HashSet<String>,
1141) -> Vec<InlineConfigWarning> {
1142 use crate::config::is_valid_rule_name;
1143
1144 let mut warnings = Vec::new();
1145
1146 let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
1147 if !is_valid_rule_name(name) {
1149 return;
1150 }
1151 let canonical = normalize_rule_name(name);
1152 let reason = if !active_rules.contains(&canonical) {
1156 EnableNoEffectReason::NotEnabled
1157 } else if ignored_for_file.contains(&canonical) {
1158 EnableNoEffectReason::IgnoredForFile
1159 } else {
1160 return;
1161 };
1162 warnings.push(InlineConfigWarning {
1163 line_number: line,
1164 rule_name: canonical,
1165 comment_type: comment_type.to_string(),
1166 suggestion: None,
1167 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1168 });
1169 };
1170
1171 for (offset, json_config) in scan_configure_file_comments(content) {
1173 let Some(obj) = json_config.as_object() else {
1174 continue;
1175 };
1176 let line = line_of_offset(content, offset);
1177 for (rule_name, rule_config) in obj {
1178 if rule_config.as_bool() == Some(true) {
1181 flag(&mut warnings, rule_name, "configure-file", line);
1182 }
1183 }
1184 }
1185
1186 for (idx, line) in content.lines().enumerate() {
1188 for directive in parse_inline_directives(line) {
1189 let comment_type = match directive.kind {
1190 DirectiveKind::Enable => "enable",
1191 DirectiveKind::EnableFile => "enable-file",
1192 _ => continue,
1193 };
1194 for rule in &directive.rules {
1195 flag(&mut warnings, rule, comment_type, idx + 1);
1196 }
1197 }
1198 }
1199
1200 warnings.sort_by_key(|w| w.line_number);
1201 drop_warnings_inside_code_blocks(content, &mut warnings);
1202 warnings
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 #[test]
1212 fn test_parse_inline_directives_all_kinds() {
1213 let cases: &[(&str, DirectiveKind)] = &[
1215 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
1216 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
1217 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
1218 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
1219 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
1220 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
1221 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
1222 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
1223 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
1224 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
1226 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
1227 (
1228 "<!-- markdownlint-disable-next-line -->",
1229 DirectiveKind::DisableNextLine,
1230 ),
1231 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
1232 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
1233 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
1234 ];
1235 for (input, expected_kind) in cases {
1236 let directives = parse_inline_directives(input);
1237 assert_eq!(
1238 directives.len(),
1239 1,
1240 "Expected 1 directive for {input:?}, got {directives:?}"
1241 );
1242 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
1243 }
1244 }
1245
1246 #[test]
1247 fn test_parse_inline_directives_disambiguation() {
1248 let line = "<!-- rumdl-disable-line MD001 -->";
1250 let directives = parse_inline_directives(line);
1251 assert_eq!(directives.len(), 1);
1252 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
1253
1254 let line = "<!-- rumdl-disable-next-line -->";
1255 let directives = parse_inline_directives(line);
1256 assert_eq!(directives.len(), 1);
1257 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
1258
1259 let line = "<!-- rumdl-disable-file MD001 -->";
1260 let directives = parse_inline_directives(line);
1261 assert_eq!(directives.len(), 1);
1262 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
1263
1264 let line = "<!-- rumdl-enable-file -->";
1265 let directives = parse_inline_directives(line);
1266 assert_eq!(directives.len(), 1);
1267 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
1268 }
1269
1270 #[test]
1271 fn test_parse_inline_directives_no_space_before_close() {
1272 let directives = parse_inline_directives("<!-- rumdl-disable-->");
1274 assert_eq!(directives.len(), 1);
1275 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1276 assert!(directives[0].rules.is_empty());
1277
1278 let directives = parse_inline_directives("<!-- rumdl-enable-->");
1279 assert_eq!(directives.len(), 1);
1280 assert_eq!(directives[0].kind, DirectiveKind::Enable);
1281 }
1282
1283 #[test]
1284 fn test_parse_inline_directives_multiple_on_one_line() {
1285 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1286 let directives = parse_inline_directives(line);
1287 assert_eq!(directives.len(), 2);
1288 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1289 assert_eq!(directives[0].rules, vec!["MD001"]);
1290 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1291 assert_eq!(directives[1].rules, vec!["MD001"]);
1292 }
1293
1294 #[test]
1295 fn test_parse_inline_directives_global_disable_then_specific_enable() {
1296 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1297 let directives = parse_inline_directives(line);
1298 assert_eq!(directives.len(), 2);
1299 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1300 assert!(directives[0].rules.is_empty());
1301 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1302 assert_eq!(directives[1].rules, vec!["MD001"]);
1303 }
1304
1305 #[test]
1306 fn test_parse_inline_directives_word_boundary() {
1307 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1309 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1311 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1313 }
1314
1315 #[test]
1316 fn test_parse_inline_directives_no_closing_tag() {
1317 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1319 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1320 }
1321
1322 #[test]
1323 fn test_parse_inline_directives_not_a_comment() {
1324 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1325 assert!(parse_inline_directives("Some regular text").is_empty());
1326 assert!(parse_inline_directives("").is_empty());
1327 }
1328
1329 #[test]
1330 fn test_parse_inline_directives_case_sensitive() {
1331 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1332 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1333 }
1334
1335 #[test]
1336 fn test_parse_inline_directives_rules_extraction() {
1337 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1338 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1339
1340 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1342 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1343
1344 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
1346 assert_eq!(directives[0].rules, vec!["MD001"]);
1347 }
1348
1349 #[test]
1350 fn test_parse_inline_directives_embedded_in_text() {
1351 let line = "Some text <!-- rumdl-disable MD001 --> more text";
1352 let directives = parse_inline_directives(line);
1353 assert_eq!(directives.len(), 1);
1354 assert_eq!(directives[0].rules, vec!["MD001"]);
1355
1356 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1357 let directives = parse_inline_directives(line);
1358 assert_eq!(directives.len(), 1);
1359 assert_eq!(directives[0].rules, vec!["MD001"]);
1360 }
1361
1362 #[test]
1363 fn test_parse_inline_directives_mixed_tools_same_line() {
1364 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1365 let directives = parse_inline_directives(line);
1366 assert_eq!(directives.len(), 2);
1367 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1368 assert_eq!(directives[0].rules, vec!["MD001"]);
1369 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1370 assert_eq!(directives[1].rules, vec!["MD002"]);
1371 }
1372
1373 #[test]
1376 fn test_parse_disable_comment() {
1377 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1379 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1380
1381 assert_eq!(
1383 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1384 Some(vec!["MD001", "MD002"])
1385 );
1386
1387 assert_eq!(parse_disable_comment("Some regular text"), None);
1389 }
1390
1391 #[test]
1392 fn test_parse_disable_line_comment() {
1393 assert_eq!(
1395 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1396 Some(vec![])
1397 );
1398
1399 assert_eq!(
1401 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1402 Some(vec!["MD013"])
1403 );
1404
1405 assert_eq!(parse_disable_line_comment("Some regular text"), None);
1407 }
1408
1409 #[test]
1410 fn test_inline_config_from_content() {
1411 let content = r#"# Test Document
1412
1413<!-- markdownlint-disable MD013 -->
1414This is a very long line that would normally trigger MD013 but it's disabled
1415
1416<!-- markdownlint-enable MD013 -->
1417This line will be checked again
1418
1419<!-- markdownlint-disable-next-line MD001 -->
1420# This heading will not be checked for MD001
1421## But this one will
1422
1423Some text <!-- markdownlint-disable-line MD013 -->
1424
1425<!-- markdownlint-capture -->
1426<!-- markdownlint-disable MD001 MD002 -->
1427# Heading with MD001 disabled
1428<!-- markdownlint-restore -->
1429# Heading with MD001 enabled again
1430"#;
1431
1432 let config = InlineConfig::from_content(content);
1433
1434 assert!(config.is_rule_disabled("MD013", 4));
1436
1437 assert!(!config.is_rule_disabled("MD013", 7));
1439
1440 assert!(config.is_rule_disabled("MD001", 10));
1442
1443 assert!(!config.is_rule_disabled("MD001", 11));
1445
1446 assert!(config.is_rule_disabled("MD013", 13));
1448
1449 assert!(!config.is_rule_disabled("MD001", 19));
1451 }
1452
1453 #[test]
1454 fn test_capture_restore() {
1455 let content = r#"<!-- markdownlint-disable MD001 -->
1456<!-- markdownlint-capture -->
1457<!-- markdownlint-disable MD002 MD003 -->
1458<!-- markdownlint-restore -->
1459Some content after restore
1460"#;
1461
1462 let config = InlineConfig::from_content(content);
1463
1464 assert!(config.is_rule_disabled("MD001", 5));
1466 assert!(!config.is_rule_disabled("MD002", 5));
1467 assert!(!config.is_rule_disabled("MD003", 5));
1468 }
1469
1470 #[test]
1471 fn test_validate_inline_config_rules_unknown_rule() {
1472 let content = "<!-- rumdl-disable abc -->\nSome content";
1473 let warnings = validate_inline_config_rules(content);
1474 assert_eq!(warnings.len(), 1);
1475 assert_eq!(warnings[0].line_number, 1);
1476 assert_eq!(warnings[0].rule_name, "abc");
1477 assert_eq!(warnings[0].comment_type, "disable");
1478 }
1479
1480 #[test]
1481 fn test_validate_inline_config_rules_valid_rule() {
1482 let content = "<!-- rumdl-disable MD001 -->\nSome content";
1483 let warnings = validate_inline_config_rules(content);
1484 assert!(
1485 warnings.is_empty(),
1486 "MD001 is a valid rule, should not produce warnings"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_validate_inline_config_rules_alias() {
1492 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1493 let warnings = validate_inline_config_rules(content);
1494 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1495 }
1496
1497 #[test]
1498 fn test_validate_inline_config_rules_multiple_unknown() {
1499 let content = r#"<!-- rumdl-disable abc xyz -->
1500<!-- rumdl-disable-line foo -->
1501<!-- markdownlint-disable-next-line bar -->
1502"#;
1503 let warnings = validate_inline_config_rules(content);
1504 assert_eq!(warnings.len(), 4);
1505 assert_eq!(warnings[0].rule_name, "abc");
1506 assert_eq!(warnings[1].rule_name, "xyz");
1507 assert_eq!(warnings[2].rule_name, "foo");
1508 assert_eq!(warnings[3].rule_name, "bar");
1509 }
1510
1511 #[test]
1512 fn test_validate_inline_config_rules_suggestion() {
1513 let content = "<!-- rumdl-disable MD00 -->\n";
1515 let warnings = validate_inline_config_rules(content);
1516 assert_eq!(warnings.len(), 1);
1517 assert!(warnings[0].suggestion.is_some());
1519 }
1520
1521 #[test]
1522 fn test_validate_inline_config_rules_file_comments() {
1523 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1524 let warnings = validate_inline_config_rules(content);
1525 assert_eq!(warnings.len(), 2);
1526 assert_eq!(warnings[0].comment_type, "disable-file");
1527 assert_eq!(warnings[1].comment_type, "enable-file");
1528 }
1529
1530 #[test]
1531 fn test_validate_inline_config_rules_global_disable() {
1532 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1534 let warnings = validate_inline_config_rules(content);
1535 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1536 }
1537
1538 #[test]
1539 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1540 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1542 let warnings = validate_inline_config_rules(content);
1543 assert_eq!(warnings.len(), 2);
1544 assert_eq!(warnings[0].rule_name, "abc");
1545 assert_eq!(warnings[1].rule_name, "xyz");
1546 }
1547
1548 #[test]
1549 fn test_validate_inline_config_rules_configure_file() {
1550 let content =
1552 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1553 let warnings = validate_inline_config_rules(content);
1554 assert_eq!(warnings.len(), 1);
1555 assert_eq!(warnings[0].rule_name, "nonexistent");
1556 assert_eq!(warnings[0].comment_type, "configure-file");
1557 }
1558
1559 #[test]
1560 fn test_validate_inline_config_rules_markdownlint_variants() {
1561 let content = r#"<!-- markdownlint-disable unknown_rule -->
1563<!-- markdownlint-enable another_fake -->
1564<!-- markdownlint-disable-line bad_rule -->
1565<!-- markdownlint-disable-next-line fake_rule -->
1566<!-- markdownlint-disable-file missing_rule -->
1567<!-- markdownlint-enable-file nonexistent -->
1568"#;
1569 let warnings = validate_inline_config_rules(content);
1570 assert_eq!(warnings.len(), 6);
1571 assert_eq!(warnings[0].rule_name, "unknown_rule");
1572 assert_eq!(warnings[1].rule_name, "another_fake");
1573 assert_eq!(warnings[2].rule_name, "bad_rule");
1574 assert_eq!(warnings[3].rule_name, "fake_rule");
1575 assert_eq!(warnings[4].rule_name, "missing_rule");
1576 assert_eq!(warnings[5].rule_name, "nonexistent");
1577 }
1578
1579 #[test]
1583 fn test_validate_inline_config_rules_ignores_code_blocks() {
1584 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
1585 assert!(
1586 validate_inline_config_rules(fenced).is_empty(),
1587 "a directive inside a fence is documentation, not configuration"
1588 );
1589
1590 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
1591 assert!(validate_inline_config_rules(tilde).is_empty());
1592
1593 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
1594 assert!(validate_inline_config_rules(indented).is_empty());
1595
1596 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
1597 assert_eq!(validate_inline_config_rules(outside).len(), 1);
1598 }
1599
1600 #[test]
1603 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
1604 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
1605 assert!(validate_inline_config_rules(fenced).is_empty());
1606
1607 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
1608 assert!(validate_inline_config_rules(multiline).is_empty());
1609
1610 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
1611 assert_eq!(validate_inline_config_rules(outside).len(), 1);
1612 }
1613
1614 #[test]
1615 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1616 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1617 let warnings = validate_inline_config_rules(content);
1618 assert_eq!(warnings.len(), 1);
1619 assert_eq!(warnings[0].rule_name, "fake_rule");
1620 assert_eq!(warnings[0].comment_type, "configure-file");
1621 }
1622
1623 #[test]
1624 fn test_get_rule_config_from_configure_file() {
1625 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1626
1627This is a test line."#;
1628
1629 let inline_config = InlineConfig::from_content(content);
1630 let config_override = inline_config.get_rule_config("MD013");
1631
1632 assert!(config_override.is_some(), "MD013 config should be found");
1633 let json = config_override.unwrap();
1634 assert!(json.is_object(), "Config should be an object");
1635 let obj = json.as_object().unwrap();
1636 assert!(obj.contains_key("line_length"), "Should have line_length key");
1637 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1638 }
1639
1640 #[test]
1641 fn test_get_rule_config_tables_false() {
1642 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1644
1645 let inline_config = InlineConfig::from_content(content);
1646 let config_override = inline_config.get_rule_config("MD013");
1647
1648 assert!(config_override.is_some(), "MD013 config should be found");
1649 let json = config_override.unwrap();
1650 let obj = json.as_object().unwrap();
1651 assert!(obj.contains_key("tables"), "Should have tables key");
1652 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1653 }
1654
1655 fn active_set(names: &[&str]) -> HashSet<String> {
1669 names.iter().map(|s| (*s).to_string()).collect()
1670 }
1671
1672 fn no_ignores() -> HashSet<String> {
1673 HashSet::new()
1674 }
1675
1676 #[test]
1677 fn test_inline_enable_of_inactive_rule_warns() {
1678 let active = active_set(&["MD013", "MD022"]);
1679 let content = "<!-- rumdl-enable MD012 -->\n";
1680
1681 let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1682
1683 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1684 assert_eq!(warnings[0].rule_name, "MD012");
1685 assert_eq!(warnings[0].comment_type, "enable");
1686 assert_eq!(
1687 warnings[0].problem,
1688 InlineConfigProblem::EnableHasNoEffect {
1689 reason: EnableNoEffectReason::NotEnabled
1690 }
1691 );
1692 assert_eq!(warnings[0].line_number, 1);
1693 }
1694
1695 #[test]
1696 fn test_inline_enable_of_active_rule_does_not_warn() {
1697 let active = active_set(&["MD012", "MD013"]);
1700 let content = "<!-- rumdl-enable MD012 -->\n";
1701
1702 let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1703
1704 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1705 }
1706
1707 #[test]
1708 fn test_inline_enable_of_per_file_ignored_rule_warns() {
1709 let active = active_set(&["MD012", "MD013"]);
1712 let ignored = active_set(&["MD012"]);
1713 let content = "<!-- rumdl-enable MD012 -->\n";
1714
1715 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1716
1717 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1718 assert_eq!(warnings[0].rule_name, "MD012");
1719 assert_eq!(
1720 warnings[0].problem,
1721 InlineConfigProblem::EnableHasNoEffect {
1722 reason: EnableNoEffectReason::IgnoredForFile
1723 }
1724 );
1725 assert!(
1726 warnings[0].format_message().contains("per-file-ignores"),
1727 "the message must name the setting in play: {}",
1728 warnings[0].format_message()
1729 );
1730 }
1731
1732 #[test]
1733 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
1734 let active = active_set(&["MD013"]);
1737 let ignored = active_set(&["MD012"]);
1738 let content = "<!-- rumdl-enable MD012 -->\n";
1739
1740 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1741
1742 assert_eq!(warnings.len(), 1, "{warnings:?}");
1743 assert_eq!(
1744 warnings[0].problem,
1745 InlineConfigProblem::EnableHasNoEffect {
1746 reason: EnableNoEffectReason::NotEnabled
1747 }
1748 );
1749 }
1750
1751 #[test]
1752 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
1753 let active = active_set(&["MD012", "MD013"]);
1756 let ignored = active_set(&["MD013"]);
1757 let content = "<!-- rumdl-enable MD012 -->\n";
1758
1759 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1760
1761 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
1762 }
1763
1764 #[test]
1765 fn test_enable_inside_code_block_does_not_warn() {
1766 let active = active_set(&["MD012", "MD013"]);
1769 let ignored = active_set(&["MD012"]);
1770
1771 for content in [
1772 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
1773 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
1774 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
1775 ] {
1776 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1777 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
1778 }
1779
1780 for content in [
1781 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
1782 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
1783 ] {
1784 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1785 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
1786 }
1787 }
1788
1789 #[test]
1790 fn test_bare_enable_all_does_not_warn() {
1791 let active = active_set(&["MD013"]);
1793 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1794 let warnings = validate_inline_enables_against_active_rules(content, &active, &active_set(&["MD013"]));
1795 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1796 }
1797 }
1798
1799 #[test]
1800 fn test_enable_file_and_alias_of_inactive_rule_warn() {
1801 let active = active_set(&["MD013"]);
1802 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1804
1805 let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1806
1807 assert_eq!(warnings.len(), 1, "{warnings:?}");
1808 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1809 assert_eq!(warnings[0].comment_type, "enable-file");
1810 }
1811
1812 #[test]
1813 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1814 let active = active_set(&["MD013"]);
1815 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1816 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1817
1818 let warn_true = validate_inline_enables_against_active_rules(enable, &active, &no_ignores());
1819 let warn_false = validate_inline_enables_against_active_rules(disable, &active, &no_ignores());
1820
1821 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1822 assert_eq!(warn_true[0].rule_name, "MD012");
1823 assert!(
1824 warn_false.is_empty(),
1825 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1831 let active = active_set(&["MD013"]);
1834 let content = "<!-- rumdl-enable NotARule -->\n";
1835
1836 let warnings = validate_inline_enables_against_active_rules(content, &active, &no_ignores());
1837
1838 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1839 }
1840
1841 #[test]
1842 fn test_disable_directive_never_warns_as_no_effect() {
1843 let active = active_set(&["MD012", "MD013"]);
1846 let ignored = active_set(&["MD012"]);
1847 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1848
1849 let warnings = validate_inline_enables_against_active_rules(content, &active, &ignored);
1850
1851 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1852 }
1853
1854 #[test]
1860 fn test_unknown_option_key_in_configure_file_warns() {
1861 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
1862
1863 let warnings = validate_inline_config_rules(content);
1864
1865 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1866 assert_eq!(warnings[0].rule_name, "MD013");
1867 assert_eq!(
1868 warnings[0].problem,
1869 InlineConfigProblem::UnknownOption {
1870 key: "line_lenght".to_string()
1871 }
1872 );
1873 assert!(
1874 warnings[0].suggestion.is_some(),
1875 "a near-miss key should suggest the real one"
1876 );
1877 assert!(
1878 warnings[0].format_message().contains("Unknown option for rule MD013"),
1879 "message was: {}",
1880 warnings[0].format_message()
1881 );
1882 }
1883
1884 #[test]
1885 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
1886 for content in [
1889 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
1890 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
1891 ] {
1892 let warnings = validate_inline_config_rules(content);
1893 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
1894 }
1895 }
1896
1897 #[test]
1898 fn test_unknown_rule_does_not_also_warn_about_its_options() {
1899 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
1902
1903 let warnings = validate_inline_config_rules(content);
1904
1905 assert_eq!(
1906 warnings.len(),
1907 1,
1908 "expected only the unknown-rule warning: {warnings:?}"
1909 );
1910 assert_eq!(warnings[0].rule_name, "nonexistent");
1911 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
1912 }
1913
1914 #[test]
1915 fn test_boolean_rule_value_has_no_option_warnings() {
1916 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
1917
1918 let warnings = validate_inline_config_rules(content);
1919
1920 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
1921 }
1922
1923 #[test]
1924 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
1925 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
1926
1927 let warnings = validate_inline_config_rules(content);
1928
1929 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1930 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
1931 }
1932
1933 #[test]
1934 fn test_configure_file_spanning_multiple_lines_applies() {
1935 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
1936
1937 let inline_config = InlineConfig::from_content(content);
1938 let config_override = inline_config.get_rule_config("MD013");
1939
1940 assert!(config_override.is_some(), "a multi-line configure-file must apply");
1941 let obj = config_override.unwrap().as_object().unwrap();
1942 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
1943 }
1944
1945 #[test]
1946 fn test_every_configure_file_comment_applies_not_just_the_first() {
1947 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";
1950
1951 let inline_config = InlineConfig::from_content(content);
1952
1953 assert!(
1954 inline_config.get_rule_config("MD013").is_some(),
1955 "first (single-line) configure-file dropped"
1956 );
1957 assert!(
1958 inline_config.get_rule_config("MD007").is_some(),
1959 "second (multi-line) configure-file dropped"
1960 );
1961 }
1962
1963 #[test]
1964 fn test_multiline_configure_file_in_code_block_is_ignored() {
1965 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
1968
1969 let inline_config = InlineConfig::from_content(content);
1970
1971 assert!(
1972 inline_config.get_rule_config("MD013").is_none(),
1973 "configure-file inside a fenced code block must not apply"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_multiline_configure_file_bool_and_alias_still_honored() {
1979 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
1982
1983 let inline_config = InlineConfig::from_content(content);
1984
1985 assert!(
1986 inline_config.is_rule_disabled("MD012", 1),
1987 "boolean alias key must disable the rule"
1988 );
1989 let obj = inline_config
1990 .get_rule_config("MD013")
1991 .expect("alias-keyed config should resolve to MD013")
1992 .as_object()
1993 .unwrap();
1994 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
1995 }
1996
1997 #[test]
1998 fn test_multiline_configure_file_warning_reports_start_line() {
1999 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2002
2003 let warnings = validate_inline_config_rules(content);
2004
2005 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2006 assert_eq!(warnings[0].rule_name, "nonexistent");
2007 assert_eq!(warnings[0].comment_type, "configure-file");
2008 assert_eq!(
2009 warnings[0].line_number, 3,
2010 "warning must point at the line the comment starts on"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_configure_file_bool_false_disables_rule() {
2016 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2019
2020 let inline_config = InlineConfig::from_content(content);
2021
2022 assert!(inline_config.is_rule_disabled("MD012", 1));
2023 assert!(
2024 inline_config.get_rule_config("MD012").is_none(),
2025 "a boolean should not be stored as rule options"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_configure_file_bool_false_disables_rule_by_alias() {
2031 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2032
2033 let inline_config = InlineConfig::from_content(content);
2034
2035 assert!(inline_config.is_rule_disabled("MD012", 1));
2036 }
2037
2038 #[test]
2039 fn test_configure_file_bool_true_leaves_rule_enabled() {
2040 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2041
2042 let inline_config = InlineConfig::from_content(content);
2043
2044 assert!(!inline_config.is_rule_disabled("MD012", 1));
2045 }
2046
2047 #[test]
2048 fn test_get_rule_config_from_configure_file_alias_key() {
2049 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2051
2052 let inline_config = InlineConfig::from_content(content);
2053 let config_override = inline_config.get_rule_config("MD013");
2054
2055 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2056 let obj = config_override.unwrap().as_object().unwrap();
2057 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2058 }
2059
2060 #[test]
2063 fn test_parse_disable_does_not_match_disable_line() {
2064 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2066 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2067 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2068 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2069 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2070 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2071 }
2072
2073 #[test]
2074 fn test_parse_enable_does_not_match_enable_file() {
2075 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2076 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2077 }
2078
2079 #[test]
2080 fn test_parse_disable_comment_edge_cases() {
2081 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2083
2084 assert_eq!(
2086 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2087 Some(vec!["MD001", "MD002"])
2088 );
2089
2090 assert_eq!(
2092 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2093 Some(vec!["MD001"])
2094 );
2095
2096 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2098
2099 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2101
2102 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2104
2105 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2107
2108 assert_eq!(
2110 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2111 Some(vec!["MD001", "MD001", "MD002"])
2112 );
2113
2114 assert_eq!(
2116 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
2117 Some(vec!["MD001"])
2118 );
2119
2120 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
2122 let comment = format!("<!-- rumdl-disable {many_rules} -->");
2123 let parsed = parse_disable_comment(&comment);
2124 assert!(parsed.is_some());
2125 assert_eq!(parsed.unwrap().len(), 100);
2126
2127 assert_eq!(
2129 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
2130 Some(vec!["MD001-test"])
2131 );
2132 assert_eq!(
2133 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
2134 Some(vec!["custom_rule"])
2135 );
2136 }
2137
2138 #[test]
2139 fn test_parse_enable_comment_edge_cases() {
2140 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
2141 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
2142 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
2143 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
2144 }
2145
2146 #[test]
2149 fn test_disable_inside_fenced_code_block_ignored() {
2150 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
2151 let config = InlineConfig::from_content(content);
2152 assert!(!config.is_rule_disabled("MD001", 6));
2154 }
2155
2156 #[test]
2157 fn test_disable_inside_tilde_fence_ignored() {
2158 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
2159 let config = InlineConfig::from_content(content);
2160 assert!(!config.is_rule_disabled("MD001", 6));
2161 }
2162
2163 #[test]
2164 fn test_disable_before_code_block_persists_after() {
2165 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
2167 let config = InlineConfig::from_content(content);
2168 assert!(config.is_rule_disabled("MD001", 5));
2169 }
2170
2171 #[test]
2172 fn test_enable_inside_code_block_ignored() {
2173 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
2175 let config = InlineConfig::from_content(content);
2176 assert!(config.is_rule_disabled("MD001", 5));
2177 }
2178
2179 #[test]
2180 fn test_disable_inside_indented_code_block_ignored() {
2181 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
2185 let config = InlineConfig::from_content(content);
2186 assert!(!config.is_rule_disabled("MD001", 5));
2187
2188 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
2190 let config = InlineConfig::from_content(unindented);
2191 assert!(config.is_rule_disabled("MD001", 5));
2192 }
2193
2194 #[test]
2195 fn test_configure_file_inside_indented_code_block_ignored() {
2196 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2199 let config = InlineConfig::from_content(content);
2200 assert!(config.get_rule_config("MD013").is_none());
2201
2202 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2203 let config = InlineConfig::from_content(unindented);
2204 assert!(config.get_rule_config("MD013").is_some());
2205 }
2206
2207 #[test]
2210 fn test_markdownlint_disable_rumdl_enable_interop() {
2211 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
2212 let config = InlineConfig::from_content(content);
2213 assert!(config.is_rule_disabled("MD001", 2));
2214 assert!(!config.is_rule_disabled("MD001", 4));
2215 }
2216
2217 #[test]
2218 fn test_rumdl_disable_markdownlint_enable_interop() {
2219 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
2220 let config = InlineConfig::from_content(content);
2221 assert!(config.is_rule_disabled("MD013", 2));
2222 assert!(!config.is_rule_disabled("MD013", 4));
2223 }
2224
2225 #[test]
2228 fn test_global_disable_then_specific_enable() {
2229 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
2230 let config = InlineConfig::from_content(content);
2231 assert!(!config.is_rule_disabled("MD001", 4));
2232 assert!(config.is_rule_disabled("MD002", 4));
2233 assert!(config.is_rule_disabled("MD013", 4));
2234 }
2235
2236 #[test]
2237 fn test_specific_disable_then_global_enable() {
2238 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
2239 let config = InlineConfig::from_content(content);
2240 assert!(config.is_rule_disabled("MD001", 2));
2241 assert!(config.is_rule_disabled("MD002", 2));
2242 assert!(!config.is_rule_disabled("MD001", 4));
2243 assert!(!config.is_rule_disabled("MD002", 4));
2244 }
2245
2246 #[test]
2247 fn test_multiple_rules_disable_enable_independently() {
2248 let content = "\
2249Line 1\n\
2250<!-- rumdl-disable MD001 MD002 -->\n\
2251Line 3\n\
2252<!-- rumdl-enable MD001 -->\n\
2253Line 5\n\
2254<!-- rumdl-disable -->\n\
2255Line 7\n\
2256<!-- rumdl-enable MD002 -->\n\
2257Line 9\n";
2258 let config = InlineConfig::from_content(content);
2259
2260 assert!(!config.is_rule_disabled("MD001", 1));
2262 assert!(!config.is_rule_disabled("MD002", 1));
2263
2264 assert!(config.is_rule_disabled("MD001", 3));
2266 assert!(config.is_rule_disabled("MD002", 3));
2267
2268 assert!(!config.is_rule_disabled("MD001", 5));
2270 assert!(config.is_rule_disabled("MD002", 5));
2271
2272 assert!(config.is_rule_disabled("MD001", 7));
2274 assert!(config.is_rule_disabled("MD002", 7));
2275
2276 assert!(config.is_rule_disabled("MD001", 9));
2278 assert!(!config.is_rule_disabled("MD002", 9));
2279 }
2280
2281 #[test]
2284 fn test_empty_content() {
2285 let config = InlineConfig::from_content("");
2286 assert!(!config.is_rule_disabled("MD001", 1));
2287 }
2288
2289 #[test]
2290 fn test_single_disable_comment_only() {
2291 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
2294 assert!(!config.is_rule_disabled("MD001", 1));
2295 assert!(config.is_rule_disabled("MD001", 2));
2296 assert!(config.is_rule_disabled("MD999", 2));
2297
2298 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
2300 assert!(!config.is_rule_disabled("MD001", 1));
2301 assert!(config.is_rule_disabled("MD001", 2));
2302 assert!(config.is_rule_disabled("MD001", 3));
2303 }
2304
2305 #[test]
2306 fn test_no_inline_markers() {
2307 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
2308 assert!(!config.is_rule_disabled("MD001", 1));
2309 assert!(!config.is_rule_disabled("MD001", 5));
2310 }
2311
2312 #[test]
2315 fn test_export_for_file_index_persistent_transitions() {
2316 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
2317 let config = InlineConfig::from_content(content);
2318 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
2319
2320 assert!(file_disabled.is_empty());
2321 assert!(
2323 persistent.len() >= 2,
2324 "Expected at least 2 transitions, got {}",
2325 persistent.len()
2326 );
2327 }
2328
2329 #[test]
2330 fn test_export_for_file_index_disable_file() {
2331 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
2332 let config = InlineConfig::from_content(content);
2333 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
2334
2335 assert!(file_disabled.contains("MD001"));
2336 }
2337
2338 #[test]
2339 fn test_export_for_file_index_disable_line() {
2340 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
2341 let config = InlineConfig::from_content(content);
2342 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
2343
2344 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
2345 assert!(line_disabled[&2].contains("MD001"));
2346 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
2347 }
2348}