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, schemars::JsonSchema)]
58pub enum Severity {
59 Error,
60 Warning,
61 Info,
62}
63
64impl<'de> serde::Deserialize<'de> for Severity {
65 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66 where
67 D: serde::Deserializer<'de>,
68 {
69 let s = String::deserialize(deserializer)?;
70 match s.to_lowercase().as_str() {
71 "error" => Ok(Severity::Error),
72 "warning" => Ok(Severity::Warning),
73 "info" => Ok(Severity::Info),
74 _ => Err(serde::de::Error::custom(format!(
75 "Invalid severity: '{s}'. Valid values: error, warning, info"
76 ))),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum RuleCategory {
84 Heading,
85 List,
86 CodeBlock,
87 Link,
88 Image,
89 Html,
90 Emphasis,
91 Whitespace,
92 Blockquote,
93 Table,
94 FrontMatter,
95 Other,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum FixCapability {
101 FullyFixable,
103 ConditionallyFixable,
105 Unfixable,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum CrossFileScope {
115 #[default]
117 None,
118 Workspace,
120}
121
122pub trait Rule: DynClone + Send + Sync {
124 fn name(&self) -> &'static str;
125 fn description(&self) -> &'static str;
126 fn check(&self, ctx: &LintContext) -> LintResult;
127 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
128
129 fn should_skip(&self, _ctx: &LintContext) -> bool {
131 false
132 }
133
134 fn category(&self) -> RuleCategory {
136 RuleCategory::Other }
138
139 fn as_any(&self) -> &dyn std::any::Any;
140
141 fn default_config_section(&self) -> Option<(String, toml::Value)> {
150 None
151 }
152
153 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
156 None
157 }
158
159 fn fix_capability(&self) -> FixCapability {
161 FixCapability::FullyFixable }
163
164 fn cross_file_scope(&self) -> CrossFileScope {
170 CrossFileScope::None
171 }
172
173 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
182 }
184
185 fn cross_file_check(
201 &self,
202 _file_path: &std::path::Path,
203 _file_index: &crate::workspace_index::FileIndex,
204 _workspace_index: &crate::workspace_index::WorkspaceIndex,
205 ) -> LintResult {
206 Ok(Vec::new()) }
208
209 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
211 where
212 Self: Sized,
213 {
214 panic!(
215 "from_config not implemented for rule: {}",
216 std::any::type_name::<Self>()
217 );
218 }
219}
220
221dyn_clone::clone_trait_object!(Rule);
223
224pub trait RuleExt {
226 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
227}
228
229impl<R: Rule + 'static> RuleExt for Box<R> {
230 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
231 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
232 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
233 } else {
234 None
235 }
236 }
237}
238
239pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
241 let lines: Vec<&str> = content.lines().collect();
242 let mut is_disabled = false;
243
244 for (i, line) in lines.iter().enumerate() {
246 if i > line_num {
248 break;
249 }
250
251 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
253 continue;
254 }
255
256 let line = line.trim();
257
258 if let Some(rules) = parse_disable_comment(line)
260 && (rules.is_empty() || rules.contains(&rule_name))
261 {
262 is_disabled = true;
263 continue;
264 }
265
266 if let Some(rules) = parse_enable_comment(line)
268 && (rules.is_empty() || rules.contains(&rule_name))
269 {
270 is_disabled = false;
271 continue;
272 }
273 }
274
275 is_disabled
276}
277
278pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
280 if let Some(start) = line.find("<!-- rumdl-disable") {
282 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
283
284 if after_prefix.trim_start().starts_with("-->") {
286 return Some(Vec::new()); }
288
289 if let Some(end) = after_prefix.find("-->") {
291 let rules_str = after_prefix[..end].trim();
292 if !rules_str.is_empty() {
293 let rules: Vec<&str> = rules_str.split_whitespace().collect();
294 return Some(rules);
295 }
296 }
297 }
298
299 if let Some(start) = line.find("<!-- markdownlint-disable") {
301 let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
302
303 if after_prefix.trim_start().starts_with("-->") {
305 return Some(Vec::new()); }
307
308 if let Some(end) = after_prefix.find("-->") {
310 let rules_str = after_prefix[..end].trim();
311 if !rules_str.is_empty() {
312 let rules: Vec<&str> = rules_str.split_whitespace().collect();
313 return Some(rules);
314 }
315 }
316 }
317
318 None
319}
320
321pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
323 if let Some(start) = line.find("<!-- rumdl-enable") {
325 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
326
327 if after_prefix.trim_start().starts_with("-->") {
329 return Some(Vec::new()); }
331
332 if let Some(end) = after_prefix.find("-->") {
334 let rules_str = after_prefix[..end].trim();
335 if !rules_str.is_empty() {
336 let rules: Vec<&str> = rules_str.split_whitespace().collect();
337 return Some(rules);
338 }
339 }
340 }
341
342 if let Some(start) = line.find("<!-- markdownlint-enable") {
344 let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
345
346 if after_prefix.trim_start().starts_with("-->") {
348 return Some(Vec::new()); }
350
351 if let Some(end) = after_prefix.find("-->") {
353 let rules_str = after_prefix[..end].trim();
354 if !rules_str.is_empty() {
355 let rules: Vec<&str> = rules_str.split_whitespace().collect();
356 return Some(rules);
357 }
358 }
359 }
360
361 None
362}
363
364pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
366 let lines: Vec<&str> = content.lines().collect();
368 is_rule_disabled_at_line(content, rule_name, lines.len())
369}
370
371#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_parse_disable_comment() {
413 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
415
416 assert_eq!(
418 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
419 Some(vec!["MD001", "MD002"])
420 );
421
422 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
424
425 assert_eq!(
427 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
428 Some(vec!["MD001", "MD002"])
429 );
430
431 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
433
434 assert_eq!(
436 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
437 Some(vec!["MD013"])
438 );
439 }
440
441 #[test]
442 fn test_parse_enable_comment() {
443 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
445
446 assert_eq!(
448 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
449 Some(vec!["MD001", "MD002"])
450 );
451
452 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
454
455 assert_eq!(
457 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
458 Some(vec!["MD001", "MD002"])
459 );
460
461 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
463 }
464
465 #[test]
466 fn test_is_rule_disabled_at_line() {
467 let content = r#"# Test
468<!-- rumdl-disable MD013 -->
469This is a long line
470<!-- rumdl-enable MD013 -->
471This is another line
472<!-- markdownlint-disable MD042 -->
473Empty link: []()
474<!-- markdownlint-enable MD042 -->
475Final line"#;
476
477 assert!(is_rule_disabled_at_line(content, "MD013", 2));
479
480 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
482
483 assert!(is_rule_disabled_at_line(content, "MD042", 6));
485
486 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
488
489 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
491 }
492
493 #[test]
494 fn test_parse_disable_comment_edge_cases() {
495 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
497
498 assert_eq!(
500 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
501 None
502 );
503
504 assert_eq!(
506 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
507 Some(vec!["MD001", "MD002"])
508 );
509
510 assert_eq!(
512 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
513 Some(vec!["MD001"])
514 );
515
516 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
518
519 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
521
522 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
524 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
525
526 assert_eq!(
528 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
529 Some(vec!["MD001"])
530 );
531
532 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
534
535 assert_eq!(
537 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
538 Some(vec!["MD001", "MD001", "MD002"])
539 );
540 }
541
542 #[test]
543 fn test_parse_enable_comment_edge_cases() {
544 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
546
547 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
549
550 assert_eq!(
552 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
553 Some(vec!["MD001", "MD002"])
554 );
555
556 assert_eq!(
558 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
559 Some(vec!["MD001"])
560 );
561
562 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
564
565 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
567
568 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
570 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
571
572 assert_eq!(
574 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
575 Some(vec!["MD001"])
576 );
577
578 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
580
581 assert_eq!(
583 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
584 Some(vec!["MD001", "MD001", "MD002"])
585 );
586 }
587
588 #[test]
589 fn test_nested_disable_enable_comments() {
590 let content = r#"# Document
591<!-- rumdl-disable -->
592All rules disabled here
593<!-- rumdl-disable MD001 -->
594Still all disabled (redundant)
595<!-- rumdl-enable MD001 -->
596Only MD001 enabled, others still disabled
597<!-- rumdl-enable -->
598All rules enabled again"#;
599
600 assert!(is_rule_disabled_at_line(content, "MD001", 2));
602 assert!(is_rule_disabled_at_line(content, "MD002", 2));
603
604 assert!(is_rule_disabled_at_line(content, "MD001", 4));
606 assert!(is_rule_disabled_at_line(content, "MD002", 4));
607
608 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
610 assert!(is_rule_disabled_at_line(content, "MD002", 6));
611
612 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
614 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
615 }
616
617 #[test]
618 fn test_mixed_comment_styles() {
619 let content = r#"# Document
620<!-- markdownlint-disable MD001 -->
621MD001 disabled via markdownlint
622<!-- rumdl-enable MD001 -->
623MD001 enabled via rumdl
624<!-- rumdl-disable -->
625All disabled via rumdl
626<!-- markdownlint-enable -->
627All enabled via markdownlint"#;
628
629 assert!(is_rule_disabled_at_line(content, "MD001", 2));
631 assert!(!is_rule_disabled_at_line(content, "MD002", 2));
632
633 assert!(!is_rule_disabled_at_line(content, "MD001", 4));
635 assert!(!is_rule_disabled_at_line(content, "MD002", 4));
636
637 assert!(is_rule_disabled_at_line(content, "MD001", 6));
639 assert!(is_rule_disabled_at_line(content, "MD002", 6));
640
641 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
643 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
644 }
645
646 #[test]
647 fn test_comments_in_code_blocks() {
648 let content = r#"# Document
649```markdown
650<!-- rumdl-disable MD001 -->
651This is in a code block, should not affect rules
652```
653MD001 should still be enabled here"#;
654
655 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
657
658 let indented_content = r#"# Document
660
661 <!-- rumdl-disable MD001 -->
662 This is in an indented code block
663
664MD001 should still be enabled here"#;
665
666 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
667 }
668
669 #[test]
670 fn test_comments_with_unicode() {
671 assert_eq!(
673 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
674 Some(vec!["MD001"])
675 );
676
677 assert_eq!(
678 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
679 Some(vec!["MD001"])
680 );
681 }
682
683 #[test]
684 fn test_rule_disabled_at_specific_lines() {
685 let content = r#"Line 0
686<!-- rumdl-disable MD001 MD002 -->
687Line 2
688Line 3
689<!-- rumdl-enable MD001 -->
690Line 5
691<!-- rumdl-disable -->
692Line 7
693<!-- rumdl-enable MD002 -->
694Line 9"#;
695
696 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
698 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
699
700 assert!(is_rule_disabled_at_line(content, "MD001", 2));
701 assert!(is_rule_disabled_at_line(content, "MD002", 2));
702
703 assert!(is_rule_disabled_at_line(content, "MD001", 3));
704 assert!(is_rule_disabled_at_line(content, "MD002", 3));
705
706 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
707 assert!(is_rule_disabled_at_line(content, "MD002", 5));
708
709 assert!(is_rule_disabled_at_line(content, "MD001", 7));
710 assert!(is_rule_disabled_at_line(content, "MD002", 7));
711
712 assert!(is_rule_disabled_at_line(content, "MD001", 9));
713 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
714 }
715
716 #[test]
717 fn test_is_rule_disabled_by_comment() {
718 let content = r#"# Document
719<!-- rumdl-disable MD001 -->
720Content here"#;
721
722 assert!(is_rule_disabled_by_comment(content, "MD001"));
723 assert!(!is_rule_disabled_by_comment(content, "MD002"));
724
725 let content2 = r#"# Document
726<!-- rumdl-disable -->
727Content here"#;
728
729 assert!(is_rule_disabled_by_comment(content2, "MD001"));
730 assert!(is_rule_disabled_by_comment(content2, "MD002"));
731 }
732
733 #[test]
734 fn test_comment_at_end_of_file() {
735 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
736
737 assert!(is_rule_disabled_by_comment(content, "MD001"));
739 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
741 assert!(is_rule_disabled_at_line(content, "MD001", 2));
743 }
744
745 #[test]
746 fn test_multiple_comments_same_line() {
747 assert_eq!(
749 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
750 Some(vec!["MD001"])
751 );
752
753 assert_eq!(
754 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
755 Some(vec!["MD001"])
756 );
757 }
758
759 #[test]
760 fn test_severity_serialization() {
761 let warning = LintWarning {
762 message: "Test warning".to_string(),
763 line: 1,
764 column: 1,
765 end_line: 1,
766 end_column: 10,
767 severity: Severity::Warning,
768 fix: None,
769 rule_name: Some("MD001".to_string()),
770 };
771
772 let serialized = serde_json::to_string(&warning).unwrap();
773 assert!(serialized.contains("\"severity\":\"Warning\""));
774
775 let error = LintWarning {
776 severity: Severity::Error,
777 ..warning
778 };
779
780 let serialized = serde_json::to_string(&error).unwrap();
781 assert!(serialized.contains("\"severity\":\"Error\""));
782 }
783
784 #[test]
785 fn test_fix_serialization() {
786 let fix = Fix {
787 range: 0..10,
788 replacement: "fixed text".to_string(),
789 };
790
791 let warning = LintWarning {
792 message: "Test warning".to_string(),
793 line: 1,
794 column: 1,
795 end_line: 1,
796 end_column: 10,
797 severity: Severity::Warning,
798 fix: Some(fix),
799 rule_name: Some("MD001".to_string()),
800 };
801
802 let serialized = serde_json::to_string(&warning).unwrap();
803 assert!(serialized.contains("\"fix\""));
804 assert!(serialized.contains("\"replacement\":\"fixed text\""));
805 }
806
807 #[test]
808 fn test_rule_category_equality() {
809 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
810 assert_ne!(RuleCategory::Heading, RuleCategory::List);
811
812 let categories = [
814 RuleCategory::Heading,
815 RuleCategory::List,
816 RuleCategory::CodeBlock,
817 RuleCategory::Link,
818 RuleCategory::Image,
819 RuleCategory::Html,
820 RuleCategory::Emphasis,
821 RuleCategory::Whitespace,
822 RuleCategory::Blockquote,
823 RuleCategory::Table,
824 RuleCategory::FrontMatter,
825 RuleCategory::Other,
826 ];
827
828 for (i, cat1) in categories.iter().enumerate() {
829 for (j, cat2) in categories.iter().enumerate() {
830 if i == j {
831 assert_eq!(cat1, cat2);
832 } else {
833 assert_ne!(cat1, cat2);
834 }
835 }
836 }
837 }
838
839 #[test]
840 fn test_lint_error_conversions() {
841 use std::io;
842
843 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
845 let lint_error: LintError = io_error.into();
846 match lint_error {
847 LintError::IoError(_) => {}
848 _ => panic!("Expected IoError variant"),
849 }
850
851 let invalid_input = LintError::InvalidInput("bad input".to_string());
853 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
854
855 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
856 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
857
858 let parsing_error = LintError::ParsingError("parse error".to_string());
859 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
860 }
861
862 #[test]
863 fn test_empty_content_edge_cases() {
864 assert!(!is_rule_disabled_at_line("", "MD001", 0));
865 assert!(!is_rule_disabled_by_comment("", "MD001"));
866
867 let single_comment = "<!-- rumdl-disable -->";
869 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
870 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
871 }
872
873 #[test]
874 fn test_very_long_rule_list() {
875 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
876 let comment = format!("<!-- rumdl-disable {many_rules} -->");
877
878 let parsed = parse_disable_comment(&comment);
879 assert!(parsed.is_some());
880 assert_eq!(parsed.unwrap().len(), 100);
881 }
882
883 #[test]
884 fn test_comment_with_special_characters() {
885 assert_eq!(
887 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
888 Some(vec!["MD001-test"])
889 );
890
891 assert_eq!(
892 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
893 Some(vec!["MD_001"])
894 );
895
896 assert_eq!(
897 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
898 Some(vec!["MD.001"])
899 );
900 }
901}