rumdl_lib/
rule.rs

1//!
2//! This module defines the Rule trait and related types for implementing linting rules in rumdl.
3//! Includes rule categories, dynamic dispatch helpers, and inline comment handling for rule enable/disable.
4
5use dyn_clone::DynClone;
6use serde::Serialize;
7use std::ops::Range;
8use thiserror::Error;
9
10// Import document structure
11use crate::lint_context::LintContext;
12
13// Import markdown AST for shared parsing
14pub use markdown::mdast::Node as MarkdownAst;
15
16// Macro to implement box_clone for Rule implementors
17#[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)]
43pub struct LintWarning {
44    pub message: String,
45    pub line: usize,       // 1-indexed start line
46    pub column: usize,     // 1-indexed start column
47    pub end_line: usize,   // 1-indexed end line
48    pub end_column: usize, // 1-indexed end column
49    pub severity: Severity,
50    pub fix: Option<Fix>,
51    pub rule_name: Option<&'static str>,
52}
53
54#[derive(Debug, PartialEq, Clone, Serialize)]
55pub struct Fix {
56    pub range: Range<usize>,
57    pub replacement: String,
58}
59
60#[derive(Debug, PartialEq, Clone, Copy, Serialize)]
61pub enum Severity {
62    Error,
63    Warning,
64}
65
66/// Type of rule for selective processing
67#[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/// Capability of a rule to fix issues
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum FixCapability {
86    /// Rule can automatically fix all violations it detects
87    FullyFixable,
88    /// Rule can fix some violations based on context
89    ConditionallyFixable,
90    /// Rule cannot fix violations (by design)
91    Unfixable,
92}
93
94/// Remove marker /// TRAIT_MARKER_V1
95pub 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    /// AST-based check method for rules that can benefit from shared AST parsing
102    /// By default, calls the regular check method if not overridden
103    fn check_with_ast(&self, ctx: &LintContext, _ast: &MarkdownAst) -> LintResult {
104        self.check(ctx)
105    }
106
107    /// Check if this rule should quickly skip processing based on content
108    fn should_skip(&self, _ctx: &LintContext) -> bool {
109        false
110    }
111
112    /// Get the category of this rule for selective processing
113    fn category(&self) -> RuleCategory {
114        RuleCategory::Other // Default implementation returns Other
115    }
116
117    /// Check if this rule can benefit from AST parsing
118    fn uses_ast(&self) -> bool {
119        false
120    }
121
122    fn as_any(&self) -> &dyn std::any::Any;
123
124    // DocumentStructure has been merged into LintContext - this method is no longer used
125    // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
126    //     None
127    // }
128
129    fn as_maybe_ast(&self) -> Option<&dyn MaybeAst> {
130        None
131    }
132
133    /// Returns the rule name and default config table if the rule has config.
134    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
135    /// not just the inherent impl.
136    fn default_config_section(&self) -> Option<(String, toml::Value)> {
137        None
138    }
139
140    /// Returns config key aliases for this rule
141    /// This allows rules to accept alternative config key names for backwards compatibility
142    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
143        None
144    }
145
146    /// Declares the fix capability of this rule
147    fn fix_capability(&self) -> FixCapability {
148        FixCapability::FullyFixable // Safe default for backward compatibility
149    }
150
151    /// Factory: create a rule from config (if present), or use defaults.
152    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
163// Implement the cloning logic for the Rule trait object
164dyn_clone::clone_trait_object!(Rule);
165
166/// Extension trait to add downcasting capabilities to Rule
167pub 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
181/// Check if a rule is disabled at a specific line via inline comments
182pub 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    // Check for both markdownlint-disable and rumdl-disable comments
187    for (i, line) in lines.iter().enumerate() {
188        // Stop processing once we reach the target line
189        if i > line_num {
190            break;
191        }
192
193        // Skip comments that are inside code blocks
194        if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
195            continue;
196        }
197
198        let line = line.trim();
199
200        // Check for disable comments (both global and rule-specific)
201        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        // Check for enable comments (both global and rule-specific)
209        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
220/// Parse a disable comment and return the list of rules (empty vec means all rules)
221pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
222    // Check for rumdl-disable first (preferred syntax)
223    if let Some(start) = line.find("<!-- rumdl-disable") {
224        let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
225
226        // Global disable: <!-- rumdl-disable -->
227        if after_prefix.trim_start().starts_with("-->") {
228            return Some(Vec::new()); // Empty vec means all rules
229        }
230
231        // Rule-specific disable: <!-- rumdl-disable MD001 MD002 -->
232        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    // Check for markdownlint-disable (compatibility)
242    if let Some(start) = line.find("<!-- markdownlint-disable") {
243        let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
244
245        // Global disable: <!-- markdownlint-disable -->
246        if after_prefix.trim_start().starts_with("-->") {
247            return Some(Vec::new()); // Empty vec means all rules
248        }
249
250        // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
251        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
263/// Parse an enable comment and return the list of rules (empty vec means all rules)
264pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
265    // Check for rumdl-enable first (preferred syntax)
266    if let Some(start) = line.find("<!-- rumdl-enable") {
267        let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
268
269        // Global enable: <!-- rumdl-enable -->
270        if after_prefix.trim_start().starts_with("-->") {
271            return Some(Vec::new()); // Empty vec means all rules
272        }
273
274        // Rule-specific enable: <!-- rumdl-enable MD001 MD002 -->
275        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    // Check for markdownlint-enable (compatibility)
285    if let Some(start) = line.find("<!-- markdownlint-enable") {
286        let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
287
288        // Global enable: <!-- markdownlint-enable -->
289        if after_prefix.trim_start().starts_with("-->") {
290            return Some(Vec::new()); // Empty vec means all rules
291        }
292
293        // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
294        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
306/// Check if a rule is disabled via inline comments in the file content (for backward compatibility)
307pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
308    // Check if the rule is disabled at the end of the file
309    let lines: Vec<&str> = content.lines().collect();
310    is_rule_disabled_at_line(content, rule_name, lines.len())
311}
312
313// DocumentStructure has been merged into LintContext - these traits are no longer needed
314// The functionality is now directly available through LintContext methods
315/*
316// Helper trait for dynamic dispatch to check_with_structure
317pub trait MaybeDocumentStructure {
318    fn check_with_structure_opt(
319        &self,
320        ctx: &LintContext,
321        structure: &crate::utils::document_structure::DocumentStructure,
322    ) -> Option<LintResult>;
323}
324
325impl<T> MaybeDocumentStructure for T
326where
327    T: Rule + crate::utils::document_structure::DocumentStructureExtensions + 'static,
328{
329    fn check_with_structure_opt(
330        &self,
331        ctx: &LintContext,
332        structure: &crate::utils::document_structure::DocumentStructure,
333    ) -> Option<LintResult> {
334        Some(self.check_with_structure(ctx, structure))
335    }
336}
337
338impl MaybeDocumentStructure for dyn Rule {
339    fn check_with_structure_opt(
340        &self,
341        _ctx: &LintContext,
342        _structure: &crate::utils::document_structure::DocumentStructure,
343    ) -> Option<LintResult> {
344        None
345    }
346}
347*/
348
349// Helper trait for dynamic dispatch to check_with_ast
350pub 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
373/// Extension trait for rules that use AST
374pub trait AstExtensions {
375    /// Check if the AST contains relevant elements for this rule
376    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        // Test rumdl-disable global
386        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
387
388        // Test rumdl-disable specific rules
389        assert_eq!(
390            parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
391            Some(vec!["MD001", "MD002"])
392        );
393
394        // Test markdownlint-disable global
395        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
396
397        // Test markdownlint-disable specific rules
398        assert_eq!(
399            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
400            Some(vec!["MD001", "MD002"])
401        );
402
403        // Test non-disable comment
404        assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
405
406        // Test with extra whitespace
407        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        // Test rumdl-enable global
416        assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
417
418        // Test rumdl-enable specific rules
419        assert_eq!(
420            parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
421            Some(vec!["MD001", "MD002"])
422        );
423
424        // Test markdownlint-enable global
425        assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
426
427        // Test markdownlint-enable specific rules
428        assert_eq!(
429            parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
430            Some(vec!["MD001", "MD002"])
431        );
432
433        // Test non-enable comment
434        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        // Test MD013 disabled at line 2 (0-indexed line 1)
450        assert!(is_rule_disabled_at_line(content, "MD013", 2));
451
452        // Test MD013 enabled at line 4 (0-indexed line 3)
453        assert!(!is_rule_disabled_at_line(content, "MD013", 4));
454
455        // Test MD042 disabled at line 6 (0-indexed line 5)
456        assert!(is_rule_disabled_at_line(content, "MD042", 6));
457
458        // Test MD042 enabled at line 8 (0-indexed line 7)
459        assert!(!is_rule_disabled_at_line(content, "MD042", 8));
460
461        // Test rule that's never disabled
462        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
463    }
464
465    #[test]
466    fn test_parse_disable_comment_edge_cases() {
467        // Test with no space before closing
468        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
469
470        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
471        assert_eq!(
472            parse_disable_comment("<!--   rumdl-disable   MD001   MD002   -->"),
473            None
474        );
475
476        // Test with tabs
477        assert_eq!(
478            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
479            Some(vec!["MD001", "MD002"])
480        );
481
482        // Test comment not at start of line
483        assert_eq!(
484            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
485            Some(vec!["MD001"])
486        );
487
488        // Test malformed comment (no closing)
489        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
490
491        // Test malformed comment (no opening)
492        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
493
494        // Test case sensitivity
495        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
496        assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
497
498        // Test with newlines - implementation finds the comment
499        assert_eq!(
500            parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
501            Some(vec!["MD001"])
502        );
503
504        // Test empty rule list
505        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
506
507        // Test duplicate rules
508        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        // Test with no space before closing
517        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
518
519        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
520        assert_eq!(parse_enable_comment("<!--   rumdl-enable   MD001   MD002   -->"), None);
521
522        // Test with tabs
523        assert_eq!(
524            parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
525            Some(vec!["MD001", "MD002"])
526        );
527
528        // Test comment not at start of line
529        assert_eq!(
530            parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
531            Some(vec!["MD001"])
532        );
533
534        // Test malformed comment (no closing)
535        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
536
537        // Test malformed comment (no opening)
538        assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
539
540        // Test case sensitivity
541        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
542        assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
543
544        // Test with newlines - implementation finds the comment
545        assert_eq!(
546            parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
547            Some(vec!["MD001"])
548        );
549
550        // Test empty rule list
551        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
552
553        // Test duplicate rules
554        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        // Line 2: All rules disabled
573        assert!(is_rule_disabled_at_line(content, "MD001", 2));
574        assert!(is_rule_disabled_at_line(content, "MD002", 2));
575
576        // Line 4: Still all disabled
577        assert!(is_rule_disabled_at_line(content, "MD001", 4));
578        assert!(is_rule_disabled_at_line(content, "MD002", 4));
579
580        // Line 6: Only MD001 enabled
581        assert!(!is_rule_disabled_at_line(content, "MD001", 6));
582        assert!(is_rule_disabled_at_line(content, "MD002", 6));
583
584        // Line 8: All enabled
585        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        // Line 2: MD001 disabled
602        assert!(is_rule_disabled_at_line(content, "MD001", 2));
603        assert!(!is_rule_disabled_at_line(content, "MD002", 2));
604
605        // Line 4: MD001 enabled
606        assert!(!is_rule_disabled_at_line(content, "MD001", 4));
607        assert!(!is_rule_disabled_at_line(content, "MD002", 4));
608
609        // Line 6: All disabled
610        assert!(is_rule_disabled_at_line(content, "MD001", 6));
611        assert!(is_rule_disabled_at_line(content, "MD002", 6));
612
613        // Line 8: All enabled
614        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        // Comments inside code blocks should be ignored
628        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
629
630        // Test with indented code blocks too
631        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        // Test with unicode in comments
644        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        // Test each line's state
669        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        // Rule should be disabled for the entire file
710        assert!(is_rule_disabled_by_comment(content, "MD001"));
711        // Line indexing - the comment is at line 2 (0-indexed), so line 1 isn't affected
712        assert!(!is_rule_disabled_at_line(content, "MD001", 1));
713        // But it is disabled at line 2
714        assert!(is_rule_disabled_at_line(content, "MD001", 2));
715    }
716
717    #[test]
718    fn test_multiple_comments_same_line() {
719        // Only the first comment should be processed
720        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"),
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"),
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        // Test all categories are distinct
785        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        // Test From<io::Error>
816        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        // Test Display trait
824        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        // Single line with just comment
840        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        // Test with various special characters that might appear
858        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}