1use dyn_clone::DynClone;
6use serde::{Deserialize, Serialize};
7use std::ops::Range;
8use thiserror::Error;
9
10use crate::lint_context::LintContext;
12
13pub use markdown::mdast::Node as MarkdownAst;
15
16#[macro_export]
18macro_rules! impl_rule_clone {
19 ($ty:ty) => {
20 impl $ty {
21 fn box_clone(&self) -> Box<dyn Rule> {
22 Box::new(self.clone())
23 }
24 }
25 };
26}
27
28#[derive(Debug, Error)]
29pub enum LintError {
30 #[error("Invalid input: {0}")]
31 InvalidInput(String),
32 #[error("Fix failed: {0}")]
33 FixFailed(String),
34 #[error("IO error: {0}")]
35 IoError(#[from] std::io::Error),
36 #[error("Parsing error: {0}")]
37 ParsingError(String),
38}
39
40pub type LintResult = Result<Vec<LintWarning>, LintError>;
41
42#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
43pub struct LintWarning {
44 pub message: String,
45 pub line: usize, pub column: usize, pub end_line: usize, pub end_column: usize, pub severity: Severity,
50 pub fix: Option<Fix>,
51 pub rule_name: Option<String>,
52}
53
54#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
55pub struct Fix {
56 pub range: Range<usize>,
57 pub replacement: String,
58}
59
60#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
61pub enum Severity {
62 Error,
63 Warning,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum RuleCategory {
69 Heading,
70 List,
71 CodeBlock,
72 Link,
73 Image,
74 Html,
75 Emphasis,
76 Whitespace,
77 Blockquote,
78 Table,
79 FrontMatter,
80 Other,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum FixCapability {
86 FullyFixable,
88 ConditionallyFixable,
90 Unfixable,
92}
93
94pub trait Rule: DynClone + Send + Sync {
96 fn name(&self) -> &'static str;
97 fn description(&self) -> &'static str;
98 fn check(&self, ctx: &LintContext) -> LintResult;
99 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
100
101 fn check_with_ast(&self, ctx: &LintContext, _ast: &MarkdownAst) -> LintResult {
104 self.check(ctx)
105 }
106
107 fn should_skip(&self, _ctx: &LintContext) -> bool {
109 false
110 }
111
112 fn category(&self) -> RuleCategory {
114 RuleCategory::Other }
116
117 fn uses_ast(&self) -> bool {
119 false
120 }
121
122 fn as_any(&self) -> &dyn std::any::Any;
123
124 fn as_maybe_ast(&self) -> Option<&dyn MaybeAst> {
130 None
131 }
132
133 fn default_config_section(&self) -> Option<(String, toml::Value)> {
137 None
138 }
139
140 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
143 None
144 }
145
146 fn fix_capability(&self) -> FixCapability {
148 FixCapability::FullyFixable }
150
151 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
153 where
154 Self: Sized,
155 {
156 panic!(
157 "from_config not implemented for rule: {}",
158 std::any::type_name::<Self>()
159 );
160 }
161}
162
163dyn_clone::clone_trait_object!(Rule);
165
166pub trait RuleExt {
168 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
169}
170
171impl<R: Rule + 'static> RuleExt for Box<R> {
172 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
173 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
174 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
175 } else {
176 None
177 }
178 }
179}
180
181pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
183 let lines: Vec<&str> = content.lines().collect();
184 let mut is_disabled = false;
185
186 for (i, line) in lines.iter().enumerate() {
188 if i > line_num {
190 break;
191 }
192
193 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
195 continue;
196 }
197
198 let line = line.trim();
199
200 if let Some(rules) = parse_disable_comment(line)
202 && (rules.is_empty() || rules.contains(&rule_name))
203 {
204 is_disabled = true;
205 continue;
206 }
207
208 if let Some(rules) = parse_enable_comment(line)
210 && (rules.is_empty() || rules.contains(&rule_name))
211 {
212 is_disabled = false;
213 continue;
214 }
215 }
216
217 is_disabled
218}
219
220pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
222 if let Some(start) = line.find("<!-- rumdl-disable") {
224 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
225
226 if after_prefix.trim_start().starts_with("-->") {
228 return Some(Vec::new()); }
230
231 if let Some(end) = after_prefix.find("-->") {
233 let rules_str = after_prefix[..end].trim();
234 if !rules_str.is_empty() {
235 let rules: Vec<&str> = rules_str.split_whitespace().collect();
236 return Some(rules);
237 }
238 }
239 }
240
241 if let Some(start) = line.find("<!-- markdownlint-disable") {
243 let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
244
245 if after_prefix.trim_start().starts_with("-->") {
247 return Some(Vec::new()); }
249
250 if let Some(end) = after_prefix.find("-->") {
252 let rules_str = after_prefix[..end].trim();
253 if !rules_str.is_empty() {
254 let rules: Vec<&str> = rules_str.split_whitespace().collect();
255 return Some(rules);
256 }
257 }
258 }
259
260 None
261}
262
263pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
265 if let Some(start) = line.find("<!-- rumdl-enable") {
267 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
268
269 if after_prefix.trim_start().starts_with("-->") {
271 return Some(Vec::new()); }
273
274 if let Some(end) = after_prefix.find("-->") {
276 let rules_str = after_prefix[..end].trim();
277 if !rules_str.is_empty() {
278 let rules: Vec<&str> = rules_str.split_whitespace().collect();
279 return Some(rules);
280 }
281 }
282 }
283
284 if let Some(start) = line.find("<!-- markdownlint-enable") {
286 let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
287
288 if after_prefix.trim_start().starts_with("-->") {
290 return Some(Vec::new()); }
292
293 if let Some(end) = after_prefix.find("-->") {
295 let rules_str = after_prefix[..end].trim();
296 if !rules_str.is_empty() {
297 let rules: Vec<&str> = rules_str.split_whitespace().collect();
298 return Some(rules);
299 }
300 }
301 }
302
303 None
304}
305
306pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
308 let lines: Vec<&str> = content.lines().collect();
310 is_rule_disabled_at_line(content, rule_name, lines.len())
311}
312
313pub trait MaybeAst {
351 fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult>;
352}
353
354impl<T> MaybeAst for T
355where
356 T: Rule + AstExtensions + 'static,
357{
358 fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult> {
359 if self.has_relevant_ast_elements(ctx, ast) {
360 Some(self.check_with_ast(ctx, ast))
361 } else {
362 None
363 }
364 }
365}
366
367impl MaybeAst for dyn Rule {
368 fn check_with_ast_opt(&self, _ctx: &LintContext, _ast: &MarkdownAst) -> Option<LintResult> {
369 None
370 }
371}
372
373pub trait AstExtensions {
375 fn has_relevant_ast_elements(&self, ctx: &LintContext, ast: &MarkdownAst) -> bool;
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn test_parse_disable_comment() {
385 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
387
388 assert_eq!(
390 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
391 Some(vec!["MD001", "MD002"])
392 );
393
394 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
396
397 assert_eq!(
399 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
400 Some(vec!["MD001", "MD002"])
401 );
402
403 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
405
406 assert_eq!(
408 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
409 Some(vec!["MD013"])
410 );
411 }
412
413 #[test]
414 fn test_parse_enable_comment() {
415 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
417
418 assert_eq!(
420 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
421 Some(vec!["MD001", "MD002"])
422 );
423
424 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
426
427 assert_eq!(
429 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
430 Some(vec!["MD001", "MD002"])
431 );
432
433 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
435 }
436
437 #[test]
438 fn test_is_rule_disabled_at_line() {
439 let content = r#"# Test
440<!-- rumdl-disable MD013 -->
441This is a long line
442<!-- rumdl-enable MD013 -->
443This is another line
444<!-- markdownlint-disable MD042 -->
445Empty link: []()
446<!-- markdownlint-enable MD042 -->
447Final line"#;
448
449 assert!(is_rule_disabled_at_line(content, "MD013", 2));
451
452 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
454
455 assert!(is_rule_disabled_at_line(content, "MD042", 6));
457
458 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
460
461 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
463 }
464
465 #[test]
466 fn test_parse_disable_comment_edge_cases() {
467 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
469
470 assert_eq!(
472 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
473 None
474 );
475
476 assert_eq!(
478 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
479 Some(vec!["MD001", "MD002"])
480 );
481
482 assert_eq!(
484 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
485 Some(vec!["MD001"])
486 );
487
488 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
490
491 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
493
494 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
496 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
497
498 assert_eq!(
500 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
501 Some(vec!["MD001"])
502 );
503
504 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
506
507 assert_eq!(
509 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
510 Some(vec!["MD001", "MD001", "MD002"])
511 );
512 }
513
514 #[test]
515 fn test_parse_enable_comment_edge_cases() {
516 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
518
519 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
521
522 assert_eq!(
524 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
525 Some(vec!["MD001", "MD002"])
526 );
527
528 assert_eq!(
530 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
531 Some(vec!["MD001"])
532 );
533
534 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
536
537 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
539
540 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
542 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
543
544 assert_eq!(
546 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
547 Some(vec!["MD001"])
548 );
549
550 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
552
553 assert_eq!(
555 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
556 Some(vec!["MD001", "MD001", "MD002"])
557 );
558 }
559
560 #[test]
561 fn test_nested_disable_enable_comments() {
562 let content = r#"# Document
563<!-- rumdl-disable -->
564All rules disabled here
565<!-- rumdl-disable MD001 -->
566Still all disabled (redundant)
567<!-- rumdl-enable MD001 -->
568Only MD001 enabled, others still disabled
569<!-- rumdl-enable -->
570All rules enabled again"#;
571
572 assert!(is_rule_disabled_at_line(content, "MD001", 2));
574 assert!(is_rule_disabled_at_line(content, "MD002", 2));
575
576 assert!(is_rule_disabled_at_line(content, "MD001", 4));
578 assert!(is_rule_disabled_at_line(content, "MD002", 4));
579
580 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
582 assert!(is_rule_disabled_at_line(content, "MD002", 6));
583
584 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
586 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
587 }
588
589 #[test]
590 fn test_mixed_comment_styles() {
591 let content = r#"# Document
592<!-- markdownlint-disable MD001 -->
593MD001 disabled via markdownlint
594<!-- rumdl-enable MD001 -->
595MD001 enabled via rumdl
596<!-- rumdl-disable -->
597All disabled via rumdl
598<!-- markdownlint-enable -->
599All enabled via markdownlint"#;
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_comments_in_code_blocks() {
620 let content = r#"# Document
621```markdown
622<!-- rumdl-disable MD001 -->
623This is in a code block, should not affect rules
624```
625MD001 should still be enabled here"#;
626
627 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
629
630 let indented_content = r#"# Document
632
633 <!-- rumdl-disable MD001 -->
634 This is in an indented code block
635
636MD001 should still be enabled here"#;
637
638 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
639 }
640
641 #[test]
642 fn test_comments_with_unicode() {
643 assert_eq!(
645 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
646 Some(vec!["MD001"])
647 );
648
649 assert_eq!(
650 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
651 Some(vec!["MD001"])
652 );
653 }
654
655 #[test]
656 fn test_rule_disabled_at_specific_lines() {
657 let content = r#"Line 0
658<!-- rumdl-disable MD001 MD002 -->
659Line 2
660Line 3
661<!-- rumdl-enable MD001 -->
662Line 5
663<!-- rumdl-disable -->
664Line 7
665<!-- rumdl-enable MD002 -->
666Line 9"#;
667
668 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
670 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
671
672 assert!(is_rule_disabled_at_line(content, "MD001", 2));
673 assert!(is_rule_disabled_at_line(content, "MD002", 2));
674
675 assert!(is_rule_disabled_at_line(content, "MD001", 3));
676 assert!(is_rule_disabled_at_line(content, "MD002", 3));
677
678 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
679 assert!(is_rule_disabled_at_line(content, "MD002", 5));
680
681 assert!(is_rule_disabled_at_line(content, "MD001", 7));
682 assert!(is_rule_disabled_at_line(content, "MD002", 7));
683
684 assert!(is_rule_disabled_at_line(content, "MD001", 9));
685 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
686 }
687
688 #[test]
689 fn test_is_rule_disabled_by_comment() {
690 let content = r#"# Document
691<!-- rumdl-disable MD001 -->
692Content here"#;
693
694 assert!(is_rule_disabled_by_comment(content, "MD001"));
695 assert!(!is_rule_disabled_by_comment(content, "MD002"));
696
697 let content2 = r#"# Document
698<!-- rumdl-disable -->
699Content here"#;
700
701 assert!(is_rule_disabled_by_comment(content2, "MD001"));
702 assert!(is_rule_disabled_by_comment(content2, "MD002"));
703 }
704
705 #[test]
706 fn test_comment_at_end_of_file() {
707 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
708
709 assert!(is_rule_disabled_by_comment(content, "MD001"));
711 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
713 assert!(is_rule_disabled_at_line(content, "MD001", 2));
715 }
716
717 #[test]
718 fn test_multiple_comments_same_line() {
719 assert_eq!(
721 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
722 Some(vec!["MD001"])
723 );
724
725 assert_eq!(
726 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
727 Some(vec!["MD001"])
728 );
729 }
730
731 #[test]
732 fn test_severity_serialization() {
733 let warning = LintWarning {
734 message: "Test warning".to_string(),
735 line: 1,
736 column: 1,
737 end_line: 1,
738 end_column: 10,
739 severity: Severity::Warning,
740 fix: None,
741 rule_name: Some("MD001".to_string()),
742 };
743
744 let serialized = serde_json::to_string(&warning).unwrap();
745 assert!(serialized.contains("\"severity\":\"Warning\""));
746
747 let error = LintWarning {
748 severity: Severity::Error,
749 ..warning
750 };
751
752 let serialized = serde_json::to_string(&error).unwrap();
753 assert!(serialized.contains("\"severity\":\"Error\""));
754 }
755
756 #[test]
757 fn test_fix_serialization() {
758 let fix = Fix {
759 range: 0..10,
760 replacement: "fixed text".to_string(),
761 };
762
763 let warning = LintWarning {
764 message: "Test warning".to_string(),
765 line: 1,
766 column: 1,
767 end_line: 1,
768 end_column: 10,
769 severity: Severity::Warning,
770 fix: Some(fix),
771 rule_name: Some("MD001".to_string()),
772 };
773
774 let serialized = serde_json::to_string(&warning).unwrap();
775 assert!(serialized.contains("\"fix\""));
776 assert!(serialized.contains("\"replacement\":\"fixed text\""));
777 }
778
779 #[test]
780 fn test_rule_category_equality() {
781 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
782 assert_ne!(RuleCategory::Heading, RuleCategory::List);
783
784 let categories = [
786 RuleCategory::Heading,
787 RuleCategory::List,
788 RuleCategory::CodeBlock,
789 RuleCategory::Link,
790 RuleCategory::Image,
791 RuleCategory::Html,
792 RuleCategory::Emphasis,
793 RuleCategory::Whitespace,
794 RuleCategory::Blockquote,
795 RuleCategory::Table,
796 RuleCategory::FrontMatter,
797 RuleCategory::Other,
798 ];
799
800 for (i, cat1) in categories.iter().enumerate() {
801 for (j, cat2) in categories.iter().enumerate() {
802 if i == j {
803 assert_eq!(cat1, cat2);
804 } else {
805 assert_ne!(cat1, cat2);
806 }
807 }
808 }
809 }
810
811 #[test]
812 fn test_lint_error_conversions() {
813 use std::io;
814
815 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
817 let lint_error: LintError = io_error.into();
818 match lint_error {
819 LintError::IoError(_) => {}
820 _ => panic!("Expected IoError variant"),
821 }
822
823 let invalid_input = LintError::InvalidInput("bad input".to_string());
825 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
826
827 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
828 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
829
830 let parsing_error = LintError::ParsingError("parse error".to_string());
831 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
832 }
833
834 #[test]
835 fn test_empty_content_edge_cases() {
836 assert!(!is_rule_disabled_at_line("", "MD001", 0));
837 assert!(!is_rule_disabled_by_comment("", "MD001"));
838
839 let single_comment = "<!-- rumdl-disable -->";
841 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
842 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
843 }
844
845 #[test]
846 fn test_very_long_rule_list() {
847 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
848 let comment = format!("<!-- rumdl-disable {many_rules} -->");
849
850 let parsed = parse_disable_comment(&comment);
851 assert!(parsed.is_some());
852 assert_eq!(parsed.unwrap().len(), 100);
853 }
854
855 #[test]
856 fn test_comment_with_special_characters() {
857 assert_eq!(
859 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
860 Some(vec!["MD001-test"])
861 );
862
863 assert_eq!(
864 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
865 Some(vec!["MD_001"])
866 );
867
868 assert_eq!(
869 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
870 Some(vec!["MD.001"])
871 );
872 }
873}