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