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