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, Deserialize)]
58pub enum Severity {
59 Error,
60 Warning,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum RuleCategory {
66 Heading,
67 List,
68 CodeBlock,
69 Link,
70 Image,
71 Html,
72 Emphasis,
73 Whitespace,
74 Blockquote,
75 Table,
76 FrontMatter,
77 Other,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum FixCapability {
83 FullyFixable,
85 ConditionallyFixable,
87 Unfixable,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum CrossFileScope {
97 #[default]
99 None,
100 Workspace,
102}
103
104pub trait Rule: DynClone + Send + Sync {
106 fn name(&self) -> &'static str;
107 fn description(&self) -> &'static str;
108 fn check(&self, ctx: &LintContext) -> LintResult;
109 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
110
111 fn should_skip(&self, _ctx: &LintContext) -> bool {
113 false
114 }
115
116 fn category(&self) -> RuleCategory {
118 RuleCategory::Other }
120
121 fn as_any(&self) -> &dyn std::any::Any;
122
123 fn default_config_section(&self) -> Option<(String, toml::Value)> {
132 None
133 }
134
135 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
138 None
139 }
140
141 fn fix_capability(&self) -> FixCapability {
143 FixCapability::FullyFixable }
145
146 fn cross_file_scope(&self) -> CrossFileScope {
152 CrossFileScope::None
153 }
154
155 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
164 }
166
167 fn cross_file_check(
183 &self,
184 _file_path: &std::path::Path,
185 _file_index: &crate::workspace_index::FileIndex,
186 _workspace_index: &crate::workspace_index::WorkspaceIndex,
187 ) -> LintResult {
188 Ok(Vec::new()) }
190
191 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
193 where
194 Self: Sized,
195 {
196 panic!(
197 "from_config not implemented for rule: {}",
198 std::any::type_name::<Self>()
199 );
200 }
201}
202
203dyn_clone::clone_trait_object!(Rule);
205
206pub trait RuleExt {
208 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
209}
210
211impl<R: Rule + 'static> RuleExt for Box<R> {
212 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
213 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
214 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
215 } else {
216 None
217 }
218 }
219}
220
221pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
223 let lines: Vec<&str> = content.lines().collect();
224 let mut is_disabled = false;
225
226 for (i, line) in lines.iter().enumerate() {
228 if i > line_num {
230 break;
231 }
232
233 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
235 continue;
236 }
237
238 let line = line.trim();
239
240 if let Some(rules) = parse_disable_comment(line)
242 && (rules.is_empty() || rules.contains(&rule_name))
243 {
244 is_disabled = true;
245 continue;
246 }
247
248 if let Some(rules) = parse_enable_comment(line)
250 && (rules.is_empty() || rules.contains(&rule_name))
251 {
252 is_disabled = false;
253 continue;
254 }
255 }
256
257 is_disabled
258}
259
260pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
262 if let Some(start) = line.find("<!-- rumdl-disable") {
264 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
265
266 if after_prefix.trim_start().starts_with("-->") {
268 return Some(Vec::new()); }
270
271 if let Some(end) = after_prefix.find("-->") {
273 let rules_str = after_prefix[..end].trim();
274 if !rules_str.is_empty() {
275 let rules: Vec<&str> = rules_str.split_whitespace().collect();
276 return Some(rules);
277 }
278 }
279 }
280
281 if let Some(start) = line.find("<!-- markdownlint-disable") {
283 let after_prefix = &line[start + "<!-- markdownlint-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 None
301}
302
303pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
305 if let Some(start) = line.find("<!-- rumdl-enable") {
307 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
308
309 if after_prefix.trim_start().starts_with("-->") {
311 return Some(Vec::new()); }
313
314 if let Some(end) = after_prefix.find("-->") {
316 let rules_str = after_prefix[..end].trim();
317 if !rules_str.is_empty() {
318 let rules: Vec<&str> = rules_str.split_whitespace().collect();
319 return Some(rules);
320 }
321 }
322 }
323
324 if let Some(start) = line.find("<!-- markdownlint-enable") {
326 let after_prefix = &line[start + "<!-- markdownlint-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 None
344}
345
346pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
348 let lines: Vec<&str> = content.lines().collect();
350 is_rule_disabled_at_line(content, rule_name, lines.len())
351}
352
353#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_parse_disable_comment() {
395 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
397
398 assert_eq!(
400 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
401 Some(vec!["MD001", "MD002"])
402 );
403
404 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
406
407 assert_eq!(
409 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
410 Some(vec!["MD001", "MD002"])
411 );
412
413 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
415
416 assert_eq!(
418 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
419 Some(vec!["MD013"])
420 );
421 }
422
423 #[test]
424 fn test_parse_enable_comment() {
425 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
427
428 assert_eq!(
430 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
431 Some(vec!["MD001", "MD002"])
432 );
433
434 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
436
437 assert_eq!(
439 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
440 Some(vec!["MD001", "MD002"])
441 );
442
443 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
445 }
446
447 #[test]
448 fn test_is_rule_disabled_at_line() {
449 let content = r#"# Test
450<!-- rumdl-disable MD013 -->
451This is a long line
452<!-- rumdl-enable MD013 -->
453This is another line
454<!-- markdownlint-disable MD042 -->
455Empty link: []()
456<!-- markdownlint-enable MD042 -->
457Final line"#;
458
459 assert!(is_rule_disabled_at_line(content, "MD013", 2));
461
462 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
464
465 assert!(is_rule_disabled_at_line(content, "MD042", 6));
467
468 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
470
471 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
473 }
474
475 #[test]
476 fn test_parse_disable_comment_edge_cases() {
477 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
479
480 assert_eq!(
482 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
483 None
484 );
485
486 assert_eq!(
488 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
489 Some(vec!["MD001", "MD002"])
490 );
491
492 assert_eq!(
494 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
495 Some(vec!["MD001"])
496 );
497
498 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
500
501 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
503
504 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
506 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
507
508 assert_eq!(
510 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
511 Some(vec!["MD001"])
512 );
513
514 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
516
517 assert_eq!(
519 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
520 Some(vec!["MD001", "MD001", "MD002"])
521 );
522 }
523
524 #[test]
525 fn test_parse_enable_comment_edge_cases() {
526 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
528
529 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
531
532 assert_eq!(
534 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
535 Some(vec!["MD001", "MD002"])
536 );
537
538 assert_eq!(
540 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
541 Some(vec!["MD001"])
542 );
543
544 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
546
547 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
549
550 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
552 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
553
554 assert_eq!(
556 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
557 Some(vec!["MD001"])
558 );
559
560 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
562
563 assert_eq!(
565 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
566 Some(vec!["MD001", "MD001", "MD002"])
567 );
568 }
569
570 #[test]
571 fn test_nested_disable_enable_comments() {
572 let content = r#"# Document
573<!-- rumdl-disable -->
574All rules disabled here
575<!-- rumdl-disable MD001 -->
576Still all disabled (redundant)
577<!-- rumdl-enable MD001 -->
578Only MD001 enabled, others still disabled
579<!-- rumdl-enable -->
580All rules enabled again"#;
581
582 assert!(is_rule_disabled_at_line(content, "MD001", 2));
584 assert!(is_rule_disabled_at_line(content, "MD002", 2));
585
586 assert!(is_rule_disabled_at_line(content, "MD001", 4));
588 assert!(is_rule_disabled_at_line(content, "MD002", 4));
589
590 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
592 assert!(is_rule_disabled_at_line(content, "MD002", 6));
593
594 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
596 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
597 }
598
599 #[test]
600 fn test_mixed_comment_styles() {
601 let content = r#"# Document
602<!-- markdownlint-disable MD001 -->
603MD001 disabled via markdownlint
604<!-- rumdl-enable MD001 -->
605MD001 enabled via rumdl
606<!-- rumdl-disable -->
607All disabled via rumdl
608<!-- markdownlint-enable -->
609All enabled via markdownlint"#;
610
611 assert!(is_rule_disabled_at_line(content, "MD001", 2));
613 assert!(!is_rule_disabled_at_line(content, "MD002", 2));
614
615 assert!(!is_rule_disabled_at_line(content, "MD001", 4));
617 assert!(!is_rule_disabled_at_line(content, "MD002", 4));
618
619 assert!(is_rule_disabled_at_line(content, "MD001", 6));
621 assert!(is_rule_disabled_at_line(content, "MD002", 6));
622
623 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
625 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
626 }
627
628 #[test]
629 fn test_comments_in_code_blocks() {
630 let content = r#"# Document
631```markdown
632<!-- rumdl-disable MD001 -->
633This is in a code block, should not affect rules
634```
635MD001 should still be enabled here"#;
636
637 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
639
640 let indented_content = r#"# Document
642
643 <!-- rumdl-disable MD001 -->
644 This is in an indented code block
645
646MD001 should still be enabled here"#;
647
648 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
649 }
650
651 #[test]
652 fn test_comments_with_unicode() {
653 assert_eq!(
655 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
656 Some(vec!["MD001"])
657 );
658
659 assert_eq!(
660 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
661 Some(vec!["MD001"])
662 );
663 }
664
665 #[test]
666 fn test_rule_disabled_at_specific_lines() {
667 let content = r#"Line 0
668<!-- rumdl-disable MD001 MD002 -->
669Line 2
670Line 3
671<!-- rumdl-enable MD001 -->
672Line 5
673<!-- rumdl-disable -->
674Line 7
675<!-- rumdl-enable MD002 -->
676Line 9"#;
677
678 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
680 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
681
682 assert!(is_rule_disabled_at_line(content, "MD001", 2));
683 assert!(is_rule_disabled_at_line(content, "MD002", 2));
684
685 assert!(is_rule_disabled_at_line(content, "MD001", 3));
686 assert!(is_rule_disabled_at_line(content, "MD002", 3));
687
688 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
689 assert!(is_rule_disabled_at_line(content, "MD002", 5));
690
691 assert!(is_rule_disabled_at_line(content, "MD001", 7));
692 assert!(is_rule_disabled_at_line(content, "MD002", 7));
693
694 assert!(is_rule_disabled_at_line(content, "MD001", 9));
695 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
696 }
697
698 #[test]
699 fn test_is_rule_disabled_by_comment() {
700 let content = r#"# Document
701<!-- rumdl-disable MD001 -->
702Content here"#;
703
704 assert!(is_rule_disabled_by_comment(content, "MD001"));
705 assert!(!is_rule_disabled_by_comment(content, "MD002"));
706
707 let content2 = r#"# Document
708<!-- rumdl-disable -->
709Content here"#;
710
711 assert!(is_rule_disabled_by_comment(content2, "MD001"));
712 assert!(is_rule_disabled_by_comment(content2, "MD002"));
713 }
714
715 #[test]
716 fn test_comment_at_end_of_file() {
717 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
718
719 assert!(is_rule_disabled_by_comment(content, "MD001"));
721 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
723 assert!(is_rule_disabled_at_line(content, "MD001", 2));
725 }
726
727 #[test]
728 fn test_multiple_comments_same_line() {
729 assert_eq!(
731 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
732 Some(vec!["MD001"])
733 );
734
735 assert_eq!(
736 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
737 Some(vec!["MD001"])
738 );
739 }
740
741 #[test]
742 fn test_severity_serialization() {
743 let warning = LintWarning {
744 message: "Test warning".to_string(),
745 line: 1,
746 column: 1,
747 end_line: 1,
748 end_column: 10,
749 severity: Severity::Warning,
750 fix: None,
751 rule_name: Some("MD001".to_string()),
752 };
753
754 let serialized = serde_json::to_string(&warning).unwrap();
755 assert!(serialized.contains("\"severity\":\"Warning\""));
756
757 let error = LintWarning {
758 severity: Severity::Error,
759 ..warning
760 };
761
762 let serialized = serde_json::to_string(&error).unwrap();
763 assert!(serialized.contains("\"severity\":\"Error\""));
764 }
765
766 #[test]
767 fn test_fix_serialization() {
768 let fix = Fix {
769 range: 0..10,
770 replacement: "fixed text".to_string(),
771 };
772
773 let warning = LintWarning {
774 message: "Test warning".to_string(),
775 line: 1,
776 column: 1,
777 end_line: 1,
778 end_column: 10,
779 severity: Severity::Warning,
780 fix: Some(fix),
781 rule_name: Some("MD001".to_string()),
782 };
783
784 let serialized = serde_json::to_string(&warning).unwrap();
785 assert!(serialized.contains("\"fix\""));
786 assert!(serialized.contains("\"replacement\":\"fixed text\""));
787 }
788
789 #[test]
790 fn test_rule_category_equality() {
791 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
792 assert_ne!(RuleCategory::Heading, RuleCategory::List);
793
794 let categories = [
796 RuleCategory::Heading,
797 RuleCategory::List,
798 RuleCategory::CodeBlock,
799 RuleCategory::Link,
800 RuleCategory::Image,
801 RuleCategory::Html,
802 RuleCategory::Emphasis,
803 RuleCategory::Whitespace,
804 RuleCategory::Blockquote,
805 RuleCategory::Table,
806 RuleCategory::FrontMatter,
807 RuleCategory::Other,
808 ];
809
810 for (i, cat1) in categories.iter().enumerate() {
811 for (j, cat2) in categories.iter().enumerate() {
812 if i == j {
813 assert_eq!(cat1, cat2);
814 } else {
815 assert_ne!(cat1, cat2);
816 }
817 }
818 }
819 }
820
821 #[test]
822 fn test_lint_error_conversions() {
823 use std::io;
824
825 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
827 let lint_error: LintError = io_error.into();
828 match lint_error {
829 LintError::IoError(_) => {}
830 _ => panic!("Expected IoError variant"),
831 }
832
833 let invalid_input = LintError::InvalidInput("bad input".to_string());
835 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
836
837 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
838 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
839
840 let parsing_error = LintError::ParsingError("parse error".to_string());
841 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
842 }
843
844 #[test]
845 fn test_empty_content_edge_cases() {
846 assert!(!is_rule_disabled_at_line("", "MD001", 0));
847 assert!(!is_rule_disabled_by_comment("", "MD001"));
848
849 let single_comment = "<!-- rumdl-disable -->";
851 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
852 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
853 }
854
855 #[test]
856 fn test_very_long_rule_list() {
857 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
858 let comment = format!("<!-- rumdl-disable {many_rules} -->");
859
860 let parsed = parse_disable_comment(&comment);
861 assert!(parsed.is_some());
862 assert_eq!(parsed.unwrap().len(), 100);
863 }
864
865 #[test]
866 fn test_comment_with_special_characters() {
867 assert_eq!(
869 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
870 Some(vec!["MD001-test"])
871 );
872
873 assert_eq!(
874 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
875 Some(vec!["MD_001"])
876 );
877
878 assert_eq!(
879 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
880 Some(vec!["MD.001"])
881 );
882 }
883}