rumdl/
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;
12use crate::utils::document_structure::DocumentStructure;
13
14// Import markdown AST for shared parsing
15pub use markdown::mdast::Node as MarkdownAst;
16
17// Macro to implement box_clone for Rule implementors
18#[macro_export]
19macro_rules! impl_rule_clone {
20    ($ty:ty) => {
21        impl $ty {
22            fn box_clone(&self) -> Box<dyn Rule> {
23                Box::new(self.clone())
24            }
25        }
26    };
27}
28
29#[derive(Debug, Error)]
30pub enum LintError {
31    #[error("Invalid input: {0}")]
32    InvalidInput(String),
33    #[error("Fix failed: {0}")]
34    FixFailed(String),
35    #[error("IO error: {0}")]
36    IoError(#[from] std::io::Error),
37    #[error("Parsing error: {0}")]
38    ParsingError(String),
39}
40
41pub type LintResult = Result<Vec<LintWarning>, LintError>;
42
43#[derive(Debug, PartialEq, Clone, Serialize)]
44pub struct LintWarning {
45    pub message: String,
46    pub line: usize,       // 1-indexed start line
47    pub column: usize,     // 1-indexed start column
48    pub end_line: usize,   // 1-indexed end line
49    pub end_column: usize, // 1-indexed end column
50    pub severity: Severity,
51    pub fix: Option<Fix>,
52    pub rule_name: Option<&'static str>,
53}
54
55#[derive(Debug, PartialEq, Clone, Serialize)]
56pub struct Fix {
57    pub range: Range<usize>,
58    pub replacement: String,
59}
60
61#[derive(Debug, PartialEq, Clone, Copy, Serialize)]
62pub enum Severity {
63    Error,
64    Warning,
65}
66
67/// Type of rule for selective processing
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum RuleCategory {
70    Heading,
71    List,
72    CodeBlock,
73    Link,
74    Image,
75    Html,
76    Emphasis,
77    Whitespace,
78    Blockquote,
79    Table,
80    FrontMatter,
81    Other,
82}
83
84/// Capability of a rule to fix issues
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum FixCapability {
87    /// Rule can automatically fix all violations it detects
88    FullyFixable,
89    /// Rule can fix some violations based on context
90    ConditionallyFixable,
91    /// Rule cannot fix violations (by design)
92    Unfixable,
93}
94
95/// Remove marker /// TRAIT_MARKER_V1
96pub trait Rule: DynClone + Send + Sync {
97    fn name(&self) -> &'static str;
98    fn description(&self) -> &'static str;
99    fn check(&self, ctx: &LintContext) -> LintResult;
100    fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
101
102    /// Enhanced check method using document structure
103    /// By default, calls the regular check method if not overridden
104    fn check_with_structure(&self, ctx: &LintContext, _structure: &DocumentStructure) -> LintResult {
105        self.check(ctx)
106    }
107
108    /// AST-based check method for rules that can benefit from shared AST parsing
109    /// By default, calls the regular check method if not overridden
110    fn check_with_ast(&self, ctx: &LintContext, _ast: &MarkdownAst) -> LintResult {
111        self.check(ctx)
112    }
113
114    /// Combined check method using both document structure and AST
115    /// By default, calls the regular check method if not overridden
116    fn check_with_structure_and_ast(
117        &self,
118        ctx: &LintContext,
119        _structure: &DocumentStructure,
120        _ast: &MarkdownAst,
121    ) -> LintResult {
122        self.check(ctx)
123    }
124
125    /// Check if this rule should quickly skip processing based on content
126    fn should_skip(&self, _ctx: &LintContext) -> bool {
127        false
128    }
129
130    /// Get the category of this rule for selective processing
131    fn category(&self) -> RuleCategory {
132        RuleCategory::Other // Default implementation returns Other
133    }
134
135    /// Check if this rule can benefit from AST parsing
136    fn uses_ast(&self) -> bool {
137        false
138    }
139
140    /// Check if this rule can benefit from document structure
141    fn uses_document_structure(&self) -> bool {
142        false
143    }
144
145    fn as_any(&self) -> &dyn std::any::Any;
146
147    fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
148        None
149    }
150
151    fn as_maybe_ast(&self) -> Option<&dyn MaybeAst> {
152        None
153    }
154
155    /// Returns the rule name and default config table if the rule has config.
156    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
157    /// not just the inherent impl.
158    fn default_config_section(&self) -> Option<(String, toml::Value)> {
159        None
160    }
161
162    /// Declares the fix capability of this rule
163    fn fix_capability(&self) -> FixCapability {
164        FixCapability::FullyFixable // Safe default for backward compatibility
165    }
166
167    /// Factory: create a rule from config (if present), or use defaults.
168    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
169    where
170        Self: Sized,
171    {
172        panic!(
173            "from_config not implemented for rule: {}",
174            std::any::type_name::<Self>()
175        );
176    }
177}
178
179// Implement the cloning logic for the Rule trait object
180dyn_clone::clone_trait_object!(Rule);
181
182/// Extension trait to add downcasting capabilities to Rule
183pub trait RuleExt {
184    fn downcast_ref<T: 'static>(&self) -> Option<&T>;
185}
186
187impl<R: Rule + 'static> RuleExt for Box<R> {
188    fn downcast_ref<T: 'static>(&self) -> Option<&T> {
189        if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
190            unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
191        } else {
192            None
193        }
194    }
195}
196
197/// Check if a rule is disabled at a specific line via inline comments
198pub fn is_rule_disabled_at_line(content: &str, rule_name: &str, line_num: usize) -> bool {
199    let lines: Vec<&str> = content.lines().collect();
200    let mut is_disabled = false;
201
202    // Check for both markdownlint-disable and rumdl-disable comments
203    for (i, line) in lines.iter().enumerate() {
204        // Stop processing once we reach the target line
205        if i > line_num {
206            break;
207        }
208
209        // Skip comments that are inside code blocks
210        if crate::rules::code_block_utils::CodeBlockUtils::is_in_code_block(content, i) {
211            continue;
212        }
213
214        let line = line.trim();
215
216        // Check for disable comments (both global and rule-specific)
217        if let Some(rules) = parse_disable_comment(line)
218            && (rules.is_empty() || rules.contains(&rule_name))
219        {
220            is_disabled = true;
221            continue;
222        }
223
224        // Check for enable comments (both global and rule-specific)
225        if let Some(rules) = parse_enable_comment(line)
226            && (rules.is_empty() || rules.contains(&rule_name))
227        {
228            is_disabled = false;
229            continue;
230        }
231    }
232
233    is_disabled
234}
235
236/// Parse a disable comment and return the list of rules (empty vec means all rules)
237pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
238    // Check for rumdl-disable first (preferred syntax)
239    if let Some(start) = line.find("<!-- rumdl-disable") {
240        let after_prefix = &line[start + "<!-- rumdl-disable".len()..];
241
242        // Global disable: <!-- rumdl-disable -->
243        if after_prefix.trim_start().starts_with("-->") {
244            return Some(Vec::new()); // Empty vec means all rules
245        }
246
247        // Rule-specific disable: <!-- rumdl-disable MD001 MD002 -->
248        if let Some(end) = after_prefix.find("-->") {
249            let rules_str = after_prefix[..end].trim();
250            if !rules_str.is_empty() {
251                let rules: Vec<&str> = rules_str.split_whitespace().collect();
252                return Some(rules);
253            }
254        }
255    }
256
257    // Check for markdownlint-disable (compatibility)
258    if let Some(start) = line.find("<!-- markdownlint-disable") {
259        let after_prefix = &line[start + "<!-- markdownlint-disable".len()..];
260
261        // Global disable: <!-- markdownlint-disable -->
262        if after_prefix.trim_start().starts_with("-->") {
263            return Some(Vec::new()); // Empty vec means all rules
264        }
265
266        // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
267        if let Some(end) = after_prefix.find("-->") {
268            let rules_str = after_prefix[..end].trim();
269            if !rules_str.is_empty() {
270                let rules: Vec<&str> = rules_str.split_whitespace().collect();
271                return Some(rules);
272            }
273        }
274    }
275
276    None
277}
278
279/// Parse an enable comment and return the list of rules (empty vec means all rules)
280pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
281    // Check for rumdl-enable first (preferred syntax)
282    if let Some(start) = line.find("<!-- rumdl-enable") {
283        let after_prefix = &line[start + "<!-- rumdl-enable".len()..];
284
285        // Global enable: <!-- rumdl-enable -->
286        if after_prefix.trim_start().starts_with("-->") {
287            return Some(Vec::new()); // Empty vec means all rules
288        }
289
290        // Rule-specific enable: <!-- rumdl-enable MD001 MD002 -->
291        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    // Check for markdownlint-enable (compatibility)
301    if let Some(start) = line.find("<!-- markdownlint-enable") {
302        let after_prefix = &line[start + "<!-- markdownlint-enable".len()..];
303
304        // Global enable: <!-- markdownlint-enable -->
305        if after_prefix.trim_start().starts_with("-->") {
306            return Some(Vec::new()); // Empty vec means all rules
307        }
308
309        // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
310        if let Some(end) = after_prefix.find("-->") {
311            let rules_str = after_prefix[..end].trim();
312            if !rules_str.is_empty() {
313                let rules: Vec<&str> = rules_str.split_whitespace().collect();
314                return Some(rules);
315            }
316        }
317    }
318
319    None
320}
321
322/// Check if a rule is disabled via inline comments in the file content (for backward compatibility)
323pub fn is_rule_disabled_by_comment(content: &str, rule_name: &str) -> bool {
324    // Check if the rule is disabled at the end of the file
325    let lines: Vec<&str> = content.lines().collect();
326    is_rule_disabled_at_line(content, rule_name, lines.len())
327}
328
329// Helper trait for dynamic dispatch to check_with_structure
330pub trait MaybeDocumentStructure {
331    fn check_with_structure_opt(
332        &self,
333        ctx: &LintContext,
334        structure: &crate::utils::document_structure::DocumentStructure,
335    ) -> Option<LintResult>;
336}
337
338impl<T> MaybeDocumentStructure for T
339where
340    T: Rule + crate::utils::document_structure::DocumentStructureExtensions + 'static,
341{
342    fn check_with_structure_opt(
343        &self,
344        ctx: &LintContext,
345        structure: &crate::utils::document_structure::DocumentStructure,
346    ) -> Option<LintResult> {
347        Some(self.check_with_structure(ctx, structure))
348    }
349}
350
351impl MaybeDocumentStructure for dyn Rule {
352    fn check_with_structure_opt(
353        &self,
354        _ctx: &LintContext,
355        _structure: &crate::utils::document_structure::DocumentStructure,
356    ) -> Option<LintResult> {
357        None
358    }
359}
360
361// Helper trait for dynamic dispatch to check_with_ast
362pub trait MaybeAst {
363    fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult>;
364}
365
366impl<T> MaybeAst for T
367where
368    T: Rule + AstExtensions + 'static,
369{
370    fn check_with_ast_opt(&self, ctx: &LintContext, ast: &MarkdownAst) -> Option<LintResult> {
371        if self.has_relevant_ast_elements(ctx, ast) {
372            Some(self.check_with_ast(ctx, ast))
373        } else {
374            None
375        }
376    }
377}
378
379impl MaybeAst for dyn Rule {
380    fn check_with_ast_opt(&self, _ctx: &LintContext, _ast: &MarkdownAst) -> Option<LintResult> {
381        None
382    }
383}
384
385/// Extension trait for rules that use AST
386pub trait AstExtensions {
387    /// Check if the AST contains relevant elements for this rule
388    fn has_relevant_ast_elements(&self, ctx: &LintContext, ast: &MarkdownAst) -> bool;
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_parse_disable_comment() {
397        // Test rumdl-disable global
398        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
399
400        // Test rumdl-disable specific rules
401        assert_eq!(
402            parse_disable_comment("<!-- rumdl-disable MD001 MD002 -->"),
403            Some(vec!["MD001", "MD002"])
404        );
405
406        // Test markdownlint-disable global
407        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
408
409        // Test markdownlint-disable specific rules
410        assert_eq!(
411            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
412            Some(vec!["MD001", "MD002"])
413        );
414
415        // Test non-disable comment
416        assert_eq!(parse_disable_comment("<!-- some other comment -->"), None);
417
418        // Test with extra whitespace
419        assert_eq!(
420            parse_disable_comment("  <!-- rumdl-disable MD013 -->  "),
421            Some(vec!["MD013"])
422        );
423    }
424
425    #[test]
426    fn test_parse_enable_comment() {
427        // Test rumdl-enable global
428        assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
429
430        // Test rumdl-enable specific rules
431        assert_eq!(
432            parse_enable_comment("<!-- rumdl-enable MD001 MD002 -->"),
433            Some(vec!["MD001", "MD002"])
434        );
435
436        // Test markdownlint-enable global
437        assert_eq!(parse_enable_comment("<!-- markdownlint-enable -->"), Some(vec![]));
438
439        // Test markdownlint-enable specific rules
440        assert_eq!(
441            parse_enable_comment("<!-- markdownlint-enable MD001 MD002 -->"),
442            Some(vec!["MD001", "MD002"])
443        );
444
445        // Test non-enable comment
446        assert_eq!(parse_enable_comment("<!-- some other comment -->"), None);
447    }
448
449    #[test]
450    fn test_is_rule_disabled_at_line() {
451        let content = r#"# Test
452<!-- rumdl-disable MD013 -->
453This is a long line
454<!-- rumdl-enable MD013 -->
455This is another line
456<!-- markdownlint-disable MD042 -->
457Empty link: []()
458<!-- markdownlint-enable MD042 -->
459Final line"#;
460
461        // Test MD013 disabled at line 2 (0-indexed line 1)
462        assert!(is_rule_disabled_at_line(content, "MD013", 2));
463
464        // Test MD013 enabled at line 4 (0-indexed line 3)
465        assert!(!is_rule_disabled_at_line(content, "MD013", 4));
466
467        // Test MD042 disabled at line 6 (0-indexed line 5)
468        assert!(is_rule_disabled_at_line(content, "MD042", 6));
469
470        // Test MD042 enabled at line 8 (0-indexed line 7)
471        assert!(!is_rule_disabled_at_line(content, "MD042", 8));
472
473        // Test rule that's never disabled
474        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
475    }
476
477    #[test]
478    fn test_parse_disable_comment_edge_cases() {
479        // Test with no space before closing
480        assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
481
482        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
483        assert_eq!(
484            parse_disable_comment("<!--   rumdl-disable   MD001   MD002   -->"),
485            None
486        );
487
488        // Test with tabs
489        assert_eq!(
490            parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
491            Some(vec!["MD001", "MD002"])
492        );
493
494        // Test comment not at start of line
495        assert_eq!(
496            parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
497            Some(vec!["MD001"])
498        );
499
500        // Test malformed comment (no closing)
501        assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
502
503        // Test malformed comment (no opening)
504        assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
505
506        // Test case sensitivity
507        assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
508        assert_eq!(parse_disable_comment("<!-- RuMdL-DiSaBlE -->"), None);
509
510        // Test with newlines - implementation finds the comment
511        assert_eq!(
512            parse_disable_comment("<!-- rumdl-disable\nMD001 -->"),
513            Some(vec!["MD001"])
514        );
515
516        // Test empty rule list
517        assert_eq!(parse_disable_comment("<!-- rumdl-disable   -->"), Some(vec![]));
518
519        // Test duplicate rules
520        assert_eq!(
521            parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
522            Some(vec!["MD001", "MD001", "MD002"])
523        );
524    }
525
526    #[test]
527    fn test_parse_enable_comment_edge_cases() {
528        // Test with no space before closing
529        assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
530
531        // Test with multiple spaces - the implementation doesn't handle leading spaces in comment
532        assert_eq!(parse_enable_comment("<!--   rumdl-enable   MD001   MD002   -->"), None);
533
534        // Test with tabs
535        assert_eq!(
536            parse_enable_comment("<!-- rumdl-enable\tMD001\tMD002 -->"),
537            Some(vec!["MD001", "MD002"])
538        );
539
540        // Test comment not at start of line
541        assert_eq!(
542            parse_enable_comment("Some text <!-- rumdl-enable MD001 --> more text"),
543            Some(vec!["MD001"])
544        );
545
546        // Test malformed comment (no closing)
547        assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
548
549        // Test malformed comment (no opening)
550        assert_eq!(parse_enable_comment("rumdl-enable MD001 -->"), None);
551
552        // Test case sensitivity
553        assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
554        assert_eq!(parse_enable_comment("<!-- RuMdL-EnAbLe -->"), None);
555
556        // Test with newlines - implementation finds the comment
557        assert_eq!(
558            parse_enable_comment("<!-- rumdl-enable\nMD001 -->"),
559            Some(vec!["MD001"])
560        );
561
562        // Test empty rule list
563        assert_eq!(parse_enable_comment("<!-- rumdl-enable   -->"), Some(vec![]));
564
565        // Test duplicate rules
566        assert_eq!(
567            parse_enable_comment("<!-- rumdl-enable MD001 MD001 MD002 -->"),
568            Some(vec!["MD001", "MD001", "MD002"])
569        );
570    }
571
572    #[test]
573    fn test_nested_disable_enable_comments() {
574        let content = r#"# Document
575<!-- rumdl-disable -->
576All rules disabled here
577<!-- rumdl-disable MD001 -->
578Still all disabled (redundant)
579<!-- rumdl-enable MD001 -->
580Only MD001 enabled, others still disabled
581<!-- rumdl-enable -->
582All rules enabled again"#;
583
584        // Line 2: All rules disabled
585        assert!(is_rule_disabled_at_line(content, "MD001", 2));
586        assert!(is_rule_disabled_at_line(content, "MD002", 2));
587
588        // Line 4: Still all disabled
589        assert!(is_rule_disabled_at_line(content, "MD001", 4));
590        assert!(is_rule_disabled_at_line(content, "MD002", 4));
591
592        // Line 6: Only MD001 enabled
593        assert!(!is_rule_disabled_at_line(content, "MD001", 6));
594        assert!(is_rule_disabled_at_line(content, "MD002", 6));
595
596        // Line 8: All enabled
597        assert!(!is_rule_disabled_at_line(content, "MD001", 8));
598        assert!(!is_rule_disabled_at_line(content, "MD002", 8));
599    }
600
601    #[test]
602    fn test_mixed_comment_styles() {
603        let content = r#"# Document
604<!-- markdownlint-disable MD001 -->
605MD001 disabled via markdownlint
606<!-- rumdl-enable MD001 -->
607MD001 enabled via rumdl
608<!-- rumdl-disable -->
609All disabled via rumdl
610<!-- markdownlint-enable -->
611All enabled via markdownlint"#;
612
613        // Line 2: MD001 disabled
614        assert!(is_rule_disabled_at_line(content, "MD001", 2));
615        assert!(!is_rule_disabled_at_line(content, "MD002", 2));
616
617        // Line 4: MD001 enabled
618        assert!(!is_rule_disabled_at_line(content, "MD001", 4));
619        assert!(!is_rule_disabled_at_line(content, "MD002", 4));
620
621        // Line 6: All disabled
622        assert!(is_rule_disabled_at_line(content, "MD001", 6));
623        assert!(is_rule_disabled_at_line(content, "MD002", 6));
624
625        // Line 8: All enabled
626        assert!(!is_rule_disabled_at_line(content, "MD001", 8));
627        assert!(!is_rule_disabled_at_line(content, "MD002", 8));
628    }
629
630    #[test]
631    fn test_comments_in_code_blocks() {
632        let content = r#"# Document
633```markdown
634<!-- rumdl-disable MD001 -->
635This is in a code block, should not affect rules
636```
637MD001 should still be enabled here"#;
638
639        // Comments inside code blocks should be ignored
640        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
641
642        // Test with indented code blocks too
643        let indented_content = r#"# Document
644
645    <!-- rumdl-disable MD001 -->
646    This is in an indented code block
647
648MD001 should still be enabled here"#;
649
650        assert!(!is_rule_disabled_at_line(indented_content, "MD001", 5));
651    }
652
653    #[test]
654    fn test_comments_with_unicode() {
655        // Test with unicode in comments
656        assert_eq!(
657            parse_disable_comment("<!-- rumdl-disable MD001 --> 你好"),
658            Some(vec!["MD001"])
659        );
660
661        assert_eq!(
662            parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
663            Some(vec!["MD001"])
664        );
665    }
666
667    #[test]
668    fn test_rule_disabled_at_specific_lines() {
669        let content = r#"Line 0
670<!-- rumdl-disable MD001 MD002 -->
671Line 2
672Line 3
673<!-- rumdl-enable MD001 -->
674Line 5
675<!-- rumdl-disable -->
676Line 7
677<!-- rumdl-enable MD002 -->
678Line 9"#;
679
680        // Test each line's state
681        assert!(!is_rule_disabled_at_line(content, "MD001", 0));
682        assert!(!is_rule_disabled_at_line(content, "MD002", 0));
683
684        assert!(is_rule_disabled_at_line(content, "MD001", 2));
685        assert!(is_rule_disabled_at_line(content, "MD002", 2));
686
687        assert!(is_rule_disabled_at_line(content, "MD001", 3));
688        assert!(is_rule_disabled_at_line(content, "MD002", 3));
689
690        assert!(!is_rule_disabled_at_line(content, "MD001", 5));
691        assert!(is_rule_disabled_at_line(content, "MD002", 5));
692
693        assert!(is_rule_disabled_at_line(content, "MD001", 7));
694        assert!(is_rule_disabled_at_line(content, "MD002", 7));
695
696        assert!(is_rule_disabled_at_line(content, "MD001", 9));
697        assert!(!is_rule_disabled_at_line(content, "MD002", 9));
698    }
699
700    #[test]
701    fn test_is_rule_disabled_by_comment() {
702        let content = r#"# Document
703<!-- rumdl-disable MD001 -->
704Content here"#;
705
706        assert!(is_rule_disabled_by_comment(content, "MD001"));
707        assert!(!is_rule_disabled_by_comment(content, "MD002"));
708
709        let content2 = r#"# Document
710<!-- rumdl-disable -->
711Content here"#;
712
713        assert!(is_rule_disabled_by_comment(content2, "MD001"));
714        assert!(is_rule_disabled_by_comment(content2, "MD002"));
715    }
716
717    #[test]
718    fn test_comment_at_end_of_file() {
719        let content = "# Document\nContent\n<!-- rumdl-disable MD001 -->";
720
721        // Rule should be disabled for the entire file
722        assert!(is_rule_disabled_by_comment(content, "MD001"));
723        // Line indexing - the comment is at line 2 (0-indexed), so line 1 isn't affected
724        assert!(!is_rule_disabled_at_line(content, "MD001", 1));
725        // But it is disabled at line 2
726        assert!(is_rule_disabled_at_line(content, "MD001", 2));
727    }
728
729    #[test]
730    fn test_multiple_comments_same_line() {
731        // Only the first comment should be processed
732        assert_eq!(
733            parse_disable_comment("<!-- rumdl-disable MD001 --> <!-- rumdl-disable MD002 -->"),
734            Some(vec!["MD001"])
735        );
736
737        assert_eq!(
738            parse_enable_comment("<!-- rumdl-enable MD001 --> <!-- rumdl-enable MD002 -->"),
739            Some(vec!["MD001"])
740        );
741    }
742
743    #[test]
744    fn test_severity_serialization() {
745        let warning = LintWarning {
746            message: "Test warning".to_string(),
747            line: 1,
748            column: 1,
749            end_line: 1,
750            end_column: 10,
751            severity: Severity::Warning,
752            fix: None,
753            rule_name: Some("MD001"),
754        };
755
756        let serialized = serde_json::to_string(&warning).unwrap();
757        assert!(serialized.contains("\"severity\":\"Warning\""));
758
759        let error = LintWarning {
760            severity: Severity::Error,
761            ..warning
762        };
763
764        let serialized = serde_json::to_string(&error).unwrap();
765        assert!(serialized.contains("\"severity\":\"Error\""));
766    }
767
768    #[test]
769    fn test_fix_serialization() {
770        let fix = Fix {
771            range: 0..10,
772            replacement: "fixed text".to_string(),
773        };
774
775        let warning = LintWarning {
776            message: "Test warning".to_string(),
777            line: 1,
778            column: 1,
779            end_line: 1,
780            end_column: 10,
781            severity: Severity::Warning,
782            fix: Some(fix),
783            rule_name: Some("MD001"),
784        };
785
786        let serialized = serde_json::to_string(&warning).unwrap();
787        assert!(serialized.contains("\"fix\""));
788        assert!(serialized.contains("\"replacement\":\"fixed text\""));
789    }
790
791    #[test]
792    fn test_rule_category_equality() {
793        assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
794        assert_ne!(RuleCategory::Heading, RuleCategory::List);
795
796        // Test all categories are distinct
797        let categories = [
798            RuleCategory::Heading,
799            RuleCategory::List,
800            RuleCategory::CodeBlock,
801            RuleCategory::Link,
802            RuleCategory::Image,
803            RuleCategory::Html,
804            RuleCategory::Emphasis,
805            RuleCategory::Whitespace,
806            RuleCategory::Blockquote,
807            RuleCategory::Table,
808            RuleCategory::FrontMatter,
809            RuleCategory::Other,
810        ];
811
812        for (i, cat1) in categories.iter().enumerate() {
813            for (j, cat2) in categories.iter().enumerate() {
814                if i == j {
815                    assert_eq!(cat1, cat2);
816                } else {
817                    assert_ne!(cat1, cat2);
818                }
819            }
820        }
821    }
822
823    #[test]
824    fn test_lint_error_conversions() {
825        use std::io;
826
827        // Test From<io::Error>
828        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
829        let lint_error: LintError = io_error.into();
830        match lint_error {
831            LintError::IoError(_) => {}
832            _ => panic!("Expected IoError variant"),
833        }
834
835        // Test Display trait
836        let invalid_input = LintError::InvalidInput("bad input".to_string());
837        assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
838
839        let fix_failed = LintError::FixFailed("couldn't fix".to_string());
840        assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
841
842        let parsing_error = LintError::ParsingError("parse error".to_string());
843        assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
844    }
845
846    #[test]
847    fn test_empty_content_edge_cases() {
848        assert!(!is_rule_disabled_at_line("", "MD001", 0));
849        assert!(!is_rule_disabled_by_comment("", "MD001"));
850
851        // Single line with just comment
852        let single_comment = "<!-- rumdl-disable -->";
853        assert!(is_rule_disabled_at_line(single_comment, "MD001", 0));
854        assert!(is_rule_disabled_by_comment(single_comment, "MD001"));
855    }
856
857    #[test]
858    fn test_very_long_rule_list() {
859        let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
860        let comment = format!("<!-- rumdl-disable {many_rules} -->");
861
862        let parsed = parse_disable_comment(&comment);
863        assert!(parsed.is_some());
864        assert_eq!(parsed.unwrap().len(), 100);
865    }
866
867    #[test]
868    fn test_comment_with_special_characters() {
869        // Test with various special characters that might appear
870        assert_eq!(
871            parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
872            Some(vec!["MD001-test"])
873        );
874
875        assert_eq!(
876            parse_disable_comment("<!-- rumdl-disable MD_001 -->"),
877            Some(vec!["MD_001"])
878        );
879
880        assert_eq!(
881            parse_disable_comment("<!-- rumdl-disable MD.001 -->"),
882            Some(vec!["MD.001"])
883        );
884    }
885}