Skip to main content

rumdl_lib/
rule.rs

1//!
2//! This module defines the Rule trait and related types for implementing linting rules in rumdl.
3
4use dyn_clone::DynClone;
5use serde::{Deserialize, Serialize};
6use std::ops::Range;
7use thiserror::Error;
8
9use crate::lint_context::LintContext;
10
11// Macro to implement box_clone for Rule implementors
12#[macro_export]
13macro_rules! impl_rule_clone {
14    ($ty:ty) => {
15        impl $ty {
16            fn box_clone(&self) -> Box<dyn Rule> {
17                Box::new(self.clone())
18            }
19        }
20    };
21}
22
23#[derive(Debug, Error)]
24pub enum LintError {
25    #[error("Invalid input: {0}")]
26    InvalidInput(String),
27    #[error("Fix failed: {0}")]
28    FixFailed(String),
29    #[error("IO error: {0}")]
30    IoError(#[from] std::io::Error),
31    #[error("Parsing error: {0}")]
32    ParsingError(String),
33}
34
35pub type LintResult = Result<Vec<LintWarning>, LintError>;
36
37#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
38pub struct LintWarning {
39    pub message: String,
40    pub line: usize,       // 1-indexed start line
41    pub column: usize,     // 1-indexed start column
42    pub end_line: usize,   // 1-indexed end line
43    pub end_column: usize, // 1-indexed end column
44    pub severity: Severity,
45    pub fix: Option<Fix>,
46    pub rule_name: Option<String>,
47}
48
49#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
50pub struct Fix {
51    pub range: Range<usize>,
52    pub replacement: String,
53}
54
55#[derive(Debug, PartialEq, Clone, Copy, Serialize, schemars::JsonSchema)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58    Error,
59    Warning,
60    Info,
61}
62
63impl<'de> serde::Deserialize<'de> for Severity {
64    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65    where
66        D: serde::Deserializer<'de>,
67    {
68        let s = String::deserialize(deserializer)?;
69        match s.to_lowercase().as_str() {
70            "error" => Ok(Severity::Error),
71            "warning" => Ok(Severity::Warning),
72            "info" => Ok(Severity::Info),
73            _ => Err(serde::de::Error::custom(format!(
74                "Invalid severity: '{s}'. Valid values: error, warning, info"
75            ))),
76        }
77    }
78}
79
80/// Type of rule for selective processing
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum RuleCategory {
83    Heading,
84    List,
85    CodeBlock,
86    Link,
87    Image,
88    Html,
89    Emphasis,
90    Whitespace,
91    Blockquote,
92    Table,
93    FrontMatter,
94    Other,
95}
96
97/// Capability of a rule to fix issues
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum FixCapability {
100    /// Rule can automatically fix all violations it detects
101    FullyFixable,
102    /// Rule can fix some violations based on context
103    ConditionallyFixable,
104    /// Rule cannot fix violations (by design)
105    Unfixable,
106}
107
108/// Declares what cross-file data a rule needs
109///
110/// Most rules only need single-file context and should use `None` (the default).
111/// Rules that need to validate references across files (like MD051) should use `Workspace`.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum CrossFileScope {
114    /// Single-file only - no cross-file analysis needed (default for 99% of rules)
115    #[default]
116    None,
117    /// Needs workspace-wide index for cross-file validation
118    Workspace,
119}
120
121/// Remove marker /// TRAIT_MARKER_V1
122pub trait Rule: DynClone + Send + Sync {
123    fn name(&self) -> &'static str;
124    fn description(&self) -> &'static str;
125    fn check(&self, ctx: &LintContext) -> LintResult;
126    fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
127
128    /// Check if this rule should quickly skip processing based on content
129    fn should_skip(&self, _ctx: &LintContext) -> bool {
130        false
131    }
132
133    /// Get the category of this rule for selective processing
134    fn category(&self) -> RuleCategory {
135        RuleCategory::Other // Default implementation returns Other
136    }
137
138    fn as_any(&self) -> &dyn std::any::Any;
139
140    // DocumentStructure has been merged into LintContext - this method is no longer used
141    // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
142    //     None
143    // }
144
145    /// Returns the rule name and default config table if the rule has config.
146    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
147    /// not just the inherent impl.
148    fn default_config_section(&self) -> Option<(String, toml::Value)> {
149        None
150    }
151
152    /// Returns config key aliases for this rule
153    /// This allows rules to accept alternative config key names for backwards compatibility
154    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
155        None
156    }
157
158    /// Declares the fix capability of this rule
159    fn fix_capability(&self) -> FixCapability {
160        FixCapability::FullyFixable // Safe default for backward compatibility
161    }
162
163    /// Declares cross-file analysis requirements for this rule
164    ///
165    /// Returns `CrossFileScope::None` by default, meaning the rule only needs
166    /// single-file context. Rules that need workspace-wide data should override
167    /// this to return `CrossFileScope::Workspace`.
168    fn cross_file_scope(&self) -> CrossFileScope {
169        CrossFileScope::None
170    }
171
172    /// Contribute data to the workspace index during linting
173    ///
174    /// Called during the single-file linting phase for rules that return
175    /// `CrossFileScope::Workspace`. Rules should extract headings, links,
176    /// and other data needed for cross-file validation.
177    ///
178    /// This is called as a side effect of linting, so LintContext is already
179    /// created - no duplicate parsing required.
180    fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
181        // Default: no contribution
182    }
183
184    /// Perform cross-file validation after all files have been linted
185    ///
186    /// Called once per file after the entire workspace has been indexed.
187    /// Rules receive the file_index (from contribute_to_index) and the full
188    /// workspace_index for cross-file lookups.
189    ///
190    /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
191    /// each file. The FileIndex was already populated during contribute_to_index.
192    ///
193    /// Rules can use workspace_index methods for cross-file validation:
194    /// - `get_file(path)` - to look up headings in target files (for MD051)
195    /// - `files()` - to iterate all indexed files
196    ///
197    /// Returns additional warnings for cross-file issues. These are appended
198    /// to the single-file warnings.
199    fn cross_file_check(
200        &self,
201        _file_path: &std::path::Path,
202        _file_index: &crate::workspace_index::FileIndex,
203        _workspace_index: &crate::workspace_index::WorkspaceIndex,
204    ) -> LintResult {
205        Ok(Vec::new()) // Default: no cross-file warnings
206    }
207
208    /// Factory: create a rule from config (if present), or use defaults.
209    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
210    where
211        Self: Sized,
212    {
213        panic!(
214            "from_config not implemented for rule: {}",
215            std::any::type_name::<Self>()
216        );
217    }
218}
219
220// Implement the cloning logic for the Rule trait object
221dyn_clone::clone_trait_object!(Rule);
222
223/// Extension trait to add downcasting capabilities to Rule
224pub trait RuleExt {
225    fn downcast_ref<T: 'static>(&self) -> Option<&T>;
226}
227
228impl<R: Rule + 'static> RuleExt for Box<R> {
229    fn downcast_ref<T: 'static>(&self) -> Option<&T> {
230        if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
231            unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
232        } else {
233            None
234        }
235    }
236}
237
238// Inline config parsing functions are in inline_config.rs.
239// Use InlineConfig::from_content() for the full inline configuration system,
240// or inline_config::parse_disable_comment/parse_enable_comment for low-level parsing.
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_severity_serialization() {
248        let warning = LintWarning {
249            message: "Test warning".to_string(),
250            line: 1,
251            column: 1,
252            end_line: 1,
253            end_column: 10,
254            severity: Severity::Warning,
255            fix: None,
256            rule_name: Some("MD001".to_string()),
257        };
258
259        let serialized = serde_json::to_string(&warning).unwrap();
260        assert!(serialized.contains("\"severity\":\"warning\""));
261
262        let error = LintWarning {
263            severity: Severity::Error,
264            ..warning
265        };
266
267        let serialized = serde_json::to_string(&error).unwrap();
268        assert!(serialized.contains("\"severity\":\"error\""));
269    }
270
271    #[test]
272    fn test_fix_serialization() {
273        let fix = Fix {
274            range: 0..10,
275            replacement: "fixed text".to_string(),
276        };
277
278        let warning = LintWarning {
279            message: "Test warning".to_string(),
280            line: 1,
281            column: 1,
282            end_line: 1,
283            end_column: 10,
284            severity: Severity::Warning,
285            fix: Some(fix),
286            rule_name: Some("MD001".to_string()),
287        };
288
289        let serialized = serde_json::to_string(&warning).unwrap();
290        assert!(serialized.contains("\"fix\""));
291        assert!(serialized.contains("\"replacement\":\"fixed text\""));
292    }
293
294    #[test]
295    fn test_rule_category_equality() {
296        assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
297        assert_ne!(RuleCategory::Heading, RuleCategory::List);
298
299        // Test all categories are distinct
300        let categories = [
301            RuleCategory::Heading,
302            RuleCategory::List,
303            RuleCategory::CodeBlock,
304            RuleCategory::Link,
305            RuleCategory::Image,
306            RuleCategory::Html,
307            RuleCategory::Emphasis,
308            RuleCategory::Whitespace,
309            RuleCategory::Blockquote,
310            RuleCategory::Table,
311            RuleCategory::FrontMatter,
312            RuleCategory::Other,
313        ];
314
315        for (i, cat1) in categories.iter().enumerate() {
316            for (j, cat2) in categories.iter().enumerate() {
317                if i == j {
318                    assert_eq!(cat1, cat2);
319                } else {
320                    assert_ne!(cat1, cat2);
321                }
322            }
323        }
324    }
325
326    #[test]
327    fn test_lint_error_conversions() {
328        use std::io;
329
330        // Test From<io::Error>
331        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
332        let lint_error: LintError = io_error.into();
333        match lint_error {
334            LintError::IoError(_) => {}
335            _ => panic!("Expected IoError variant"),
336        }
337
338        // Test Display trait
339        let invalid_input = LintError::InvalidInput("bad input".to_string());
340        assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
341
342        let fix_failed = LintError::FixFailed("couldn't fix".to_string());
343        assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
344
345        let parsing_error = LintError::ParsingError("parse error".to_string());
346        assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
347    }
348}