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
31fn 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)]
66pub struct InlineConfig {
67 transitions: Vec<StateTransition>,
70 line_disabled_rules: HashMap<usize, HashSet<String>>,
72 file_disabled_rules: HashSet<String>,
74 file_enabled_rules: HashSet<String>,
76 file_rule_config: HashMap<String, JsonValue>,
79}
80
81impl Default for InlineConfig {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl InlineConfig {
88 pub fn new() -> Self {
89 Self {
90 transitions: Vec::new(),
91 line_disabled_rules: HashMap::new(),
92 file_disabled_rules: HashSet::new(),
93 file_enabled_rules: HashSet::new(),
94 file_rule_config: HashMap::new(),
95 }
96 }
97
98 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
101 if self.transitions.is_empty() {
102 return None;
103 }
104 match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
106 Ok(idx) => Some(&self.transitions[idx]),
107 Err(idx) => {
108 if idx > 0 {
109 Some(&self.transitions[idx - 1])
110 } else {
111 None
112 }
113 }
114 }
115 }
116
117 pub fn from_content(content: &str) -> Self {
119 if !has_inline_config_markers(content) {
120 return Self::new();
121 }
122
123 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
124 Self::from_content_with_code_blocks_internal(content, &code_blocks)
125 }
126
127 pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
129 if !has_inline_config_markers(content) {
130 return Self::new();
131 }
132
133 Self::from_content_with_code_blocks_internal(content, code_blocks)
134 }
135
136 fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
137 let mut config = Self::new();
138 let lines: Vec<&str> = content.lines().collect();
139
140 for (offset, json_config) in scan_configure_file_comments(content) {
145 if offset_in_code_block(offset, code_blocks) {
146 continue;
147 }
148 let Some(obj) = json_config.as_object() else {
149 continue;
150 };
151 for (rule_name, rule_config) in obj {
152 let normalized = normalize_rule_name(rule_name);
156 if let Some(enabled) = rule_config.as_bool() {
157 if enabled {
158 config.file_disabled_rules.remove(&normalized);
159 } else {
160 config.file_disabled_rules.insert(normalized);
161 }
162 continue;
163 }
164 config.file_rule_config.insert(normalized, rule_config.clone());
168 }
169 }
170
171 let mut line_positions = Vec::with_capacity(lines.len());
173 let mut pos = 0;
174 for line in &lines {
175 line_positions.push(pos);
176 pos += line.len() + 1; }
178
179 let mut currently_disabled: HashSet<String> = HashSet::new();
181 let mut currently_enabled: HashSet<String> = HashSet::new();
182 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
183
184 let mut prev_disabled: HashSet<String> = HashSet::new();
186 let mut prev_enabled: HashSet<String> = HashSet::new();
187
188 config.transitions.push(StateTransition {
190 line: 1,
191 disabled: HashSet::new(),
192 enabled: HashSet::new(),
193 });
194
195 for (idx, line) in lines.iter().enumerate() {
196 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
201 config.transitions.push(StateTransition {
202 line: line_num,
203 disabled: currently_disabled.clone(),
204 enabled: currently_enabled.clone(),
205 });
206 prev_disabled.clone_from(¤tly_disabled);
207 prev_enabled.clone_from(¤tly_enabled);
208 }
209
210 let line_start = line_positions[idx];
212 let line_end = line_start + line.len();
213 let in_code_block = code_blocks
214 .iter()
215 .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
216
217 if in_code_block {
218 continue;
219 }
220
221 let directives = parse_inline_directives(line);
224
225 let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
227
228 for directive in &directives {
230 match directive.kind {
231 DirectiveKind::DisableFile => {
232 if directive.rules.is_empty() {
233 config.file_disabled_rules.clear();
234 config.file_disabled_rules.insert("*".to_string());
235 } else if config.file_disabled_rules.contains("*") {
236 for rule in &directive.rules {
237 config.file_enabled_rules.remove(&normalize_rule_name(rule));
238 }
239 } else {
240 for rule in &directive.rules {
241 config.file_disabled_rules.insert(normalize_rule_name(rule));
242 }
243 }
244 }
245 DirectiveKind::EnableFile => {
246 if directive.rules.is_empty() {
247 config.file_disabled_rules.clear();
248 config.file_enabled_rules.clear();
249 } else if config.file_disabled_rules.contains("*") {
250 for rule in &directive.rules {
251 config.file_enabled_rules.insert(normalize_rule_name(rule));
252 }
253 } else {
254 for rule in &directive.rules {
255 config.file_disabled_rules.remove(&normalize_rule_name(rule));
256 }
257 }
258 }
259 _ => {}
261 }
262 }
263
264 for directive in &directives {
266 match directive.kind {
267 DirectiveKind::DisableNextLine => {
268 let next_line = line_num + 1;
269 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
270 if directive.rules.is_empty() {
271 line_rules.insert("*".to_string());
272 } else {
273 for rule in &directive.rules {
274 line_rules.insert(normalize_rule_name(rule));
275 }
276 }
277 }
278 DirectiveKind::DisableLine => {
279 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
280 if directive.rules.is_empty() {
281 line_rules.insert("*".to_string());
282 } else {
283 for rule in &directive.rules {
284 line_rules.insert(normalize_rule_name(rule));
285 }
286 }
287 }
288 DirectiveKind::Disable => {
289 if directive.rules.is_empty() {
290 currently_disabled.clear();
291 currently_disabled.insert("*".to_string());
292 currently_enabled.clear();
293 } else if currently_disabled.contains("*") {
294 for rule in &directive.rules {
295 currently_enabled.remove(&normalize_rule_name(rule));
296 }
297 } else {
298 for rule in &directive.rules {
299 currently_disabled.insert(normalize_rule_name(rule));
300 }
301 }
302 }
303 DirectiveKind::Enable => {
304 if directive.rules.is_empty() {
305 currently_disabled.clear();
306 currently_enabled.clear();
307 } else if currently_disabled.contains("*") {
308 for rule in &directive.rules {
309 currently_enabled.insert(normalize_rule_name(rule));
310 }
311 } else {
312 for rule in &directive.rules {
313 currently_disabled.remove(&normalize_rule_name(rule));
314 }
315 }
316 }
317 DirectiveKind::Capture => {
318 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
319 }
320 DirectiveKind::Restore => {
321 if let Some((disabled, enabled)) = capture_stack.pop() {
322 currently_disabled = disabled;
323 currently_enabled = enabled;
324 }
325 }
326 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
328 }
329 }
330
331 if has_prettier_ignore {
333 let next_line = line_num + 1;
334 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
335 line_rules.insert("*".to_string());
336 }
337 }
338
339 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
341 config.transitions.push(StateTransition {
342 line: lines.len() + 1,
343 disabled: currently_disabled,
344 enabled: currently_enabled,
345 });
346 }
347
348 config
349 }
350
351 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
353 if self.file_disabled_rules.contains("*") {
355 return !self.file_enabled_rules.contains(rule_name);
357 } else if self.file_disabled_rules.contains(rule_name) {
358 return true;
359 }
360
361 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
363 && (line_rules.contains("*") || line_rules.contains(rule_name))
364 {
365 return true;
366 }
367
368 if let Some(transition) = self.find_transition(line_number) {
370 if transition.disabled.contains("*") {
371 return !transition.enabled.contains(rule_name);
372 } else {
373 return transition.disabled.contains(rule_name);
374 }
375 }
376
377 false
378 }
379
380 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
382 let mut disabled = HashSet::new();
383
384 if let Some(transition) = self.find_transition(line_number) {
386 if transition.disabled.contains("*") {
387 disabled.insert("*".to_string());
388 } else {
389 for rule in &transition.disabled {
390 disabled.insert(rule.clone());
391 }
392 }
393 }
394
395 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
397 for rule in line_rules {
398 disabled.insert(rule.clone());
399 }
400 }
401
402 disabled
403 }
404
405 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
407 self.file_rule_config.get(rule_name)
408 }
409
410 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
412 &self.file_rule_config
413 }
414
415 pub fn export_for_file_index(&self) -> FileIndexExport {
419 let file_disabled = self.file_disabled_rules.clone();
420
421 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
422 .transitions
423 .iter()
424 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
425 .collect();
426
427 let line_disabled = self.line_disabled_rules.clone();
428
429 (file_disabled, persistent_transitions, line_disabled)
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum DirectiveKind {
444 Disable,
445 DisableLine,
446 DisableNextLine,
447 DisableFile,
448 Enable,
449 EnableFile,
450 Capture,
451 Restore,
452 ConfigureFile,
453}
454
455#[derive(Debug, Clone, PartialEq)]
457pub struct InlineDirective<'a> {
458 pub kind: DirectiveKind,
459 pub rules: Vec<&'a str>,
460}
461
462const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
464
465const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
469 (DirectiveKind::DisableNextLine, "disable-next-line"),
470 (DirectiveKind::DisableLine, "disable-line"),
471 (DirectiveKind::DisableFile, "disable-file"),
472 (DirectiveKind::Disable, "disable"),
473 (DirectiveKind::EnableFile, "enable-file"),
474 (DirectiveKind::Enable, "enable"),
475 (DirectiveKind::ConfigureFile, "configure-file"),
476 (DirectiveKind::Capture, "capture"),
477 (DirectiveKind::Restore, "restore"),
478];
479
480fn try_parse_directive(s: &str) -> Option<(InlineDirective<'_>, usize)> {
484 for tool in TOOL_PREFIXES {
485 if !s.starts_with(tool) {
486 continue;
487 }
488 let after_tool = &s[tool.len()..];
489
490 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
491 if !after_tool.starts_with(keyword) {
492 continue;
493 }
494 let after_kw = &after_tool[keyword.len()..];
495
496 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
499 continue;
500 }
501
502 let close_offset = after_kw.find("-->")?;
504
505 let rules_str = after_kw[..close_offset].trim();
506 let rules = if rules_str.is_empty() {
507 Vec::new()
508 } else {
509 rules_str.split_whitespace().collect()
510 };
511
512 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((InlineDirective { kind, rules }, consumed));
514 }
515
516 return None;
518 }
519 None
520}
521
522pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
528 let mut results = Vec::new();
529 let mut pos = 0;
530
531 while pos < line.len() {
532 let remaining = &line[pos..];
533 let Some(open_offset) = remaining.find("<!-- ") else {
534 break;
535 };
536 let comment_start = pos + open_offset;
537 let after_open = &line[comment_start + 5..]; if let Some((directive, consumed)) = try_parse_directive(after_open) {
540 results.push(directive);
541 pos = comment_start + 5 + consumed;
542 } else {
543 pos = comment_start + 5;
544 }
545 }
546
547 results
548}
549
550fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
556 parse_inline_directives(line)
557 .into_iter()
558 .find(|d| d.kind == kind)
559 .map(|d| d.rules)
560}
561
562pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
564 find_directive_rules(line, DirectiveKind::Disable)
565}
566
567pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
569 find_directive_rules(line, DirectiveKind::Enable)
570}
571
572pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
574 find_directive_rules(line, DirectiveKind::DisableLine)
575}
576
577pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
579 find_directive_rules(line, DirectiveKind::DisableNextLine)
580}
581
582pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
584 find_directive_rules(line, DirectiveKind::DisableFile)
585}
586
587pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
589 find_directive_rules(line, DirectiveKind::EnableFile)
590}
591
592pub fn is_capture_comment(line: &str) -> bool {
594 parse_inline_directives(line)
595 .iter()
596 .any(|d| d.kind == DirectiveKind::Capture)
597}
598
599pub fn is_restore_comment(line: &str) -> bool {
601 parse_inline_directives(line)
602 .iter()
603 .any(|d| d.kind == DirectiveKind::Restore)
604}
605
606const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
607
608fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
610 code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
611}
612
613fn line_of_offset(text: &str, offset: usize) -> usize {
615 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
616}
617
618fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
629 let mut found = Vec::new();
630 let mut pos = 0;
631
632 while let Some(open_offset) = text[pos..].find("<!-- ") {
633 let comment_start = pos + open_offset;
634 let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
638
639 for tool in TOOL_PREFIXES {
640 let Some(after_tool) = after_open.strip_prefix(tool) else {
641 continue;
642 };
643 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
644 break;
645 };
646 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
649 break;
650 }
651 let Some(close_offset) = after_kw.find("-->") else {
652 break;
653 };
654
655 let json_str = after_kw[..close_offset].trim();
656 if !json_str.is_empty()
657 && let Ok(value) = serde_json::from_str(json_str)
658 {
659 found.push((comment_start, value));
660 }
661 pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
662 break;
663 }
664 }
665
666 found
667}
668
669pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
673 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
674}
675
676#[derive(Debug, Clone, PartialEq, Eq)]
678pub enum InlineConfigProblem {
679 UnknownRule,
681 UnknownOption { key: String },
684 EnableHasNoEffect,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct InlineConfigWarning {
693 pub line_number: usize,
695 pub rule_name: String,
697 pub comment_type: String,
699 pub suggestion: Option<String>,
701 pub problem: InlineConfigProblem,
703}
704
705impl InlineConfigWarning {
706 pub fn format_message(&self) -> String {
708 match &self.problem {
711 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
712 Some(ref suggestion) => format!(
713 "Unknown option for rule {}: {} (did you mean: {}?)",
714 self.rule_name, key, suggestion
715 ),
716 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
717 },
718 InlineConfigProblem::UnknownRule => match self.suggestion {
719 Some(ref suggestion) => format!(
720 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
721 self.comment_type, self.rule_name, suggestion
722 ),
723 None => format!(
724 "Unknown rule in inline {} comment: {}",
725 self.comment_type, self.rule_name
726 ),
727 },
728 InlineConfigProblem::EnableHasNoEffect => format!(
729 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
730 self.rule_name, self.comment_type
731 ),
732 }
733 }
734
735 pub fn print_warning(&self, file_path: &str) {
737 eprintln!(
738 "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
739 file_path,
740 self.line_number,
741 self.format_message()
742 );
743 }
744}
745
746pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
752 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
753
754 let mut warnings = Vec::new();
755 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
756
757 let suggest = |rule_name: &str| {
758 suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
759 };
760
761 let registry = crate::config::default_registry();
765 for (offset, json_config) in scan_configure_file_comments(content) {
766 let Some(obj) = json_config.as_object() else {
767 continue;
768 };
769 let line_number = line_of_offset(content, offset);
770 for (rule_name, rule_config) in obj {
771 if !is_valid_rule_name(rule_name) {
772 warnings.push(InlineConfigWarning {
773 line_number,
774 rule_name: rule_name.clone(),
775 comment_type: "configure-file".to_string(),
776 suggestion: suggest(rule_name),
777 problem: InlineConfigProblem::UnknownRule,
778 });
779 continue;
782 }
783 let Some(options) = rule_config.as_object() else {
785 continue;
786 };
787 let canonical = normalize_rule_name(rule_name);
788 let Some(valid_keys) = registry.config_keys_for(&canonical) else {
789 continue;
790 };
791 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
792 for key in options.keys() {
793 if !valid_keys.contains(key) {
794 warnings.push(InlineConfigWarning {
795 line_number,
796 rule_name: canonical.clone(),
797 comment_type: "configure-file".to_string(),
798 suggestion: suggest_similar_key(key, &valid_keys_vec),
799 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
800 });
801 }
802 }
803 }
804 }
805
806 for (idx, line) in content.lines().enumerate() {
807 let line_num = idx + 1;
808
809 let directives = parse_inline_directives(line);
811 let mut rule_entries: Vec<(&str, &str)> = Vec::new();
812
813 for directive in &directives {
814 let comment_type = match directive.kind {
815 DirectiveKind::Disable => "disable",
816 DirectiveKind::Enable => "enable",
817 DirectiveKind::DisableLine => "disable-line",
818 DirectiveKind::DisableNextLine => "disable-next-line",
819 DirectiveKind::DisableFile => "disable-file",
820 DirectiveKind::EnableFile => "enable-file",
821 DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
823 };
824 for rule in &directive.rules {
825 rule_entries.push((rule, comment_type));
826 }
827 }
828
829 for (rule_name, comment_type) in rule_entries {
831 if !is_valid_rule_name(rule_name) {
832 warnings.push(InlineConfigWarning {
833 line_number: line_num,
834 rule_name: rule_name.to_string(),
835 comment_type: comment_type.to_string(),
836 suggestion: suggest(rule_name),
837 problem: InlineConfigProblem::UnknownRule,
838 });
839 }
840 }
841 }
842
843 warnings.sort_by_key(|w| w.line_number);
846 warnings
847}
848
849pub fn validate_inline_enables_against_active_rules(
862 content: &str,
863 active_rules: &HashSet<String>,
864) -> Vec<InlineConfigWarning> {
865 use crate::config::is_valid_rule_name;
866
867 let mut warnings = Vec::new();
868
869 let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
870 if !is_valid_rule_name(name) {
872 return;
873 }
874 let canonical = normalize_rule_name(name);
875 if active_rules.contains(&canonical) {
876 return;
877 }
878 warnings.push(InlineConfigWarning {
879 line_number: line,
880 rule_name: canonical,
881 comment_type: comment_type.to_string(),
882 suggestion: None,
883 problem: InlineConfigProblem::EnableHasNoEffect,
884 });
885 };
886
887 for (offset, json_config) in scan_configure_file_comments(content) {
889 let Some(obj) = json_config.as_object() else {
890 continue;
891 };
892 let line = line_of_offset(content, offset);
893 for (rule_name, rule_config) in obj {
894 if rule_config.as_bool() == Some(true) {
897 flag(&mut warnings, rule_name, "configure-file", line);
898 }
899 }
900 }
901
902 for (idx, line) in content.lines().enumerate() {
904 for directive in parse_inline_directives(line) {
905 let comment_type = match directive.kind {
906 DirectiveKind::Enable => "enable",
907 DirectiveKind::EnableFile => "enable-file",
908 _ => continue,
909 };
910 for rule in &directive.rules {
911 flag(&mut warnings, rule, comment_type, idx + 1);
912 }
913 }
914 }
915
916 warnings.sort_by_key(|w| w.line_number);
917 warnings
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 #[test]
927 fn test_parse_inline_directives_all_kinds() {
928 let cases: &[(&str, DirectiveKind)] = &[
930 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
931 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
932 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
933 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
934 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
935 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
936 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
937 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
938 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
939 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
941 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
942 (
943 "<!-- markdownlint-disable-next-line -->",
944 DirectiveKind::DisableNextLine,
945 ),
946 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
947 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
948 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
949 ];
950 for (input, expected_kind) in cases {
951 let directives = parse_inline_directives(input);
952 assert_eq!(
953 directives.len(),
954 1,
955 "Expected 1 directive for {input:?}, got {directives:?}"
956 );
957 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
958 }
959 }
960
961 #[test]
962 fn test_parse_inline_directives_disambiguation() {
963 let line = "<!-- rumdl-disable-line MD001 -->";
965 let directives = parse_inline_directives(line);
966 assert_eq!(directives.len(), 1);
967 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
968
969 let line = "<!-- rumdl-disable-next-line -->";
970 let directives = parse_inline_directives(line);
971 assert_eq!(directives.len(), 1);
972 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
973
974 let line = "<!-- rumdl-disable-file MD001 -->";
975 let directives = parse_inline_directives(line);
976 assert_eq!(directives.len(), 1);
977 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
978
979 let line = "<!-- rumdl-enable-file -->";
980 let directives = parse_inline_directives(line);
981 assert_eq!(directives.len(), 1);
982 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
983 }
984
985 #[test]
986 fn test_parse_inline_directives_no_space_before_close() {
987 let directives = parse_inline_directives("<!-- rumdl-disable-->");
989 assert_eq!(directives.len(), 1);
990 assert_eq!(directives[0].kind, DirectiveKind::Disable);
991 assert!(directives[0].rules.is_empty());
992
993 let directives = parse_inline_directives("<!-- rumdl-enable-->");
994 assert_eq!(directives.len(), 1);
995 assert_eq!(directives[0].kind, DirectiveKind::Enable);
996 }
997
998 #[test]
999 fn test_parse_inline_directives_multiple_on_one_line() {
1000 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1001 let directives = parse_inline_directives(line);
1002 assert_eq!(directives.len(), 2);
1003 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1004 assert_eq!(directives[0].rules, vec!["MD001"]);
1005 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1006 assert_eq!(directives[1].rules, vec!["MD001"]);
1007 }
1008
1009 #[test]
1010 fn test_parse_inline_directives_global_disable_then_specific_enable() {
1011 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1012 let directives = parse_inline_directives(line);
1013 assert_eq!(directives.len(), 2);
1014 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1015 assert!(directives[0].rules.is_empty());
1016 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1017 assert_eq!(directives[1].rules, vec!["MD001"]);
1018 }
1019
1020 #[test]
1021 fn test_parse_inline_directives_word_boundary() {
1022 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1024 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1026 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1028 }
1029
1030 #[test]
1031 fn test_parse_inline_directives_no_closing_tag() {
1032 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1034 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1035 }
1036
1037 #[test]
1038 fn test_parse_inline_directives_not_a_comment() {
1039 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1040 assert!(parse_inline_directives("Some regular text").is_empty());
1041 assert!(parse_inline_directives("").is_empty());
1042 }
1043
1044 #[test]
1045 fn test_parse_inline_directives_case_sensitive() {
1046 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1047 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1048 }
1049
1050 #[test]
1051 fn test_parse_inline_directives_rules_extraction() {
1052 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1053 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1054
1055 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1057 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1058
1059 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
1061 assert_eq!(directives[0].rules, vec!["MD001"]);
1062 }
1063
1064 #[test]
1065 fn test_parse_inline_directives_embedded_in_text() {
1066 let line = "Some text <!-- rumdl-disable MD001 --> more text";
1067 let directives = parse_inline_directives(line);
1068 assert_eq!(directives.len(), 1);
1069 assert_eq!(directives[0].rules, vec!["MD001"]);
1070
1071 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1072 let directives = parse_inline_directives(line);
1073 assert_eq!(directives.len(), 1);
1074 assert_eq!(directives[0].rules, vec!["MD001"]);
1075 }
1076
1077 #[test]
1078 fn test_parse_inline_directives_mixed_tools_same_line() {
1079 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1080 let directives = parse_inline_directives(line);
1081 assert_eq!(directives.len(), 2);
1082 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1083 assert_eq!(directives[0].rules, vec!["MD001"]);
1084 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1085 assert_eq!(directives[1].rules, vec!["MD002"]);
1086 }
1087
1088 #[test]
1091 fn test_parse_disable_comment() {
1092 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1094 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1095
1096 assert_eq!(
1098 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1099 Some(vec!["MD001", "MD002"])
1100 );
1101
1102 assert_eq!(parse_disable_comment("Some regular text"), None);
1104 }
1105
1106 #[test]
1107 fn test_parse_disable_line_comment() {
1108 assert_eq!(
1110 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1111 Some(vec![])
1112 );
1113
1114 assert_eq!(
1116 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1117 Some(vec!["MD013"])
1118 );
1119
1120 assert_eq!(parse_disable_line_comment("Some regular text"), None);
1122 }
1123
1124 #[test]
1125 fn test_inline_config_from_content() {
1126 let content = r#"# Test Document
1127
1128<!-- markdownlint-disable MD013 -->
1129This is a very long line that would normally trigger MD013 but it's disabled
1130
1131<!-- markdownlint-enable MD013 -->
1132This line will be checked again
1133
1134<!-- markdownlint-disable-next-line MD001 -->
1135# This heading will not be checked for MD001
1136## But this one will
1137
1138Some text <!-- markdownlint-disable-line MD013 -->
1139
1140<!-- markdownlint-capture -->
1141<!-- markdownlint-disable MD001 MD002 -->
1142# Heading with MD001 disabled
1143<!-- markdownlint-restore -->
1144# Heading with MD001 enabled again
1145"#;
1146
1147 let config = InlineConfig::from_content(content);
1148
1149 assert!(config.is_rule_disabled("MD013", 4));
1151
1152 assert!(!config.is_rule_disabled("MD013", 7));
1154
1155 assert!(config.is_rule_disabled("MD001", 10));
1157
1158 assert!(!config.is_rule_disabled("MD001", 11));
1160
1161 assert!(config.is_rule_disabled("MD013", 13));
1163
1164 assert!(!config.is_rule_disabled("MD001", 19));
1166 }
1167
1168 #[test]
1169 fn test_capture_restore() {
1170 let content = r#"<!-- markdownlint-disable MD001 -->
1171<!-- markdownlint-capture -->
1172<!-- markdownlint-disable MD002 MD003 -->
1173<!-- markdownlint-restore -->
1174Some content after restore
1175"#;
1176
1177 let config = InlineConfig::from_content(content);
1178
1179 assert!(config.is_rule_disabled("MD001", 5));
1181 assert!(!config.is_rule_disabled("MD002", 5));
1182 assert!(!config.is_rule_disabled("MD003", 5));
1183 }
1184
1185 #[test]
1186 fn test_validate_inline_config_rules_unknown_rule() {
1187 let content = "<!-- rumdl-disable abc -->\nSome content";
1188 let warnings = validate_inline_config_rules(content);
1189 assert_eq!(warnings.len(), 1);
1190 assert_eq!(warnings[0].line_number, 1);
1191 assert_eq!(warnings[0].rule_name, "abc");
1192 assert_eq!(warnings[0].comment_type, "disable");
1193 }
1194
1195 #[test]
1196 fn test_validate_inline_config_rules_valid_rule() {
1197 let content = "<!-- rumdl-disable MD001 -->\nSome content";
1198 let warnings = validate_inline_config_rules(content);
1199 assert!(
1200 warnings.is_empty(),
1201 "MD001 is a valid rule, should not produce warnings"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_validate_inline_config_rules_alias() {
1207 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1208 let warnings = validate_inline_config_rules(content);
1209 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1210 }
1211
1212 #[test]
1213 fn test_validate_inline_config_rules_multiple_unknown() {
1214 let content = r#"<!-- rumdl-disable abc xyz -->
1215<!-- rumdl-disable-line foo -->
1216<!-- markdownlint-disable-next-line bar -->
1217"#;
1218 let warnings = validate_inline_config_rules(content);
1219 assert_eq!(warnings.len(), 4);
1220 assert_eq!(warnings[0].rule_name, "abc");
1221 assert_eq!(warnings[1].rule_name, "xyz");
1222 assert_eq!(warnings[2].rule_name, "foo");
1223 assert_eq!(warnings[3].rule_name, "bar");
1224 }
1225
1226 #[test]
1227 fn test_validate_inline_config_rules_suggestion() {
1228 let content = "<!-- rumdl-disable MD00 -->\n";
1230 let warnings = validate_inline_config_rules(content);
1231 assert_eq!(warnings.len(), 1);
1232 assert!(warnings[0].suggestion.is_some());
1234 }
1235
1236 #[test]
1237 fn test_validate_inline_config_rules_file_comments() {
1238 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1239 let warnings = validate_inline_config_rules(content);
1240 assert_eq!(warnings.len(), 2);
1241 assert_eq!(warnings[0].comment_type, "disable-file");
1242 assert_eq!(warnings[1].comment_type, "enable-file");
1243 }
1244
1245 #[test]
1246 fn test_validate_inline_config_rules_global_disable() {
1247 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1249 let warnings = validate_inline_config_rules(content);
1250 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1251 }
1252
1253 #[test]
1254 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1255 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1257 let warnings = validate_inline_config_rules(content);
1258 assert_eq!(warnings.len(), 2);
1259 assert_eq!(warnings[0].rule_name, "abc");
1260 assert_eq!(warnings[1].rule_name, "xyz");
1261 }
1262
1263 #[test]
1264 fn test_validate_inline_config_rules_configure_file() {
1265 let content =
1267 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1268 let warnings = validate_inline_config_rules(content);
1269 assert_eq!(warnings.len(), 1);
1270 assert_eq!(warnings[0].rule_name, "nonexistent");
1271 assert_eq!(warnings[0].comment_type, "configure-file");
1272 }
1273
1274 #[test]
1275 fn test_validate_inline_config_rules_markdownlint_variants() {
1276 let content = r#"<!-- markdownlint-disable unknown_rule -->
1278<!-- markdownlint-enable another_fake -->
1279<!-- markdownlint-disable-line bad_rule -->
1280<!-- markdownlint-disable-next-line fake_rule -->
1281<!-- markdownlint-disable-file missing_rule -->
1282<!-- markdownlint-enable-file nonexistent -->
1283"#;
1284 let warnings = validate_inline_config_rules(content);
1285 assert_eq!(warnings.len(), 6);
1286 assert_eq!(warnings[0].rule_name, "unknown_rule");
1287 assert_eq!(warnings[1].rule_name, "another_fake");
1288 assert_eq!(warnings[2].rule_name, "bad_rule");
1289 assert_eq!(warnings[3].rule_name, "fake_rule");
1290 assert_eq!(warnings[4].rule_name, "missing_rule");
1291 assert_eq!(warnings[5].rule_name, "nonexistent");
1292 }
1293
1294 #[test]
1295 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1296 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1297 let warnings = validate_inline_config_rules(content);
1298 assert_eq!(warnings.len(), 1);
1299 assert_eq!(warnings[0].rule_name, "fake_rule");
1300 assert_eq!(warnings[0].comment_type, "configure-file");
1301 }
1302
1303 #[test]
1304 fn test_get_rule_config_from_configure_file() {
1305 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1306
1307This is a test line."#;
1308
1309 let inline_config = InlineConfig::from_content(content);
1310 let config_override = inline_config.get_rule_config("MD013");
1311
1312 assert!(config_override.is_some(), "MD013 config should be found");
1313 let json = config_override.unwrap();
1314 assert!(json.is_object(), "Config should be an object");
1315 let obj = json.as_object().unwrap();
1316 assert!(obj.contains_key("line_length"), "Should have line_length key");
1317 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1318 }
1319
1320 #[test]
1321 fn test_get_rule_config_tables_false() {
1322 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1324
1325 let inline_config = InlineConfig::from_content(content);
1326 let config_override = inline_config.get_rule_config("MD013");
1327
1328 assert!(config_override.is_some(), "MD013 config should be found");
1329 let json = config_override.unwrap();
1330 let obj = json.as_object().unwrap();
1331 assert!(obj.contains_key("tables"), "Should have tables key");
1332 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1333 }
1334
1335 fn active_set(names: &[&str]) -> HashSet<String> {
1348 names.iter().map(|s| (*s).to_string()).collect()
1349 }
1350
1351 #[test]
1352 fn test_inline_enable_of_inactive_rule_warns() {
1353 let active = active_set(&["MD013", "MD022"]);
1354 let content = "<!-- rumdl-enable MD012 -->\n";
1355
1356 let warnings = validate_inline_enables_against_active_rules(content, &active);
1357
1358 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1359 assert_eq!(warnings[0].rule_name, "MD012");
1360 assert_eq!(warnings[0].comment_type, "enable");
1361 assert_eq!(warnings[0].problem, InlineConfigProblem::EnableHasNoEffect);
1362 assert_eq!(warnings[0].line_number, 1);
1363 }
1364
1365 #[test]
1366 fn test_inline_enable_of_active_rule_does_not_warn() {
1367 let active = active_set(&["MD012", "MD013"]);
1370 let content = "<!-- rumdl-enable MD012 -->\n";
1371
1372 let warnings = validate_inline_enables_against_active_rules(content, &active);
1373
1374 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1375 }
1376
1377 #[test]
1378 fn test_bare_enable_all_does_not_warn() {
1379 let active = active_set(&["MD013"]);
1381 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1382 let warnings = validate_inline_enables_against_active_rules(content, &active);
1383 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1384 }
1385 }
1386
1387 #[test]
1388 fn test_enable_file_and_alias_of_inactive_rule_warn() {
1389 let active = active_set(&["MD013"]);
1390 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1392
1393 let warnings = validate_inline_enables_against_active_rules(content, &active);
1394
1395 assert_eq!(warnings.len(), 1, "{warnings:?}");
1396 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1397 assert_eq!(warnings[0].comment_type, "enable-file");
1398 }
1399
1400 #[test]
1401 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1402 let active = active_set(&["MD013"]);
1403 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1404 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1405
1406 let warn_true = validate_inline_enables_against_active_rules(enable, &active);
1407 let warn_false = validate_inline_enables_against_active_rules(disable, &active);
1408
1409 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1410 assert_eq!(warn_true[0].rule_name, "MD012");
1411 assert!(
1412 warn_false.is_empty(),
1413 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1419 let active = active_set(&["MD013"]);
1422 let content = "<!-- rumdl-enable NotARule -->\n";
1423
1424 let warnings = validate_inline_enables_against_active_rules(content, &active);
1425
1426 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1427 }
1428
1429 #[test]
1430 fn test_disable_directive_never_warns_as_no_effect() {
1431 let active = active_set(&["MD013"]);
1434 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1435
1436 let warnings = validate_inline_enables_against_active_rules(content, &active);
1437
1438 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1439 }
1440
1441 #[test]
1447 fn test_unknown_option_key_in_configure_file_warns() {
1448 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
1449
1450 let warnings = validate_inline_config_rules(content);
1451
1452 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1453 assert_eq!(warnings[0].rule_name, "MD013");
1454 assert_eq!(
1455 warnings[0].problem,
1456 InlineConfigProblem::UnknownOption {
1457 key: "line_lenght".to_string()
1458 }
1459 );
1460 assert!(
1461 warnings[0].suggestion.is_some(),
1462 "a near-miss key should suggest the real one"
1463 );
1464 assert!(
1465 warnings[0].format_message().contains("Unknown option for rule MD013"),
1466 "message was: {}",
1467 warnings[0].format_message()
1468 );
1469 }
1470
1471 #[test]
1472 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
1473 for content in [
1476 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
1477 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
1478 ] {
1479 let warnings = validate_inline_config_rules(content);
1480 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
1481 }
1482 }
1483
1484 #[test]
1485 fn test_unknown_rule_does_not_also_warn_about_its_options() {
1486 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
1489
1490 let warnings = validate_inline_config_rules(content);
1491
1492 assert_eq!(
1493 warnings.len(),
1494 1,
1495 "expected only the unknown-rule warning: {warnings:?}"
1496 );
1497 assert_eq!(warnings[0].rule_name, "nonexistent");
1498 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
1499 }
1500
1501 #[test]
1502 fn test_boolean_rule_value_has_no_option_warnings() {
1503 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
1504
1505 let warnings = validate_inline_config_rules(content);
1506
1507 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
1508 }
1509
1510 #[test]
1511 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
1512 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
1513
1514 let warnings = validate_inline_config_rules(content);
1515
1516 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
1517 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
1518 }
1519
1520 #[test]
1521 fn test_configure_file_spanning_multiple_lines_applies() {
1522 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
1523
1524 let inline_config = InlineConfig::from_content(content);
1525 let config_override = inline_config.get_rule_config("MD013");
1526
1527 assert!(config_override.is_some(), "a multi-line configure-file must apply");
1528 let obj = config_override.unwrap().as_object().unwrap();
1529 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
1530 }
1531
1532 #[test]
1533 fn test_every_configure_file_comment_applies_not_just_the_first() {
1534 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";
1537
1538 let inline_config = InlineConfig::from_content(content);
1539
1540 assert!(
1541 inline_config.get_rule_config("MD013").is_some(),
1542 "first (single-line) configure-file dropped"
1543 );
1544 assert!(
1545 inline_config.get_rule_config("MD007").is_some(),
1546 "second (multi-line) configure-file dropped"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_multiline_configure_file_in_code_block_is_ignored() {
1552 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
1555
1556 let inline_config = InlineConfig::from_content(content);
1557
1558 assert!(
1559 inline_config.get_rule_config("MD013").is_none(),
1560 "configure-file inside a fenced code block must not apply"
1561 );
1562 }
1563
1564 #[test]
1565 fn test_multiline_configure_file_bool_and_alias_still_honored() {
1566 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
1569
1570 let inline_config = InlineConfig::from_content(content);
1571
1572 assert!(
1573 inline_config.is_rule_disabled("MD012", 1),
1574 "boolean alias key must disable the rule"
1575 );
1576 let obj = inline_config
1577 .get_rule_config("MD013")
1578 .expect("alias-keyed config should resolve to MD013")
1579 .as_object()
1580 .unwrap();
1581 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
1582 }
1583
1584 #[test]
1585 fn test_multiline_configure_file_warning_reports_start_line() {
1586 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
1589
1590 let warnings = validate_inline_config_rules(content);
1591
1592 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
1593 assert_eq!(warnings[0].rule_name, "nonexistent");
1594 assert_eq!(warnings[0].comment_type, "configure-file");
1595 assert_eq!(
1596 warnings[0].line_number, 3,
1597 "warning must point at the line the comment starts on"
1598 );
1599 }
1600
1601 #[test]
1602 fn test_configure_file_bool_false_disables_rule() {
1603 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1606
1607 let inline_config = InlineConfig::from_content(content);
1608
1609 assert!(inline_config.is_rule_disabled("MD012", 1));
1610 assert!(
1611 inline_config.get_rule_config("MD012").is_none(),
1612 "a boolean should not be stored as rule options"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_configure_file_bool_false_disables_rule_by_alias() {
1618 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
1619
1620 let inline_config = InlineConfig::from_content(content);
1621
1622 assert!(inline_config.is_rule_disabled("MD012", 1));
1623 }
1624
1625 #[test]
1626 fn test_configure_file_bool_true_leaves_rule_enabled() {
1627 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1628
1629 let inline_config = InlineConfig::from_content(content);
1630
1631 assert!(!inline_config.is_rule_disabled("MD012", 1));
1632 }
1633
1634 #[test]
1635 fn test_get_rule_config_from_configure_file_alias_key() {
1636 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
1638
1639 let inline_config = InlineConfig::from_content(content);
1640 let config_override = inline_config.get_rule_config("MD013");
1641
1642 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
1643 let obj = config_override.unwrap().as_object().unwrap();
1644 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1645 }
1646
1647 #[test]
1650 fn test_parse_disable_does_not_match_disable_line() {
1651 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
1653 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
1654 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
1655 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
1656 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
1657 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
1658 }
1659
1660 #[test]
1661 fn test_parse_enable_does_not_match_enable_file() {
1662 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
1663 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
1664 }
1665
1666 #[test]
1667 fn test_parse_disable_comment_edge_cases() {
1668 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
1670
1671 assert_eq!(
1673 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
1674 Some(vec!["MD001", "MD002"])
1675 );
1676
1677 assert_eq!(
1679 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
1680 Some(vec!["MD001"])
1681 );
1682
1683 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
1685
1686 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
1688
1689 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
1691
1692 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1694
1695 assert_eq!(
1697 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
1698 Some(vec!["MD001", "MD001", "MD002"])
1699 );
1700
1701 assert_eq!(
1703 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
1704 Some(vec!["MD001"])
1705 );
1706
1707 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
1709 let comment = format!("<!-- rumdl-disable {many_rules} -->");
1710 let parsed = parse_disable_comment(&comment);
1711 assert!(parsed.is_some());
1712 assert_eq!(parsed.unwrap().len(), 100);
1713
1714 assert_eq!(
1716 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
1717 Some(vec!["MD001-test"])
1718 );
1719 assert_eq!(
1720 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
1721 Some(vec!["custom_rule"])
1722 );
1723 }
1724
1725 #[test]
1726 fn test_parse_enable_comment_edge_cases() {
1727 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
1728 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
1729 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
1730 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
1731 }
1732
1733 #[test]
1736 fn test_disable_inside_fenced_code_block_ignored() {
1737 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
1738 let config = InlineConfig::from_content(content);
1739 assert!(!config.is_rule_disabled("MD001", 6));
1741 }
1742
1743 #[test]
1744 fn test_disable_inside_tilde_fence_ignored() {
1745 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
1746 let config = InlineConfig::from_content(content);
1747 assert!(!config.is_rule_disabled("MD001", 6));
1748 }
1749
1750 #[test]
1751 fn test_disable_before_code_block_persists_after() {
1752 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
1754 let config = InlineConfig::from_content(content);
1755 assert!(config.is_rule_disabled("MD001", 5));
1756 }
1757
1758 #[test]
1759 fn test_enable_inside_code_block_ignored() {
1760 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
1762 let config = InlineConfig::from_content(content);
1763 assert!(config.is_rule_disabled("MD001", 5));
1764 }
1765
1766 #[test]
1769 fn test_markdownlint_disable_rumdl_enable_interop() {
1770 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
1771 let config = InlineConfig::from_content(content);
1772 assert!(config.is_rule_disabled("MD001", 2));
1773 assert!(!config.is_rule_disabled("MD001", 4));
1774 }
1775
1776 #[test]
1777 fn test_rumdl_disable_markdownlint_enable_interop() {
1778 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
1779 let config = InlineConfig::from_content(content);
1780 assert!(config.is_rule_disabled("MD013", 2));
1781 assert!(!config.is_rule_disabled("MD013", 4));
1782 }
1783
1784 #[test]
1787 fn test_global_disable_then_specific_enable() {
1788 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
1789 let config = InlineConfig::from_content(content);
1790 assert!(!config.is_rule_disabled("MD001", 4));
1791 assert!(config.is_rule_disabled("MD002", 4));
1792 assert!(config.is_rule_disabled("MD013", 4));
1793 }
1794
1795 #[test]
1796 fn test_specific_disable_then_global_enable() {
1797 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
1798 let config = InlineConfig::from_content(content);
1799 assert!(config.is_rule_disabled("MD001", 2));
1800 assert!(config.is_rule_disabled("MD002", 2));
1801 assert!(!config.is_rule_disabled("MD001", 4));
1802 assert!(!config.is_rule_disabled("MD002", 4));
1803 }
1804
1805 #[test]
1806 fn test_multiple_rules_disable_enable_independently() {
1807 let content = "\
1808Line 1\n\
1809<!-- rumdl-disable MD001 MD002 -->\n\
1810Line 3\n\
1811<!-- rumdl-enable MD001 -->\n\
1812Line 5\n\
1813<!-- rumdl-disable -->\n\
1814Line 7\n\
1815<!-- rumdl-enable MD002 -->\n\
1816Line 9\n";
1817 let config = InlineConfig::from_content(content);
1818
1819 assert!(!config.is_rule_disabled("MD001", 1));
1821 assert!(!config.is_rule_disabled("MD002", 1));
1822
1823 assert!(config.is_rule_disabled("MD001", 3));
1825 assert!(config.is_rule_disabled("MD002", 3));
1826
1827 assert!(!config.is_rule_disabled("MD001", 5));
1829 assert!(config.is_rule_disabled("MD002", 5));
1830
1831 assert!(config.is_rule_disabled("MD001", 7));
1833 assert!(config.is_rule_disabled("MD002", 7));
1834
1835 assert!(config.is_rule_disabled("MD001", 9));
1837 assert!(!config.is_rule_disabled("MD002", 9));
1838 }
1839
1840 #[test]
1843 fn test_empty_content() {
1844 let config = InlineConfig::from_content("");
1845 assert!(!config.is_rule_disabled("MD001", 1));
1846 }
1847
1848 #[test]
1849 fn test_single_disable_comment_only() {
1850 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
1853 assert!(!config.is_rule_disabled("MD001", 1));
1854 assert!(config.is_rule_disabled("MD001", 2));
1855 assert!(config.is_rule_disabled("MD999", 2));
1856
1857 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
1859 assert!(!config.is_rule_disabled("MD001", 1));
1860 assert!(config.is_rule_disabled("MD001", 2));
1861 assert!(config.is_rule_disabled("MD001", 3));
1862 }
1863
1864 #[test]
1865 fn test_no_inline_markers() {
1866 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
1867 assert!(!config.is_rule_disabled("MD001", 1));
1868 assert!(!config.is_rule_disabled("MD001", 5));
1869 }
1870
1871 #[test]
1874 fn test_export_for_file_index_persistent_transitions() {
1875 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
1876 let config = InlineConfig::from_content(content);
1877 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
1878
1879 assert!(file_disabled.is_empty());
1880 assert!(
1882 persistent.len() >= 2,
1883 "Expected at least 2 transitions, got {}",
1884 persistent.len()
1885 );
1886 }
1887
1888 #[test]
1889 fn test_export_for_file_index_disable_file() {
1890 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
1891 let config = InlineConfig::from_content(content);
1892 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
1893
1894 assert!(file_disabled.contains("MD001"));
1895 }
1896
1897 #[test]
1898 fn test_export_for_file_index_disable_line() {
1899 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
1900 let config = InlineConfig::from_content(content);
1901 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
1902
1903 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
1904 assert!(line_disabled[&2].contains("MD001"));
1905 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
1906 }
1907}