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