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