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
91pub trait Rule: DynClone + Send + Sync {
93 fn name(&self) -> &'static str;
94 fn description(&self) -> &'static str;
95 fn check(&self, ctx: &LintContext) -> LintResult;
96 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
97
98 fn should_skip(&self, _ctx: &LintContext) -> bool {
100 false
101 }
102
103 fn category(&self) -> RuleCategory {
105 RuleCategory::Other }
107
108 fn as_any(&self) -> &dyn std::any::Any;
109
110 fn default_config_section(&self) -> Option<(String, toml::Value)> {
119 None
120 }
121
122 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
125 None
126 }
127
128 fn fix_capability(&self) -> FixCapability {
130 FixCapability::FullyFixable }
132
133 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
135 where
136 Self: Sized,
137 {
138 panic!(
139 "from_config not implemented for rule: {}",
140 std::any::type_name::<Self>()
141 );
142 }
143}
144
145dyn_clone::clone_trait_object!(Rule);
147
148pub trait RuleExt {
150 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
151}
152
153impl<R: Rule + 'static> RuleExt for Box<R> {
154 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
155 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
156 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
157 } else {
158 None
159 }
160 }
161}
162
163pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
165 let lines: Vec<&str> = content.lines().collect();
166 let mut is_disabled = false;
167
168 for (i, line) in lines.iter().enumerate() {
170 if i > line_num {
172 break;
173 }
174
175 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
177 continue;
178 }
179
180 let line = line.trim();
181
182 if let Some(rules) = parse_disable_comment(line)
184 && (rules.is_empty() || rules.contains(&rule_name))
185 {
186 is_disabled = true;
187 continue;
188 }
189
190 if let Some(rules) = parse_enable_comment(line)
192 && (rules.is_empty() || rules.contains(&rule_name))
193 {
194 is_disabled = false;
195 continue;
196 }
197 }
198
199 is_disabled
200}
201
202pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
204 if let Some(start) = line.find("<!-- rumdl-disable") {
206 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
207
208 if after_prefix.trim_start().starts_with("-->") {
210 return Some(Vec::new()); }
212
213 if let Some(end) = after_prefix.find("-->") {
215 let rules_str = after_prefix[..end].trim();
216 if !rules_str.is_empty() {
217 let rules: Vec<&str> = rules_str.split_whitespace().collect();
218 return Some(rules);
219 }
220 }
221 }
222
223 if let Some(start) = line.find("<!-- markdownlint-disable") {
225 let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
226
227 if after_prefix.trim_start().starts_with("-->") {
229 return Some(Vec::new()); }
231
232 if let Some(end) = after_prefix.find("-->") {
234 let rules_str = after_prefix[..end].trim();
235 if !rules_str.is_empty() {
236 let rules: Vec<&str> = rules_str.split_whitespace().collect();
237 return Some(rules);
238 }
239 }
240 }
241
242 None
243}
244
245pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
247 if let Some(start) = line.find("<!-- rumdl-enable") {
249 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
250
251 if after_prefix.trim_start().starts_with("-->") {
253 return Some(Vec::new()); }
255
256 if let Some(end) = after_prefix.find("-->") {
258 let rules_str = after_prefix[..end].trim();
259 if !rules_str.is_empty() {
260 let rules: Vec<&str> = rules_str.split_whitespace().collect();
261 return Some(rules);
262 }
263 }
264 }
265
266 if let Some(start) = line.find("<!-- markdownlint-enable") {
268 let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
269
270 if after_prefix.trim_start().starts_with("-->") {
272 return Some(Vec::new()); }
274
275 if let Some(end) = after_prefix.find("-->") {
277 let rules_str = after_prefix[..end].trim();
278 if !rules_str.is_empty() {
279 let rules: Vec<&str> = rules_str.split_whitespace().collect();
280 return Some(rules);
281 }
282 }
283 }
284
285 None
286}
287
288pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
290 let lines: Vec<&str> = content.lines().collect();
292 is_rule_disabled_at_line(content, rule_name, lines.len())
293}
294
295#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_parse_disable_comment() {
337 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
339
340 assert_eq!(
342 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
343 Some(vec!["MD001", "MD002"])
344 );
345
346 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
348
349 assert_eq!(
351 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
352 Some(vec!["MD001", "MD002"])
353 );
354
355 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
357
358 assert_eq!(
360 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
361 Some(vec!["MD013"])
362 );
363 }
364
365 #[test]
366 fn test_parse_enable_comment() {
367 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
369
370 assert_eq!(
372 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
373 Some(vec!["MD001", "MD002"])
374 );
375
376 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
378
379 assert_eq!(
381 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
382 Some(vec!["MD001", "MD002"])
383 );
384
385 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
387 }
388
389 #[test]
390 fn test_is_rule_disabled_at_line() {
391 let content = r#"# Test
392<!-- rumdl-disable MD013 -->
393This is a long line
394<!-- rumdl-enable MD013 -->
395This is another line
396<!-- markdownlint-disable MD042 -->
397Empty link: []()
398<!-- markdownlint-enable MD042 -->
399Final line"#;
400
401 assert!(is_rule_disabled_at_line(content, "MD013", 2));
403
404 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
406
407 assert!(is_rule_disabled_at_line(content, "MD042", 6));
409
410 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
412
413 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
415 }
416
417 #[test]
418 fn test_parse_disable_comment_edge_cases() {
419 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
421
422 assert_eq!(
424 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
425 None
426 );
427
428 assert_eq!(
430 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
431 Some(vec!["MD001", "MD002"])
432 );
433
434 assert_eq!(
436 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
437 Some(vec!["MD001"])
438 );
439
440 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
442
443 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
445
446 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
448 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
449
450 assert_eq!(
452 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
453 Some(vec!["MD001"])
454 );
455
456 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
458
459 assert_eq!(
461 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
462 Some(vec!["MD001", "MD001", "MD002"])
463 );
464 }
465
466 #[test]
467 fn test_parse_enable_comment_edge_cases() {
468 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
470
471 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
473
474 assert_eq!(
476 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
477 Some(vec!["MD001", "MD002"])
478 );
479
480 assert_eq!(
482 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
483 Some(vec!["MD001"])
484 );
485
486 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
488
489 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
491
492 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
494 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
495
496 assert_eq!(
498 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
499 Some(vec!["MD001"])
500 );
501
502 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
504
505 assert_eq!(
507 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
508 Some(vec!["MD001", "MD001", "MD002"])
509 );
510 }
511
512 #[test]
513 fn test_nested_disable_enable_comments() {
514 let content = r#"# Document
515<!-- rumdl-disable -->
516All rules disabled here
517<!-- rumdl-disable MD001 -->
518Still all disabled (redundant)
519<!-- rumdl-enable MD001 -->
520Only MD001 enabled, others still disabled
521<!-- rumdl-enable -->
522All rules enabled again"#;
523
524 assert!(is_rule_disabled_at_line(content, "MD001", 2));
526 assert!(is_rule_disabled_at_line(content, "MD002", 2));
527
528 assert!(is_rule_disabled_at_line(content, "MD001", 4));
530 assert!(is_rule_disabled_at_line(content, "MD002", 4));
531
532 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
534 assert!(is_rule_disabled_at_line(content, "MD002", 6));
535
536 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
538 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
539 }
540
541 #[test]
542 fn test_mixed_comment_styles() {
543 let content = r#"# Document
544<!-- markdownlint-disable MD001 -->
545MD001 disabled via markdownlint
546<!-- rumdl-enable MD001 -->
547MD001 enabled via rumdl
548<!-- rumdl-disable -->
549All disabled via rumdl
550<!-- markdownlint-enable -->
551All enabled via markdownlint"#;
552
553 assert!(is_rule_disabled_at_line(content, "MD001", 2));
555 assert!(!is_rule_disabled_at_line(content, "MD002", 2));
556
557 assert!(!is_rule_disabled_at_line(content, "MD001", 4));
559 assert!(!is_rule_disabled_at_line(content, "MD002", 4));
560
561 assert!(is_rule_disabled_at_line(content, "MD001", 6));
563 assert!(is_rule_disabled_at_line(content, "MD002", 6));
564
565 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
567 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
568 }
569
570 #[test]
571 fn test_comments_in_code_blocks() {
572 let content = r#"# Document
573```markdown
574<!-- rumdl-disable MD001 -->
575This is in a code block, should not affect rules
576```
577MD001 should still be enabled here"#;
578
579 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
581
582 let indented_content = r#"# Document
584
585 <!-- rumdl-disable MD001 -->
586 This is in an indented code block
587
588MD001 should still be enabled here"#;
589
590 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
591 }
592
593 #[test]
594 fn test_comments_with_unicode() {
595 assert_eq!(
597 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
598 Some(vec!["MD001"])
599 );
600
601 assert_eq!(
602 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
603 Some(vec!["MD001"])
604 );
605 }
606
607 #[test]
608 fn test_rule_disabled_at_specific_lines() {
609 let content = r#"Line 0
610<!-- rumdl-disable MD001 MD002 -->
611Line 2
612Line 3
613<!-- rumdl-enable MD001 -->
614Line 5
615<!-- rumdl-disable -->
616Line 7
617<!-- rumdl-enable MD002 -->
618Line 9"#;
619
620 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
622 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
623
624 assert!(is_rule_disabled_at_line(content, "MD001", 2));
625 assert!(is_rule_disabled_at_line(content, "MD002", 2));
626
627 assert!(is_rule_disabled_at_line(content, "MD001", 3));
628 assert!(is_rule_disabled_at_line(content, "MD002", 3));
629
630 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
631 assert!(is_rule_disabled_at_line(content, "MD002", 5));
632
633 assert!(is_rule_disabled_at_line(content, "MD001", 7));
634 assert!(is_rule_disabled_at_line(content, "MD002", 7));
635
636 assert!(is_rule_disabled_at_line(content, "MD001", 9));
637 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
638 }
639
640 #[test]
641 fn test_is_rule_disabled_by_comment() {
642 let content = r#"# Document
643<!-- rumdl-disable MD001 -->
644Content here"#;
645
646 assert!(is_rule_disabled_by_comment(content, "MD001"));
647 assert!(!is_rule_disabled_by_comment(content, "MD002"));
648
649 let content2 = r#"# Document
650<!-- rumdl-disable -->
651Content here"#;
652
653 assert!(is_rule_disabled_by_comment(content2, "MD001"));
654 assert!(is_rule_disabled_by_comment(content2, "MD002"));
655 }
656
657 #[test]
658 fn test_comment_at_end_of_file() {
659 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
660
661 assert!(is_rule_disabled_by_comment(content, "MD001"));
663 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
665 assert!(is_rule_disabled_at_line(content, "MD001", 2));
667 }
668
669 #[test]
670 fn test_multiple_comments_same_line() {
671 assert_eq!(
673 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
674 Some(vec!["MD001"])
675 );
676
677 assert_eq!(
678 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
679 Some(vec!["MD001"])
680 );
681 }
682
683 #[test]
684 fn test_severity_serialization() {
685 let warning = LintWarning {
686 message: "Test warning".to_string(),
687 line: 1,
688 column: 1,
689 end_line: 1,
690 end_column: 10,
691 severity: Severity::Warning,
692 fix: None,
693 rule_name: Some("MD001".to_string()),
694 };
695
696 let serialized = serde_json::to_string(&warning).unwrap();
697 assert!(serialized.contains("\"severity\":\"Warning\""));
698
699 let error = LintWarning {
700 severity: Severity::Error,
701 ..warning
702 };
703
704 let serialized = serde_json::to_string(&error).unwrap();
705 assert!(serialized.contains("\"severity\":\"Error\""));
706 }
707
708 #[test]
709 fn test_fix_serialization() {
710 let fix = Fix {
711 range: 0..10,
712 replacement: "fixed text".to_string(),
713 };
714
715 let warning = LintWarning {
716 message: "Test warning".to_string(),
717 line: 1,
718 column: 1,
719 end_line: 1,
720 end_column: 10,
721 severity: Severity::Warning,
722 fix: Some(fix),
723 rule_name: Some("MD001".to_string()),
724 };
725
726 let serialized = serde_json::to_string(&warning).unwrap();
727 assert!(serialized.contains("\"fix\""));
728 assert!(serialized.contains("\"replacement\":\"fixed text\""));
729 }
730
731 #[test]
732 fn test_rule_category_equality() {
733 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
734 assert_ne!(RuleCategory::Heading, RuleCategory::List);
735
736 let categories = [
738 RuleCategory::Heading,
739 RuleCategory::List,
740 RuleCategory::CodeBlock,
741 RuleCategory::Link,
742 RuleCategory::Image,
743 RuleCategory::Html,
744 RuleCategory::Emphasis,
745 RuleCategory::Whitespace,
746 RuleCategory::Blockquote,
747 RuleCategory::Table,
748 RuleCategory::FrontMatter,
749 RuleCategory::Other,
750 ];
751
752 for (i, cat1) in categories.iter().enumerate() {
753 for (j, cat2) in categories.iter().enumerate() {
754 if i == j {
755 assert_eq!(cat1, cat2);
756 } else {
757 assert_ne!(cat1, cat2);
758 }
759 }
760 }
761 }
762
763 #[test]
764 fn test_lint_error_conversions() {
765 use std::io;
766
767 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
769 let lint_error: LintError = io_error.into();
770 match lint_error {
771 LintError::IoError(_) => {}
772 _ => panic!("Expected IoError variant"),
773 }
774
775 let invalid_input = LintError::InvalidInput("bad input".to_string());
777 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
778
779 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
780 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
781
782 let parsing_error = LintError::ParsingError("parse error".to_string());
783 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
784 }
785
786 #[test]
787 fn test_empty_content_edge_cases() {
788 assert!(!is_rule_disabled_at_line("", "MD001", 0));
789 assert!(!is_rule_disabled_by_comment("", "MD001"));
790
791 let single_comment = "<!-- rumdl-disable -->";
793 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
794 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
795 }
796
797 #[test]
798 fn test_very_long_rule_list() {
799 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
800 let comment = format!("<!-- rumdl-disable {many_rules} -->");
801
802 let parsed = parse_disable_comment(&comment);
803 assert!(parsed.is_some());
804 assert_eq!(parsed.unwrap().len(), 100);
805 }
806
807 #[test]
808 fn test_comment_with_special_characters() {
809 assert_eq!(
811 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
812 Some(vec!["MD001-test"])
813 );
814
815 assert_eq!(
816 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
817 Some(vec!["MD_001"])
818 );
819
820 assert_eq!(
821 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
822 Some(vec!["MD.001"])
823 );
824 }
825}