1use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
22use crate::utils::code_block_utils::CodeBlockUtils;
23use serde_json::Value as JsonValue;
24use std::collections::{HashMap, HashSet};
25
26fn normalize_rule_name(rule: &str) -> String {
29 markdownlint_to_rumdl_rule_key(rule).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
30}
31
32fn has_inline_config_markers(content: &str) -> bool {
33 if !content.contains("<!--") {
34 return false;
35 }
36 content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
37}
38
39pub type FileIndexExport = (
42 HashSet<String>,
43 Vec<(usize, HashSet<String>, HashSet<String>)>,
44 HashMap<usize, HashSet<String>>,
45);
46
47#[derive(Debug, Clone)]
51struct StateTransition {
52 line: usize,
54 disabled: HashSet<String>,
56 enabled: HashSet<String>,
58}
59
60#[derive(Debug, Clone)]
61pub struct InlineConfig {
62 transitions: Vec<StateTransition>,
65 line_disabled_rules: HashMap<usize, HashSet<String>>,
67 file_disabled_rules: HashSet<String>,
69 file_enabled_rules: HashSet<String>,
71 file_rule_config: HashMap<String, JsonValue>,
74}
75
76impl Default for InlineConfig {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl InlineConfig {
83 pub fn new() -> Self {
84 Self {
85 transitions: Vec::new(),
86 line_disabled_rules: HashMap::new(),
87 file_disabled_rules: HashSet::new(),
88 file_enabled_rules: HashSet::new(),
89 file_rule_config: HashMap::new(),
90 }
91 }
92
93 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
96 if self.transitions.is_empty() {
97 return None;
98 }
99 match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
101 Ok(idx) => Some(&self.transitions[idx]),
102 Err(idx) => {
103 if idx > 0 {
104 Some(&self.transitions[idx - 1])
105 } else {
106 None
107 }
108 }
109 }
110 }
111
112 pub fn from_content(content: &str) -> Self {
114 if !has_inline_config_markers(content) {
115 return Self::new();
116 }
117
118 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
119 Self::from_content_with_code_blocks_internal(content, &code_blocks)
120 }
121
122 pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
124 if !has_inline_config_markers(content) {
125 return Self::new();
126 }
127
128 Self::from_content_with_code_blocks_internal(content, code_blocks)
129 }
130
131 fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
132 let mut config = Self::new();
133 let lines: Vec<&str> = content.lines().collect();
134
135 let mut line_positions = Vec::with_capacity(lines.len());
137 let mut pos = 0;
138 for line in &lines {
139 line_positions.push(pos);
140 pos += line.len() + 1; }
142
143 let mut currently_disabled: HashSet<String> = HashSet::new();
145 let mut currently_enabled: HashSet<String> = HashSet::new();
146 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
147
148 let mut prev_disabled: HashSet<String> = HashSet::new();
150 let mut prev_enabled: HashSet<String> = HashSet::new();
151
152 config.transitions.push(StateTransition {
154 line: 1,
155 disabled: HashSet::new(),
156 enabled: HashSet::new(),
157 });
158
159 for (idx, line) in lines.iter().enumerate() {
160 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
165 config.transitions.push(StateTransition {
166 line: line_num,
167 disabled: currently_disabled.clone(),
168 enabled: currently_enabled.clone(),
169 });
170 prev_disabled.clone_from(¤tly_disabled);
171 prev_enabled.clone_from(¤tly_enabled);
172 }
173
174 let line_start = line_positions[idx];
176 let line_end = line_start + line.len();
177 let in_code_block = code_blocks
178 .iter()
179 .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
180
181 if in_code_block {
182 continue;
183 }
184
185 let directives = parse_inline_directives(line);
188
189 let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
191
192 for directive in &directives {
194 match directive.kind {
195 DirectiveKind::DisableFile => {
196 if directive.rules.is_empty() {
197 config.file_disabled_rules.clear();
198 config.file_disabled_rules.insert("*".to_string());
199 } else if config.file_disabled_rules.contains("*") {
200 for rule in &directive.rules {
201 config.file_enabled_rules.remove(&normalize_rule_name(rule));
202 }
203 } else {
204 for rule in &directive.rules {
205 config.file_disabled_rules.insert(normalize_rule_name(rule));
206 }
207 }
208 }
209 DirectiveKind::EnableFile => {
210 if directive.rules.is_empty() {
211 config.file_disabled_rules.clear();
212 config.file_enabled_rules.clear();
213 } else if config.file_disabled_rules.contains("*") {
214 for rule in &directive.rules {
215 config.file_enabled_rules.insert(normalize_rule_name(rule));
216 }
217 } else {
218 for rule in &directive.rules {
219 config.file_disabled_rules.remove(&normalize_rule_name(rule));
220 }
221 }
222 }
223 DirectiveKind::ConfigureFile => {
224 if let Some(json_config) = parse_configure_file_comment(line)
225 && let Some(obj) = json_config.as_object()
226 {
227 for (rule_name, rule_config) in obj {
228 config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
229 }
230 }
231 }
232 _ => {}
233 }
234 }
235
236 for directive in &directives {
238 match directive.kind {
239 DirectiveKind::DisableNextLine => {
240 let next_line = line_num + 1;
241 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
242 if directive.rules.is_empty() {
243 line_rules.insert("*".to_string());
244 } else {
245 for rule in &directive.rules {
246 line_rules.insert(normalize_rule_name(rule));
247 }
248 }
249 }
250 DirectiveKind::DisableLine => {
251 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
252 if directive.rules.is_empty() {
253 line_rules.insert("*".to_string());
254 } else {
255 for rule in &directive.rules {
256 line_rules.insert(normalize_rule_name(rule));
257 }
258 }
259 }
260 DirectiveKind::Disable => {
261 if directive.rules.is_empty() {
262 currently_disabled.clear();
263 currently_disabled.insert("*".to_string());
264 currently_enabled.clear();
265 } else if currently_disabled.contains("*") {
266 for rule in &directive.rules {
267 currently_enabled.remove(&normalize_rule_name(rule));
268 }
269 } else {
270 for rule in &directive.rules {
271 currently_disabled.insert(normalize_rule_name(rule));
272 }
273 }
274 }
275 DirectiveKind::Enable => {
276 if directive.rules.is_empty() {
277 currently_disabled.clear();
278 currently_enabled.clear();
279 } else if currently_disabled.contains("*") {
280 for rule in &directive.rules {
281 currently_enabled.insert(normalize_rule_name(rule));
282 }
283 } else {
284 for rule in &directive.rules {
285 currently_disabled.remove(&normalize_rule_name(rule));
286 }
287 }
288 }
289 DirectiveKind::Capture => {
290 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
291 }
292 DirectiveKind::Restore => {
293 if let Some((disabled, enabled)) = capture_stack.pop() {
294 currently_disabled = disabled;
295 currently_enabled = enabled;
296 }
297 }
298 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
300 }
301 }
302
303 if has_prettier_ignore {
305 let next_line = line_num + 1;
306 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
307 line_rules.insert("*".to_string());
308 }
309 }
310
311 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
313 config.transitions.push(StateTransition {
314 line: lines.len() + 1,
315 disabled: currently_disabled,
316 enabled: currently_enabled,
317 });
318 }
319
320 config
321 }
322
323 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
325 if self.file_disabled_rules.contains("*") {
327 return !self.file_enabled_rules.contains(rule_name);
329 } else if self.file_disabled_rules.contains(rule_name) {
330 return true;
331 }
332
333 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
335 && (line_rules.contains("*") || line_rules.contains(rule_name))
336 {
337 return true;
338 }
339
340 if let Some(transition) = self.find_transition(line_number) {
342 if transition.disabled.contains("*") {
343 return !transition.enabled.contains(rule_name);
344 } else {
345 return transition.disabled.contains(rule_name);
346 }
347 }
348
349 false
350 }
351
352 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
354 let mut disabled = HashSet::new();
355
356 if let Some(transition) = self.find_transition(line_number) {
358 if transition.disabled.contains("*") {
359 disabled.insert("*".to_string());
360 } else {
361 for rule in &transition.disabled {
362 disabled.insert(rule.clone());
363 }
364 }
365 }
366
367 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
369 for rule in line_rules {
370 disabled.insert(rule.clone());
371 }
372 }
373
374 disabled
375 }
376
377 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
379 self.file_rule_config.get(rule_name)
380 }
381
382 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
384 &self.file_rule_config
385 }
386
387 pub fn export_for_file_index(&self) -> FileIndexExport {
391 let file_disabled = self.file_disabled_rules.clone();
392
393 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
394 .transitions
395 .iter()
396 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
397 .collect();
398
399 let line_disabled = self.line_disabled_rules.clone();
400
401 (file_disabled, persistent_transitions, line_disabled)
402 }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub enum DirectiveKind {
416 Disable,
417 DisableLine,
418 DisableNextLine,
419 DisableFile,
420 Enable,
421 EnableFile,
422 Capture,
423 Restore,
424 ConfigureFile,
425}
426
427#[derive(Debug, Clone, PartialEq)]
429pub struct InlineDirective<'a> {
430 pub kind: DirectiveKind,
431 pub rules: Vec<&'a str>,
432}
433
434const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
436
437const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
441 (DirectiveKind::DisableNextLine, "disable-next-line"),
442 (DirectiveKind::DisableLine, "disable-line"),
443 (DirectiveKind::DisableFile, "disable-file"),
444 (DirectiveKind::Disable, "disable"),
445 (DirectiveKind::EnableFile, "enable-file"),
446 (DirectiveKind::Enable, "enable"),
447 (DirectiveKind::ConfigureFile, "configure-file"),
448 (DirectiveKind::Capture, "capture"),
449 (DirectiveKind::Restore, "restore"),
450];
451
452fn try_parse_directive(s: &str) -> Option<(InlineDirective<'_>, usize)> {
456 for tool in TOOL_PREFIXES {
457 if !s.starts_with(tool) {
458 continue;
459 }
460 let after_tool = &s[tool.len()..];
461
462 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
463 if !after_tool.starts_with(keyword) {
464 continue;
465 }
466 let after_kw = &after_tool[keyword.len()..];
467
468 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
471 continue;
472 }
473
474 let close_offset = after_kw.find("-->")?;
476
477 let rules_str = after_kw[..close_offset].trim();
478 let rules = if rules_str.is_empty() {
479 Vec::new()
480 } else {
481 rules_str.split_whitespace().collect()
482 };
483
484 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((InlineDirective { kind, rules }, consumed));
486 }
487
488 return None;
490 }
491 None
492}
493
494pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
500 let mut results = Vec::new();
501 let mut pos = 0;
502
503 while pos < line.len() {
504 let remaining = &line[pos..];
505 let Some(open_offset) = remaining.find("<!-- ") else {
506 break;
507 };
508 let comment_start = pos + open_offset;
509 let after_open = &line[comment_start + 5..]; if let Some((directive, consumed)) = try_parse_directive(after_open) {
512 results.push(directive);
513 pos = comment_start + 5 + consumed;
514 } else {
515 pos = comment_start + 5;
516 }
517 }
518
519 results
520}
521
522fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
528 parse_inline_directives(line)
529 .into_iter()
530 .find(|d| d.kind == kind)
531 .map(|d| d.rules)
532}
533
534pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
536 find_directive_rules(line, DirectiveKind::Disable)
537}
538
539pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
541 find_directive_rules(line, DirectiveKind::Enable)
542}
543
544pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
546 find_directive_rules(line, DirectiveKind::DisableLine)
547}
548
549pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
551 find_directive_rules(line, DirectiveKind::DisableNextLine)
552}
553
554pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
556 find_directive_rules(line, DirectiveKind::DisableFile)
557}
558
559pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
561 find_directive_rules(line, DirectiveKind::EnableFile)
562}
563
564pub fn is_capture_comment(line: &str) -> bool {
566 parse_inline_directives(line)
567 .iter()
568 .any(|d| d.kind == DirectiveKind::Capture)
569}
570
571pub fn is_restore_comment(line: &str) -> bool {
573 parse_inline_directives(line)
574 .iter()
575 .any(|d| d.kind == DirectiveKind::Restore)
576}
577
578pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
584 if !parse_inline_directives(line)
586 .iter()
587 .any(|d| d.kind == DirectiveKind::ConfigureFile)
588 {
589 return None;
590 }
591
592 for tool in TOOL_PREFIXES {
594 let prefix = format!("<!-- {tool}configure-file");
595 if let Some(start) = line.find(&prefix) {
596 let after_prefix = &line[start + prefix.len()..];
597 if let Some(end) = after_prefix.find("-->") {
598 let json_str = after_prefix[..end].trim();
599 if !json_str.is_empty()
600 && let Ok(value) = serde_json::from_str(json_str)
601 {
602 return Some(value);
603 }
604 }
605 }
606 }
607 None
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct InlineConfigWarning {
613 pub line_number: usize,
615 pub rule_name: String,
617 pub comment_type: String,
619 pub suggestion: Option<String>,
621}
622
623impl InlineConfigWarning {
624 pub fn format_message(&self) -> String {
626 if let Some(ref suggestion) = self.suggestion {
627 format!(
628 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
629 self.comment_type, self.rule_name, suggestion
630 )
631 } else {
632 format!(
633 "Unknown rule in inline {} comment: {}",
634 self.comment_type, self.rule_name
635 )
636 }
637 }
638
639 pub fn print_warning(&self, file_path: &str) {
641 eprintln!(
642 "\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
643 file_path,
644 self.line_number,
645 self.format_message()
646 );
647 }
648}
649
650pub fn validate_inline_config_rules(content: &str) -> Vec<InlineConfigWarning> {
656 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
657
658 let mut warnings = Vec::new();
659 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
660
661 for (idx, line) in content.lines().enumerate() {
662 let line_num = idx + 1;
663
664 let directives = parse_inline_directives(line);
666 let mut rule_entries: Vec<(&str, &str)> = Vec::new();
667
668 for directive in &directives {
669 let comment_type = match directive.kind {
670 DirectiveKind::Disable => "disable",
671 DirectiveKind::Enable => "enable",
672 DirectiveKind::DisableLine => "disable-line",
673 DirectiveKind::DisableNextLine => "disable-next-line",
674 DirectiveKind::DisableFile => "disable-file",
675 DirectiveKind::EnableFile => "enable-file",
676 DirectiveKind::ConfigureFile => {
677 if let Some(json_config) = parse_configure_file_comment(line)
679 && let Some(obj) = json_config.as_object()
680 {
681 for rule_name in obj.keys() {
682 if !is_valid_rule_name(rule_name) {
683 let suggestion = suggest_similar_key(rule_name, &all_rule_names)
684 .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
685 warnings.push(InlineConfigWarning {
686 line_number: line_num,
687 rule_name: rule_name.clone(),
688 comment_type: "configure-file".to_string(),
689 suggestion,
690 });
691 }
692 }
693 }
694 continue;
695 }
696 DirectiveKind::Capture | DirectiveKind::Restore => continue,
697 };
698 for rule in &directive.rules {
699 rule_entries.push((rule, comment_type));
700 }
701 }
702
703 for (rule_name, comment_type) in rule_entries {
705 if !is_valid_rule_name(rule_name) {
706 let suggestion = suggest_similar_key(rule_name, &all_rule_names)
707 .map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() });
708 warnings.push(InlineConfigWarning {
709 line_number: line_num,
710 rule_name: rule_name.to_string(),
711 comment_type: comment_type.to_string(),
712 suggestion,
713 });
714 }
715 }
716 }
717
718 warnings
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
728 fn test_parse_inline_directives_all_kinds() {
729 let cases: &[(&str, DirectiveKind)] = &[
731 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
732 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
733 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
734 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
735 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
736 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
737 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
738 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
739 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
740 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
742 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
743 (
744 "<!-- markdownlint-disable-next-line -->",
745 DirectiveKind::DisableNextLine,
746 ),
747 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
748 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
749 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
750 ];
751 for (input, expected_kind) in cases {
752 let directives = parse_inline_directives(input);
753 assert_eq!(
754 directives.len(),
755 1,
756 "Expected 1 directive for {input:?}, got {directives:?}"
757 );
758 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
759 }
760 }
761
762 #[test]
763 fn test_parse_inline_directives_disambiguation() {
764 let line = "<!-- rumdl-disable-line MD001 -->";
766 let directives = parse_inline_directives(line);
767 assert_eq!(directives.len(), 1);
768 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
769
770 let line = "<!-- rumdl-disable-next-line -->";
771 let directives = parse_inline_directives(line);
772 assert_eq!(directives.len(), 1);
773 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
774
775 let line = "<!-- rumdl-disable-file MD001 -->";
776 let directives = parse_inline_directives(line);
777 assert_eq!(directives.len(), 1);
778 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
779
780 let line = "<!-- rumdl-enable-file -->";
781 let directives = parse_inline_directives(line);
782 assert_eq!(directives.len(), 1);
783 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
784 }
785
786 #[test]
787 fn test_parse_inline_directives_no_space_before_close() {
788 let directives = parse_inline_directives("<!-- rumdl-disable-->");
790 assert_eq!(directives.len(), 1);
791 assert_eq!(directives[0].kind, DirectiveKind::Disable);
792 assert!(directives[0].rules.is_empty());
793
794 let directives = parse_inline_directives("<!-- rumdl-enable-->");
795 assert_eq!(directives.len(), 1);
796 assert_eq!(directives[0].kind, DirectiveKind::Enable);
797 }
798
799 #[test]
800 fn test_parse_inline_directives_multiple_on_one_line() {
801 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
802 let directives = parse_inline_directives(line);
803 assert_eq!(directives.len(), 2);
804 assert_eq!(directives[0].kind, DirectiveKind::Disable);
805 assert_eq!(directives[0].rules, vec!["MD001"]);
806 assert_eq!(directives[1].kind, DirectiveKind::Enable);
807 assert_eq!(directives[1].rules, vec!["MD001"]);
808 }
809
810 #[test]
811 fn test_parse_inline_directives_global_disable_then_specific_enable() {
812 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
813 let directives = parse_inline_directives(line);
814 assert_eq!(directives.len(), 2);
815 assert_eq!(directives[0].kind, DirectiveKind::Disable);
816 assert!(directives[0].rules.is_empty());
817 assert_eq!(directives[1].kind, DirectiveKind::Enable);
818 assert_eq!(directives[1].rules, vec!["MD001"]);
819 }
820
821 #[test]
822 fn test_parse_inline_directives_word_boundary() {
823 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
825 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
827 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
829 }
830
831 #[test]
832 fn test_parse_inline_directives_no_closing_tag() {
833 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
835 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
836 }
837
838 #[test]
839 fn test_parse_inline_directives_not_a_comment() {
840 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
841 assert!(parse_inline_directives("Some regular text").is_empty());
842 assert!(parse_inline_directives("").is_empty());
843 }
844
845 #[test]
846 fn test_parse_inline_directives_case_sensitive() {
847 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
848 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
849 }
850
851 #[test]
852 fn test_parse_inline_directives_rules_extraction() {
853 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
854 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
855
856 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
858 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
859
860 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
862 assert_eq!(directives[0].rules, vec!["MD001"]);
863 }
864
865 #[test]
866 fn test_parse_inline_directives_embedded_in_text() {
867 let line = "Some text <!-- rumdl-disable MD001 --> more text";
868 let directives = parse_inline_directives(line);
869 assert_eq!(directives.len(), 1);
870 assert_eq!(directives[0].rules, vec!["MD001"]);
871
872 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
873 let directives = parse_inline_directives(line);
874 assert_eq!(directives.len(), 1);
875 assert_eq!(directives[0].rules, vec!["MD001"]);
876 }
877
878 #[test]
879 fn test_parse_inline_directives_mixed_tools_same_line() {
880 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
881 let directives = parse_inline_directives(line);
882 assert_eq!(directives.len(), 2);
883 assert_eq!(directives[0].kind, DirectiveKind::Disable);
884 assert_eq!(directives[0].rules, vec!["MD001"]);
885 assert_eq!(directives[1].kind, DirectiveKind::Enable);
886 assert_eq!(directives[1].rules, vec!["MD002"]);
887 }
888
889 #[test]
892 fn test_parse_disable_comment() {
893 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
895 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
896
897 assert_eq!(
899 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
900 Some(vec!["MD001", "MD002"])
901 );
902
903 assert_eq!(parse_disable_comment("Some regular text"), None);
905 }
906
907 #[test]
908 fn test_parse_disable_line_comment() {
909 assert_eq!(
911 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
912 Some(vec![])
913 );
914
915 assert_eq!(
917 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
918 Some(vec!["MD013"])
919 );
920
921 assert_eq!(parse_disable_line_comment("Some regular text"), None);
923 }
924
925 #[test]
926 fn test_inline_config_from_content() {
927 let content = r#"# Test Document
928
929<!-- markdownlint-disable MD013 -->
930This is a very long line that would normally trigger MD013 but it's disabled
931
932<!-- markdownlint-enable MD013 -->
933This line will be checked again
934
935<!-- markdownlint-disable-next-line MD001 -->
936# This heading will not be checked for MD001
937## But this one will
938
939Some text <!-- markdownlint-disable-line MD013 -->
940
941<!-- markdownlint-capture -->
942<!-- markdownlint-disable MD001 MD002 -->
943# Heading with MD001 disabled
944<!-- markdownlint-restore -->
945# Heading with MD001 enabled again
946"#;
947
948 let config = InlineConfig::from_content(content);
949
950 assert!(config.is_rule_disabled("MD013", 4));
952
953 assert!(!config.is_rule_disabled("MD013", 7));
955
956 assert!(config.is_rule_disabled("MD001", 10));
958
959 assert!(!config.is_rule_disabled("MD001", 11));
961
962 assert!(config.is_rule_disabled("MD013", 13));
964
965 assert!(!config.is_rule_disabled("MD001", 19));
967 }
968
969 #[test]
970 fn test_capture_restore() {
971 let content = r#"<!-- markdownlint-disable MD001 -->
972<!-- markdownlint-capture -->
973<!-- markdownlint-disable MD002 MD003 -->
974<!-- markdownlint-restore -->
975Some content after restore
976"#;
977
978 let config = InlineConfig::from_content(content);
979
980 assert!(config.is_rule_disabled("MD001", 5));
982 assert!(!config.is_rule_disabled("MD002", 5));
983 assert!(!config.is_rule_disabled("MD003", 5));
984 }
985
986 #[test]
987 fn test_validate_inline_config_rules_unknown_rule() {
988 let content = "<!-- rumdl-disable abc -->\nSome content";
989 let warnings = validate_inline_config_rules(content);
990 assert_eq!(warnings.len(), 1);
991 assert_eq!(warnings[0].line_number, 1);
992 assert_eq!(warnings[0].rule_name, "abc");
993 assert_eq!(warnings[0].comment_type, "disable");
994 }
995
996 #[test]
997 fn test_validate_inline_config_rules_valid_rule() {
998 let content = "<!-- rumdl-disable MD001 -->\nSome content";
999 let warnings = validate_inline_config_rules(content);
1000 assert!(
1001 warnings.is_empty(),
1002 "MD001 is a valid rule, should not produce warnings"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_validate_inline_config_rules_alias() {
1008 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1009 let warnings = validate_inline_config_rules(content);
1010 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1011 }
1012
1013 #[test]
1014 fn test_validate_inline_config_rules_multiple_unknown() {
1015 let content = r#"<!-- rumdl-disable abc xyz -->
1016<!-- rumdl-disable-line foo -->
1017<!-- markdownlint-disable-next-line bar -->
1018"#;
1019 let warnings = validate_inline_config_rules(content);
1020 assert_eq!(warnings.len(), 4);
1021 assert_eq!(warnings[0].rule_name, "abc");
1022 assert_eq!(warnings[1].rule_name, "xyz");
1023 assert_eq!(warnings[2].rule_name, "foo");
1024 assert_eq!(warnings[3].rule_name, "bar");
1025 }
1026
1027 #[test]
1028 fn test_validate_inline_config_rules_suggestion() {
1029 let content = "<!-- rumdl-disable MD00 -->\n";
1031 let warnings = validate_inline_config_rules(content);
1032 assert_eq!(warnings.len(), 1);
1033 assert!(warnings[0].suggestion.is_some());
1035 }
1036
1037 #[test]
1038 fn test_validate_inline_config_rules_file_comments() {
1039 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1040 let warnings = validate_inline_config_rules(content);
1041 assert_eq!(warnings.len(), 2);
1042 assert_eq!(warnings[0].comment_type, "disable-file");
1043 assert_eq!(warnings[1].comment_type, "enable-file");
1044 }
1045
1046 #[test]
1047 fn test_validate_inline_config_rules_global_disable() {
1048 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1050 let warnings = validate_inline_config_rules(content);
1051 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1052 }
1053
1054 #[test]
1055 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1056 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1058 let warnings = validate_inline_config_rules(content);
1059 assert_eq!(warnings.len(), 2);
1060 assert_eq!(warnings[0].rule_name, "abc");
1061 assert_eq!(warnings[1].rule_name, "xyz");
1062 }
1063
1064 #[test]
1065 fn test_validate_inline_config_rules_configure_file() {
1066 let content =
1068 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1069 let warnings = validate_inline_config_rules(content);
1070 assert_eq!(warnings.len(), 1);
1071 assert_eq!(warnings[0].rule_name, "nonexistent");
1072 assert_eq!(warnings[0].comment_type, "configure-file");
1073 }
1074
1075 #[test]
1076 fn test_validate_inline_config_rules_markdownlint_variants() {
1077 let content = r#"<!-- markdownlint-disable unknown_rule -->
1079<!-- markdownlint-enable another_fake -->
1080<!-- markdownlint-disable-line bad_rule -->
1081<!-- markdownlint-disable-next-line fake_rule -->
1082<!-- markdownlint-disable-file missing_rule -->
1083<!-- markdownlint-enable-file nonexistent -->
1084"#;
1085 let warnings = validate_inline_config_rules(content);
1086 assert_eq!(warnings.len(), 6);
1087 assert_eq!(warnings[0].rule_name, "unknown_rule");
1088 assert_eq!(warnings[1].rule_name, "another_fake");
1089 assert_eq!(warnings[2].rule_name, "bad_rule");
1090 assert_eq!(warnings[3].rule_name, "fake_rule");
1091 assert_eq!(warnings[4].rule_name, "missing_rule");
1092 assert_eq!(warnings[5].rule_name, "nonexistent");
1093 }
1094
1095 #[test]
1096 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1097 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1098 let warnings = validate_inline_config_rules(content);
1099 assert_eq!(warnings.len(), 1);
1100 assert_eq!(warnings[0].rule_name, "fake_rule");
1101 assert_eq!(warnings[0].comment_type, "configure-file");
1102 }
1103
1104 #[test]
1105 fn test_get_rule_config_from_configure_file() {
1106 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1107
1108This is a test line."#;
1109
1110 let inline_config = InlineConfig::from_content(content);
1111 let config_override = inline_config.get_rule_config("MD013");
1112
1113 assert!(config_override.is_some(), "MD013 config should be found");
1114 let json = config_override.unwrap();
1115 assert!(json.is_object(), "Config should be an object");
1116 let obj = json.as_object().unwrap();
1117 assert!(obj.contains_key("line_length"), "Should have line_length key");
1118 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1119 }
1120
1121 #[test]
1122 fn test_get_rule_config_tables_false() {
1123 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1125
1126 let inline_config = InlineConfig::from_content(content);
1127 let config_override = inline_config.get_rule_config("MD013");
1128
1129 assert!(config_override.is_some(), "MD013 config should be found");
1130 let json = config_override.unwrap();
1131 let obj = json.as_object().unwrap();
1132 assert!(obj.contains_key("tables"), "Should have tables key");
1133 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1134 }
1135
1136 #[test]
1139 fn test_parse_disable_does_not_match_disable_line() {
1140 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
1142 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
1143 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
1144 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
1145 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
1146 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
1147 }
1148
1149 #[test]
1150 fn test_parse_enable_does_not_match_enable_file() {
1151 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
1152 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
1153 }
1154
1155 #[test]
1156 fn test_parse_disable_comment_edge_cases() {
1157 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
1159
1160 assert_eq!(
1162 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
1163 Some(vec!["MD001", "MD002"])
1164 );
1165
1166 assert_eq!(
1168 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
1169 Some(vec!["MD001"])
1170 );
1171
1172 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
1174
1175 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
1177
1178 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
1180
1181 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1183
1184 assert_eq!(
1186 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
1187 Some(vec!["MD001", "MD001", "MD002"])
1188 );
1189
1190 assert_eq!(
1192 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
1193 Some(vec!["MD001"])
1194 );
1195
1196 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
1198 let comment = format!("<!-- rumdl-disable {many_rules} -->");
1199 let parsed = parse_disable_comment(&comment);
1200 assert!(parsed.is_some());
1201 assert_eq!(parsed.unwrap().len(), 100);
1202
1203 assert_eq!(
1205 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
1206 Some(vec!["MD001-test"])
1207 );
1208 assert_eq!(
1209 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
1210 Some(vec!["custom_rule"])
1211 );
1212 }
1213
1214 #[test]
1215 fn test_parse_enable_comment_edge_cases() {
1216 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
1217 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
1218 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
1219 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
1220 }
1221
1222 #[test]
1225 fn test_disable_inside_fenced_code_block_ignored() {
1226 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
1227 let config = InlineConfig::from_content(content);
1228 assert!(!config.is_rule_disabled("MD001", 6));
1230 }
1231
1232 #[test]
1233 fn test_disable_inside_tilde_fence_ignored() {
1234 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
1235 let config = InlineConfig::from_content(content);
1236 assert!(!config.is_rule_disabled("MD001", 6));
1237 }
1238
1239 #[test]
1240 fn test_disable_before_code_block_persists_after() {
1241 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
1243 let config = InlineConfig::from_content(content);
1244 assert!(config.is_rule_disabled("MD001", 5));
1245 }
1246
1247 #[test]
1248 fn test_enable_inside_code_block_ignored() {
1249 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
1251 let config = InlineConfig::from_content(content);
1252 assert!(config.is_rule_disabled("MD001", 5));
1253 }
1254
1255 #[test]
1258 fn test_markdownlint_disable_rumdl_enable_interop() {
1259 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
1260 let config = InlineConfig::from_content(content);
1261 assert!(config.is_rule_disabled("MD001", 2));
1262 assert!(!config.is_rule_disabled("MD001", 4));
1263 }
1264
1265 #[test]
1266 fn test_rumdl_disable_markdownlint_enable_interop() {
1267 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
1268 let config = InlineConfig::from_content(content);
1269 assert!(config.is_rule_disabled("MD013", 2));
1270 assert!(!config.is_rule_disabled("MD013", 4));
1271 }
1272
1273 #[test]
1276 fn test_global_disable_then_specific_enable() {
1277 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
1278 let config = InlineConfig::from_content(content);
1279 assert!(!config.is_rule_disabled("MD001", 4));
1280 assert!(config.is_rule_disabled("MD002", 4));
1281 assert!(config.is_rule_disabled("MD013", 4));
1282 }
1283
1284 #[test]
1285 fn test_specific_disable_then_global_enable() {
1286 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
1287 let config = InlineConfig::from_content(content);
1288 assert!(config.is_rule_disabled("MD001", 2));
1289 assert!(config.is_rule_disabled("MD002", 2));
1290 assert!(!config.is_rule_disabled("MD001", 4));
1291 assert!(!config.is_rule_disabled("MD002", 4));
1292 }
1293
1294 #[test]
1295 fn test_multiple_rules_disable_enable_independently() {
1296 let content = "\
1297Line 1\n\
1298<!-- rumdl-disable MD001 MD002 -->\n\
1299Line 3\n\
1300<!-- rumdl-enable MD001 -->\n\
1301Line 5\n\
1302<!-- rumdl-disable -->\n\
1303Line 7\n\
1304<!-- rumdl-enable MD002 -->\n\
1305Line 9\n";
1306 let config = InlineConfig::from_content(content);
1307
1308 assert!(!config.is_rule_disabled("MD001", 1));
1310 assert!(!config.is_rule_disabled("MD002", 1));
1311
1312 assert!(config.is_rule_disabled("MD001", 3));
1314 assert!(config.is_rule_disabled("MD002", 3));
1315
1316 assert!(!config.is_rule_disabled("MD001", 5));
1318 assert!(config.is_rule_disabled("MD002", 5));
1319
1320 assert!(config.is_rule_disabled("MD001", 7));
1322 assert!(config.is_rule_disabled("MD002", 7));
1323
1324 assert!(config.is_rule_disabled("MD001", 9));
1326 assert!(!config.is_rule_disabled("MD002", 9));
1327 }
1328
1329 #[test]
1332 fn test_empty_content() {
1333 let config = InlineConfig::from_content("");
1334 assert!(!config.is_rule_disabled("MD001", 1));
1335 }
1336
1337 #[test]
1338 fn test_single_disable_comment_only() {
1339 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
1342 assert!(!config.is_rule_disabled("MD001", 1));
1343 assert!(config.is_rule_disabled("MD001", 2));
1344 assert!(config.is_rule_disabled("MD999", 2));
1345
1346 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
1348 assert!(!config.is_rule_disabled("MD001", 1));
1349 assert!(config.is_rule_disabled("MD001", 2));
1350 assert!(config.is_rule_disabled("MD001", 3));
1351 }
1352
1353 #[test]
1354 fn test_no_inline_markers() {
1355 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
1356 assert!(!config.is_rule_disabled("MD001", 1));
1357 assert!(!config.is_rule_disabled("MD001", 5));
1358 }
1359
1360 #[test]
1363 fn test_export_for_file_index_persistent_transitions() {
1364 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
1365 let config = InlineConfig::from_content(content);
1366 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
1367
1368 assert!(file_disabled.is_empty());
1369 assert!(
1371 persistent.len() >= 2,
1372 "Expected at least 2 transitions, got {}",
1373 persistent.len()
1374 );
1375 }
1376
1377 #[test]
1378 fn test_export_for_file_index_disable_file() {
1379 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
1380 let config = InlineConfig::from_content(content);
1381 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
1382
1383 assert!(file_disabled.contains("MD001"));
1384 }
1385
1386 #[test]
1387 fn test_export_for_file_index_disable_line() {
1388 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
1389 let config = InlineConfig::from_content(content);
1390 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
1391
1392 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
1393 assert!(line_disabled[&2].contains("MD001"));
1394 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
1395 }
1396}