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 as_any(&self) -> &dyn std::any::Any;
118
119 fn as_maybe_ast(&self) -> Option<&dyn MaybeAst> {
125 None
126 }
127
128 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 from_config(_config: &crate::config::Config) -> Box<dyn Rule>
148 where
149 Self: Sized,
150 {
151 panic!(
152 "from_config not implemented for rule: {}",
153 std::any::type_name::<Self>()
154 );
155 }
156}
157
158dyn_clone::clone_trait_object!(Rule);
160
161pub trait RuleExt {
163 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
164}
165
166impl<R: Rule + 'static> RuleExt for Box<R> {
167 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
168 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
169 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
170 } else {
171 None
172 }
173 }
174}
175
176pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
178 let lines: Vec<&str> = content.lines().collect();
179 let mut is_disabled = false;
180
181 for (i, line) in lines.iter().enumerate() {
183 if i > line_num {
185 break;
186 }
187
188 if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
190 continue;
191 }
192
193 let line = line.trim();
194
195 if let Some(rules) = parse_disable_comment(line)
197 && (rules.is_empty() || rules.contains(&rule_name))
198 {
199 is_disabled = true;
200 continue;
201 }
202
203 if let Some(rules) = parse_enable_comment(line)
205 && (rules.is_empty() || rules.contains(&rule_name))
206 {
207 is_disabled = false;
208 continue;
209 }
210 }
211
212 is_disabled
213}
214
215pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
217 if let Some(start) = line.find("<!-- rumdl-disable") {
219 let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
220
221 if after_prefix.trim_start().starts_with("-->") {
223 return Some(Vec::new()); }
225
226 if let Some(end) = after_prefix.find("-->") {
228 let rules_str = after_prefix[..end].trim();
229 if !rules_str.is_empty() {
230 let rules: Vec<&str> = rules_str.split_whitespace().collect();
231 return Some(rules);
232 }
233 }
234 }
235
236 if let Some(start) = line.find("<!-- markdownlint-disable") {
238 let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
239
240 if after_prefix.trim_start().starts_with("-->") {
242 return Some(Vec::new()); }
244
245 if let Some(end) = after_prefix.find("-->") {
247 let rules_str = after_prefix[..end].trim();
248 if !rules_str.is_empty() {
249 let rules: Vec<&str> = rules_str.split_whitespace().collect();
250 return Some(rules);
251 }
252 }
253 }
254
255 None
256}
257
258pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
260 if let Some(start) = line.find("<!-- rumdl-enable") {
262 let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
263
264 if after_prefix.trim_start().starts_with("-->") {
266 return Some(Vec::new()); }
268
269 if let Some(end) = after_prefix.find("-->") {
271 let rules_str = after_prefix[..end].trim();
272 if !rules_str.is_empty() {
273 let rules: Vec<&str> = rules_str.split_whitespace().collect();
274 return Some(rules);
275 }
276 }
277 }
278
279 if let Some(start) = line.find("<!-- markdownlint-enable") {
281 let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
282
283 if after_prefix.trim_start().starts_with("-->") {
285 return Some(Vec::new()); }
287
288 if let Some(end) = after_prefix.find("-->") {
290 let rules_str = after_prefix[..end].trim();
291 if !rules_str.is_empty() {
292 let rules: Vec<&str> = rules_str.split_whitespace().collect();
293 return Some(rules);
294 }
295 }
296 }
297
298 None
299}
300
301pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
303 let lines: Vec<&str> = content.lines().collect();
305 is_rule_disabled_at_line(content, rule_name, lines.len())
306}
307
308pub trait MaybeAst {
346 fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult>;
347}
348
349impl<T> MaybeAst for T
350where
351 T: Rule + AstExtensions + 'static,
352{
353 fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult> {
354 if self.has_relevant_ast_elements(ctx, ast) {
355 Some(self.check_with_ast(ctx, ast))
356 } else {
357 None
358 }
359 }
360}
361
362impl MaybeAst for dyn Rule {
363 fn check_with_ast_opt(&self, _ctx: &LintContext, _ast: &MarkdownAst) -> Option<LintResult> {
364 None
365 }
366}
367
368pub trait AstExtensions {
370 fn has_relevant_ast_elements(&self, ctx: &LintContext, ast: &MarkdownAst) -> bool;
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_parse_disable_comment() {
380 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
382
383 assert_eq!(
385 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
386 Some(vec!["MD001", "MD002"])
387 );
388
389 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
391
392 assert_eq!(
394 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
395 Some(vec!["MD001", "MD002"])
396 );
397
398 assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
400
401 assert_eq!(
403 parse_disable_comment(" <!-- rumdl-disable MD013 --> "),
404 Some(vec!["MD013"])
405 );
406 }
407
408 #[test]
409 fn test_parse_enable_comment() {
410 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
412
413 assert_eq!(
415 parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
416 Some(vec!["MD001", "MD002"])
417 );
418
419 assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
421
422 assert_eq!(
424 parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
425 Some(vec!["MD001", "MD002"])
426 );
427
428 assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
430 }
431
432 #[test]
433 fn test_is_rule_disabled_at_line() {
434 let content = r#"# Test
435<!-- rumdl-disable MD013 -->
436This is a long line
437<!-- rumdl-enable MD013 -->
438This is another line
439<!-- markdownlint-disable MD042 -->
440Empty link: []()
441<!-- markdownlint-enable MD042 -->
442Final line"#;
443
444 assert!(is_rule_disabled_at_line(content, "MD013", 2));
446
447 assert!(!is_rule_disabled_at_line(content, "MD013", 4));
449
450 assert!(is_rule_disabled_at_line(content, "MD042", 6));
452
453 assert!(!is_rule_disabled_at_line(content, "MD042", 8));
455
456 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
458 }
459
460 #[test]
461 fn test_parse_disable_comment_edge_cases() {
462 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
464
465 assert_eq!(
467 parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
468 None
469 );
470
471 assert_eq!(
473 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
474 Some(vec!["MD001", "MD002"])
475 );
476
477 assert_eq!(
479 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
480 Some(vec!["MD001"])
481 );
482
483 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
485
486 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
488
489 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
491 assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
492
493 assert_eq!(
495 parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
496 Some(vec!["MD001"])
497 );
498
499 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
501
502 assert_eq!(
504 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
505 Some(vec!["MD001", "MD001", "MD002"])
506 );
507 }
508
509 #[test]
510 fn test_parse_enable_comment_edge_cases() {
511 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
513
514 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"), None);
516
517 assert_eq!(
519 parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
520 Some(vec!["MD001", "MD002"])
521 );
522
523 assert_eq!(
525 parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
526 Some(vec!["MD001"])
527 );
528
529 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
531
532 assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
534
535 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
537 assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
538
539 assert_eq!(
541 parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
542 Some(vec!["MD001"])
543 );
544
545 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
547
548 assert_eq!(
550 parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
551 Some(vec!["MD001", "MD001", "MD002"])
552 );
553 }
554
555 #[test]
556 fn test_nested_disable_enable_comments() {
557 let content = r#"# Document
558<!-- rumdl-disable -->
559All rules disabled here
560<!-- rumdl-disable MD001 -->
561Still all disabled (redundant)
562<!-- rumdl-enable MD001 -->
563Only MD001 enabled, others still disabled
564<!-- rumdl-enable -->
565All rules enabled again"#;
566
567 assert!(is_rule_disabled_at_line(content, "MD001", 2));
569 assert!(is_rule_disabled_at_line(content, "MD002", 2));
570
571 assert!(is_rule_disabled_at_line(content, "MD001", 4));
573 assert!(is_rule_disabled_at_line(content, "MD002", 4));
574
575 assert!(!is_rule_disabled_at_line(content, "MD001", 6));
577 assert!(is_rule_disabled_at_line(content, "MD002", 6));
578
579 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
581 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
582 }
583
584 #[test]
585 fn test_mixed_comment_styles() {
586 let content = r#"# Document
587<!-- markdownlint-disable MD001 -->
588MD001 disabled via markdownlint
589<!-- rumdl-enable MD001 -->
590MD001 enabled via rumdl
591<!-- rumdl-disable -->
592All disabled via rumdl
593<!-- markdownlint-enable -->
594All enabled via markdownlint"#;
595
596 assert!(is_rule_disabled_at_line(content, "MD001", 2));
598 assert!(!is_rule_disabled_at_line(content, "MD002", 2));
599
600 assert!(!is_rule_disabled_at_line(content, "MD001", 4));
602 assert!(!is_rule_disabled_at_line(content, "MD002", 4));
603
604 assert!(is_rule_disabled_at_line(content, "MD001", 6));
606 assert!(is_rule_disabled_at_line(content, "MD002", 6));
607
608 assert!(!is_rule_disabled_at_line(content, "MD001", 8));
610 assert!(!is_rule_disabled_at_line(content, "MD002", 8));
611 }
612
613 #[test]
614 fn test_comments_in_code_blocks() {
615 let content = r#"# Document
616```markdown
617<!-- rumdl-disable MD001 -->
618This is in a code block, should not affect rules
619```
620MD001 should still be enabled here"#;
621
622 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
624
625 let indented_content = r#"# Document
627
628 <!-- rumdl-disable MD001 -->
629 This is in an indented code block
630
631MD001 should still be enabled here"#;
632
633 assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
634 }
635
636 #[test]
637 fn test_comments_with_unicode() {
638 assert_eq!(
640 parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
641 Some(vec!["MD001"])
642 );
643
644 assert_eq!(
645 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
646 Some(vec!["MD001"])
647 );
648 }
649
650 #[test]
651 fn test_rule_disabled_at_specific_lines() {
652 let content = r#"Line 0
653<!-- rumdl-disable MD001 MD002 -->
654Line 2
655Line 3
656<!-- rumdl-enable MD001 -->
657Line 5
658<!-- rumdl-disable -->
659Line 7
660<!-- rumdl-enable MD002 -->
661Line 9"#;
662
663 assert!(!is_rule_disabled_at_line(content, "MD001", 0));
665 assert!(!is_rule_disabled_at_line(content, "MD002", 0));
666
667 assert!(is_rule_disabled_at_line(content, "MD001", 2));
668 assert!(is_rule_disabled_at_line(content, "MD002", 2));
669
670 assert!(is_rule_disabled_at_line(content, "MD001", 3));
671 assert!(is_rule_disabled_at_line(content, "MD002", 3));
672
673 assert!(!is_rule_disabled_at_line(content, "MD001", 5));
674 assert!(is_rule_disabled_at_line(content, "MD002", 5));
675
676 assert!(is_rule_disabled_at_line(content, "MD001", 7));
677 assert!(is_rule_disabled_at_line(content, "MD002", 7));
678
679 assert!(is_rule_disabled_at_line(content, "MD001", 9));
680 assert!(!is_rule_disabled_at_line(content, "MD002", 9));
681 }
682
683 #[test]
684 fn test_is_rule_disabled_by_comment() {
685 let content = r#"# Document
686<!-- rumdl-disable MD001 -->
687Content here"#;
688
689 assert!(is_rule_disabled_by_comment(content, "MD001"));
690 assert!(!is_rule_disabled_by_comment(content, "MD002"));
691
692 let content2 = r#"# Document
693<!-- rumdl-disable -->
694Content here"#;
695
696 assert!(is_rule_disabled_by_comment(content2, "MD001"));
697 assert!(is_rule_disabled_by_comment(content2, "MD002"));
698 }
699
700 #[test]
701 fn test_comment_at_end_of_file() {
702 let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
703
704 assert!(is_rule_disabled_by_comment(content, "MD001"));
706 assert!(!is_rule_disabled_at_line(content, "MD001", 1));
708 assert!(is_rule_disabled_at_line(content, "MD001", 2));
710 }
711
712 #[test]
713 fn test_multiple_comments_same_line() {
714 assert_eq!(
716 parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
717 Some(vec!["MD001"])
718 );
719
720 assert_eq!(
721 parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
722 Some(vec!["MD001"])
723 );
724 }
725
726 #[test]
727 fn test_severity_serialization() {
728 let warning = LintWarning {
729 message: "Test warning".to_string(),
730 line: 1,
731 column: 1,
732 end_line: 1,
733 end_column: 10,
734 severity: Severity::Warning,
735 fix: None,
736 rule_name: Some("MD001".to_string()),
737 };
738
739 let serialized = serde_json::to_string(&warning).unwrap();
740 assert!(serialized.contains("\"severity\":\"Warning\""));
741
742 let error = LintWarning {
743 severity: Severity::Error,
744 ..warning
745 };
746
747 let serialized = serde_json::to_string(&error).unwrap();
748 assert!(serialized.contains("\"severity\":\"Error\""));
749 }
750
751 #[test]
752 fn test_fix_serialization() {
753 let fix = Fix {
754 range: 0..10,
755 replacement: "fixed text".to_string(),
756 };
757
758 let warning = LintWarning {
759 message: "Test warning".to_string(),
760 line: 1,
761 column: 1,
762 end_line: 1,
763 end_column: 10,
764 severity: Severity::Warning,
765 fix: Some(fix),
766 rule_name: Some("MD001".to_string()),
767 };
768
769 let serialized = serde_json::to_string(&warning).unwrap();
770 assert!(serialized.contains("\"fix\""));
771 assert!(serialized.contains("\"replacement\":\"fixed text\""));
772 }
773
774 #[test]
775 fn test_rule_category_equality() {
776 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
777 assert_ne!(RuleCategory::Heading, RuleCategory::List);
778
779 let categories = [
781 RuleCategory::Heading,
782 RuleCategory::List,
783 RuleCategory::CodeBlock,
784 RuleCategory::Link,
785 RuleCategory::Image,
786 RuleCategory::Html,
787 RuleCategory::Emphasis,
788 RuleCategory::Whitespace,
789 RuleCategory::Blockquote,
790 RuleCategory::Table,
791 RuleCategory::FrontMatter,
792 RuleCategory::Other,
793 ];
794
795 for (i, cat1) in categories.iter().enumerate() {
796 for (j, cat2) in categories.iter().enumerate() {
797 if i == j {
798 assert_eq!(cat1, cat2);
799 } else {
800 assert_ne!(cat1, cat2);
801 }
802 }
803 }
804 }
805
806 #[test]
807 fn test_lint_error_conversions() {
808 use std::io;
809
810 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
812 let lint_error: LintError = io_error.into();
813 match lint_error {
814 LintError::IoError(_) => {}
815 _ => panic!("Expected IoError variant"),
816 }
817
818 let invalid_input = LintError::InvalidInput("bad input".to_string());
820 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
821
822 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
823 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
824
825 let parsing_error = LintError::ParsingError("parse error".to_string());
826 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
827 }
828
829 #[test]
830 fn test_empty_content_edge_cases() {
831 assert!(!is_rule_disabled_at_line("", "MD001", 0));
832 assert!(!is_rule_disabled_by_comment("", "MD001"));
833
834 let single_comment = "<!-- rumdl-disable -->";
836 assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
837 assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
838 }
839
840 #[test]
841 fn test_very_long_rule_list() {
842 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
843 let comment = format!("<!-- rumdl-disable {many_rules} -->");
844
845 let parsed = parse_disable_comment(&comment);
846 assert!(parsed.is_some());
847 assert_eq!(parsed.unwrap().len(), 100);
848 }
849
850 #[test]
851 fn test_comment_with_special_characters() {
852 assert_eq!(
854 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
855 Some(vec!["MD001-test"])
856 );
857
858 assert_eq!(
859 parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
860 Some(vec!["MD_001"])
861 );
862
863 assert_eq!(
864 parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
865 Some(vec!["MD.001"])
866 );
867 }
868}