1use dyn_clone::DynClone;
6use serde::{Deserialize, Serialize};
7use std::ops::Range;
8use thiserror::Error;
9
10use crate::lint_context::LintContext;
12
13#[macro_export]
15macro_rules! impl_rule_clone {
16 ($ty:ty) => {
17 impl $ty {
18 fn box_clone(&self) -> Box<dyn Rule> {
19 Box::new(self.clone())
20 }
21 }
22 };
23}
24
25#[derive(Debug, Error)]
26pub enum LintError {
27 #[error("Invalid input: {0}")]
28 InvalidInput(String),
29 #[error("Fix failed: {0}")]
30 FixFailed(String),
31 #[error("IO error: {0}")]
32 IoError(#[from] std::io::Error),
33 #[error("Parsing error: {0}")]
34 ParsingError(String),
35}
36
37pub type LintResult = Result<Vec<LintWarning>, LintError>;
38
39#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
40pub struct LintWarning {
41 pub message: String,
42 pub line: usize, pub column: usize, pub end_line: usize, pub end_column: usize, pub severity: Severity,
47 pub fix: Option<Fix>,
48 pub rule_name: Option<String>,
49}
50
51#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
52pub struct Fix {
53 pub range: Range<usize>,
54 pub replacement: String,
55}
56
57#[derive(Debug, PartialEq, Clone, Copy, Serialize)]
58pub enum Severity {
59 Error,
60 Warning,
61}
62
63impl<'de> serde::Deserialize<'de> for Severity {
64 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65 where
66 D: serde::Deserializer<'de>,
67 {
68 let s = String::deserialize(deserializer)?;
69 match s.to_lowercase().as_str() {
70 "error" => Ok(Severity::Error),
71 "warning" => Ok(Severity::Warning),
72 _ => Err(serde::de::Error::custom(format!(
73 "Invalid severity: '{s}'. Valid values: error, warning"
74 ))),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RuleCategory {
82 Heading,
83 List,
84 CodeBlock,
85 Link,
86 Image,
87 Html,
88 Emphasis,
89 Whitespace,
90 Blockquote,
91 Table,
92 FrontMatter,
93 Other,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum FixCapability {
99 FullyFixable,
101 ConditionallyFixable,
103 Unfixable,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum CrossFileScope {
113 #[default]
115 None,
116 Workspace,
118}
119
120pub trait Rule: DynClone + Send + Sync {
122 fn name(&self) -> &'static str;
123 fn description(&self) -> &'static str;
124 fn check(&self, ctx: &LintContext) -> LintResult;
125 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
126
127 fn should_skip(&self, _ctx: &LintContext) -> bool {
129 false
130 }
131
132 fn category(&self) -> RuleCategory {
134 RuleCategory::Other }
136
137 fn as_any(&self) -> &dyn std::any::Any;
138
139 fn default_config_section(&self) -> Option<(String, toml::Value)> {
148 None
149 }
150
151 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
154 None
155 }
156
157 fn fix_capability(&self) -> FixCapability {
159 FixCapability::FullyFixable }
161
162 fn cross_file_scope(&self) -> CrossFileScope {
168 CrossFileScope::None
169 }
170
171 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
180 }
182
183 fn cross_file_check(
199 &self,
200 _file_path: &std::path::Path,
201 _file_index: &crate::workspace_index::FileIndex,
202 _workspace_index: &crate::workspace_index::WorkspaceIndex,
203 ) -> LintResult {
204 Ok(Vec::new()) }
206
207 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
209 where
210 Self: Sized,
211 {
212 panic!(
213 "from_config not implemented for rule: {}",
214 std::any::type_name::<Self>()
215 );
216 }
217}
218
219dyn_clone::clone_trait_object!(Rule);
221
222pub trait RuleExt {
224 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
225}
226
227impl<R: Rule + 'static> RuleExt for Box<R> {
228 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
229 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
230 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
231 } else {
232 None
233 }
234 }
235}
236
237pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
239 let lines: Vec<&str> = content.lines().collect();
240 let mut is_disabled = false;
241
242 for (i, line) in lines.iter().enumerate() {
244 if i > line_num {
246 break;
247 }
248
249 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
251 continue;
252 }
253
254 let line = line.trim();
255
256 if let Some(rules) = parse_disable_comment(line)
258 && (rules.is_empty() || rules.contains(&rule_name))
259 {
260 is_disabled = true;
261 continue;
262 }
263
264 if let Some(rules) = parse_enable_comment(line)
266 && (rules.is_empty() || rules.contains(&rule_name))
267 {
268 is_disabled = false;
269 continue;
270 }
271 }
272
273 is_disabled
274}
275
276pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
278 if let Some(start) = line.find("<!-- rumdl-disable") {
280 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
281
282 if after_prefix.trim_start().starts_with("-->") {
284 return Some(Vec::new()); }
286
287 if let Some(end) = after_prefix.find("-->") {
289 let rules_str = after_prefix[..end].trim();
290 if !rules_str.is_empty() {
291 let rules: Vec<&str> = rules_str.split_whitespace().collect();
292 return Some(rules);
293 }
294 }
295 }
296
297 if let Some(start) = line.find("<!-- markdownlint-disable") {
299 let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
300
301 if after_prefix.trim_start().starts_with("-->") {
303 return Some(Vec::new()); }
305
306 if let Some(end) = after_prefix.find("-->") {
308 let rules_str = after_prefix[..end].trim();
309 if !rules_str.is_empty() {
310 let rules: Vec<&str> = rules_str.split_whitespace().collect();
311 return Some(rules);
312 }
313 }
314 }
315
316 None
317}
318
319pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
321 if let Some(start) = line.find("<!-- rumdl-enable") {
323 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
324
325 if after_prefix.trim_start().starts_with("-->") {
327 return Some(Vec::new()); }
329
330 if let Some(end) = after_prefix.find("-->") {
332 let rules_str = after_prefix[..end].trim();
333 if !rules_str.is_empty() {
334 let rules: Vec<&str> = rules_str.split_whitespace().collect();
335 return Some(rules);
336 }
337 }
338 }
339
340 if let Some(start) = line.find("<!-- markdownlint-enable") {
342 let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
343
344 if after_prefix.trim_start().starts_with("-->") {
346 return Some(Vec::new()); }
348
349 if let Some(end) = after_prefix.find("-->") {
351 let rules_str = after_prefix[..end].trim();
352 if !rules_str.is_empty() {
353 let rules: Vec<&str> = rules_str.split_whitespace().collect();
354 return Some(rules);
355 }
356 }
357 }
358
359 None
360}
361
362pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
364 let lines: Vec<&str> = content.lines().collect();
366 is_rule_disabled_at_line(content, rule_name, lines.len())
367}
368
369#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_parse_disable_comment() {
411 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
413
414 assert_eq!(
416 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
417 Some(vec!["MD001", "MD002"])
418 );
419
420 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
422
423 assert_eq!(
425 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
426 Some(vec!["MD001", "MD002"])
427 );
428
429 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
431
432 assert_eq!(
434 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
435 Some(vec!["MD013"])
436 );
437 }
438
439 #[test]
440 fn test_parse_enable_comment() {
441 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
443
444 assert_eq!(
446 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
447 Some(vec!["MD001", "MD002"])
448 );
449
450 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
452
453 assert_eq!(
455 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
456 Some(vec!["MD001", "MD002"])
457 );
458
459 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
461 }
462
463 #[test]
464 fn test_is_rule_disabled_at_line() {
465 let content = r#"# Test
466<!-- rumdl-disable MD013 -->
467This is a long line
468<!-- rumdl-enable MD013 -->
469This is another line
470<!-- markdownlint-disable MD042 -->
471Empty link: []()
472<!-- markdownlint-enable MD042 -->
473Final line"#;
474
475 assert!(is_rule_disabled_at_line(content, "MD013", 2));
477
478 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
480
481 assert!(is_rule_disabled_at_line(content, "MD042", 6));
483
484 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
486
487 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
489 }
490
491 #[test]
492 fn test_parse_disable_comment_edge_cases() {
493 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
495
496 assert_eq!(
498 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
499 None
500 );
501
502 assert_eq!(
504 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
505 Some(vec!["MD001", "MD002"])
506 );
507
508 assert_eq!(
510 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
511 Some(vec!["MD001"])
512 );
513
514 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
516
517 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
519
520 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
522 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
523
524 assert_eq!(
526 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
527 Some(vec!["MD001"])
528 );
529
530 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
532
533 assert_eq!(
535 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
536 Some(vec!["MD001", "MD001", "MD002"])
537 );
538 }
539
540 #[test]
541 fn test_parse_enable_comment_edge_cases() {
542 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
544
545 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
547
548 assert_eq!(
550 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
551 Some(vec!["MD001", "MD002"])
552 );
553
554 assert_eq!(
556 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
557 Some(vec!["MD001"])
558 );
559
560 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
562
563 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
565
566 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
568 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
569
570 assert_eq!(
572 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
573 Some(vec!["MD001"])
574 );
575
576 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
578
579 assert_eq!(
581 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
582 Some(vec!["MD001", "MD001", "MD002"])
583 );
584 }
585
586 #[test]
587 fn test_nested_disable_enable_comments() {
588 let content = r#"# Document
589<!-- rumdl-disable -->
590All rules disabled here
591<!-- rumdl-disable MD001 -->
592Still all disabled (redundant)
593<!-- rumdl-enable MD001 -->
594Only MD001 enabled, others still disabled
595<!-- rumdl-enable -->
596All rules enabled again"#;
597
598 assert!(is_rule_disabled_at_line(content, "MD001", 2));
600 assert!(is_rule_disabled_at_line(content, "MD002", 2));
601
602 assert!(is_rule_disabled_at_line(content, "MD001", 4));
604 assert!(is_rule_disabled_at_line(content, "MD002", 4));
605
606 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
608 assert!(is_rule_disabled_at_line(content, "MD002", 6));
609
610 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
612 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
613 }
614
615 #[test]
616 fn test_mixed_comment_styles() {
617 let content = r#"# Document
618<!-- markdownlint-disable MD001 -->
619MD001 disabled via markdownlint
620<!-- rumdl-enable MD001 -->
621MD001 enabled via rumdl
622<!-- rumdl-disable -->
623All disabled via rumdl
624<!-- markdownlint-enable -->
625All enabled via markdownlint"#;
626
627 assert!(is_rule_disabled_at_line(content, "MD001", 2));
629 assert!(!is_rule_disabled_at_line(content, "MD002", 2));
630
631 assert!(!is_rule_disabled_at_line(content, "MD001", 4));
633 assert!(!is_rule_disabled_at_line(content, "MD002", 4));
634
635 assert!(is_rule_disabled_at_line(content, "MD001", 6));
637 assert!(is_rule_disabled_at_line(content, "MD002", 6));
638
639 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
641 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
642 }
643
644 #[test]
645 fn test_comments_in_code_blocks() {
646 let content = r#"# Document
647```markdown
648<!-- rumdl-disable MD001 -->
649This is in a code block, should not affect rules
650```
651MD001 should still be enabled here"#;
652
653 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
655
656 let indented_content = r#"# Document
658
659 <!-- rumdl-disable MD001 -->
660 This is in an indented code block
661
662MD001 should still be enabled here"#;
663
664 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
665 }
666
667 #[test]
668 fn test_comments_with_unicode() {
669 assert_eq!(
671 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
672 Some(vec!["MD001"])
673 );
674
675 assert_eq!(
676 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
677 Some(vec!["MD001"])
678 );
679 }
680
681 #[test]
682 fn test_rule_disabled_at_specific_lines() {
683 let content = r#"Line 0
684<!-- rumdl-disable MD001 MD002 -->
685Line 2
686Line 3
687<!-- rumdl-enable MD001 -->
688Line 5
689<!-- rumdl-disable -->
690Line 7
691<!-- rumdl-enable MD002 -->
692Line 9"#;
693
694 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
696 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
697
698 assert!(is_rule_disabled_at_line(content, "MD001", 2));
699 assert!(is_rule_disabled_at_line(content, "MD002", 2));
700
701 assert!(is_rule_disabled_at_line(content, "MD001", 3));
702 assert!(is_rule_disabled_at_line(content, "MD002", 3));
703
704 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
705 assert!(is_rule_disabled_at_line(content, "MD002", 5));
706
707 assert!(is_rule_disabled_at_line(content, "MD001", 7));
708 assert!(is_rule_disabled_at_line(content, "MD002", 7));
709
710 assert!(is_rule_disabled_at_line(content, "MD001", 9));
711 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
712 }
713
714 #[test]
715 fn test_is_rule_disabled_by_comment() {
716 let content = r#"# Document
717<!-- rumdl-disable MD001 -->
718Content here"#;
719
720 assert!(is_rule_disabled_by_comment(content, "MD001"));
721 assert!(!is_rule_disabled_by_comment(content, "MD002"));
722
723 let content2 = r#"# Document
724<!-- rumdl-disable -->
725Content here"#;
726
727 assert!(is_rule_disabled_by_comment(content2, "MD001"));
728 assert!(is_rule_disabled_by_comment(content2, "MD002"));
729 }
730
731 #[test]
732 fn test_comment_at_end_of_file() {
733 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
734
735 assert!(is_rule_disabled_by_comment(content, "MD001"));
737 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
739 assert!(is_rule_disabled_at_line(content, "MD001", 2));
741 }
742
743 #[test]
744 fn test_multiple_comments_same_line() {
745 assert_eq!(
747 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
748 Some(vec!["MD001"])
749 );
750
751 assert_eq!(
752 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
753 Some(vec!["MD001"])
754 );
755 }
756
757 #[test]
758 fn test_severity_serialization() {
759 let warning = LintWarning {
760 message: "Test warning".to_string(),
761 line: 1,
762 column: 1,
763 end_line: 1,
764 end_column: 10,
765 severity: Severity::Warning,
766 fix: None,
767 rule_name: Some("MD001".to_string()),
768 };
769
770 let serialized = serde_json::to_string(&warning).unwrap();
771 assert!(serialized.contains("\"severity\":\"Warning\""));
772
773 let error = LintWarning {
774 severity: Severity::Error,
775 ..warning
776 };
777
778 let serialized = serde_json::to_string(&error).unwrap();
779 assert!(serialized.contains("\"severity\":\"Error\""));
780 }
781
782 #[test]
783 fn test_fix_serialization() {
784 let fix = Fix {
785 range: 0..10,
786 replacement: "fixed text".to_string(),
787 };
788
789 let warning = LintWarning {
790 message: "Test warning".to_string(),
791 line: 1,
792 column: 1,
793 end_line: 1,
794 end_column: 10,
795 severity: Severity::Warning,
796 fix: Some(fix),
797 rule_name: Some("MD001".to_string()),
798 };
799
800 let serialized = serde_json::to_string(&warning).unwrap();
801 assert!(serialized.contains("\"fix\""));
802 assert!(serialized.contains("\"replacement\":\"fixed text\""));
803 }
804
805 #[test]
806 fn test_rule_category_equality() {
807 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
808 assert_ne!(RuleCategory::Heading, RuleCategory::List);
809
810 let categories = [
812 RuleCategory::Heading,
813 RuleCategory::List,
814 RuleCategory::CodeBlock,
815 RuleCategory::Link,
816 RuleCategory::Image,
817 RuleCategory::Html,
818 RuleCategory::Emphasis,
819 RuleCategory::Whitespace,
820 RuleCategory::Blockquote,
821 RuleCategory::Table,
822 RuleCategory::FrontMatter,
823 RuleCategory::Other,
824 ];
825
826 for (i, cat1) in categories.iter().enumerate() {
827 for (j, cat2) in categories.iter().enumerate() {
828 if i == j {
829 assert_eq!(cat1, cat2);
830 } else {
831 assert_ne!(cat1, cat2);
832 }
833 }
834 }
835 }
836
837 #[test]
838 fn test_lint_error_conversions() {
839 use std::io;
840
841 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
843 let lint_error: LintError = io_error.into();
844 match lint_error {
845 LintError::IoError(_) => {}
846 _ => panic!("Expected IoError variant"),
847 }
848
849 let invalid_input = LintError::InvalidInput("bad input".to_string());
851 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
852
853 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
854 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
855
856 let parsing_error = LintError::ParsingError("parse error".to_string());
857 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
858 }
859
860 #[test]
861 fn test_empty_content_edge_cases() {
862 assert!(!is_rule_disabled_at_line("", "MD001", 0));
863 assert!(!is_rule_disabled_by_comment("", "MD001"));
864
865 let single_comment = "<!-- rumdl-disable -->";
867 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
868 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
869 }
870
871 #[test]
872 fn test_very_long_rule_list() {
873 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
874 let comment = format!("<!-- rumdl-disable {many_rules} -->");
875
876 let parsed = parse_disable_comment(&comment);
877 assert!(parsed.is_some());
878 assert_eq!(parsed.unwrap().len(), 100);
879 }
880
881 #[test]
882 fn test_comment_with_special_characters() {
883 assert_eq!(
885 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
886 Some(vec!["MD001-test"])
887 );
888
889 assert_eq!(
890 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
891 Some(vec!["MD_001"])
892 );
893
894 assert_eq!(
895 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
896 Some(vec!["MD.001"])
897 );
898 }
899}