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    /// 1-indexed start column, measured in **characters** (not bytes).
42    /// When deriving a column from a byte offset (regex match, `str::find`,
43    /// parser byte offset), convert with `range_utils::byte_to_char_count` or a
44    /// character-based range helper. A raw byte offset mis-positions the
45    /// highlight on lines containing multi-byte UTF-8.
46    pub column: usize,
47    pub end_line: usize, // 1-indexed end line
48    /// 1-indexed end column, measured in **characters** (see `column`). Use
49    /// `str::chars().count()`, not `str::len()`, when computing a span width.
50    pub end_column: usize,
51    pub severity: Severity,
52    pub fix: Option<Fix>,
53    pub rule_name: Option<String>,
54}
55
56/// One atomic fix attached to a `LintWarning`.
57///
58/// `range`/`replacement` describe the primary edit. `additional_edits`
59/// carries any *paired* edits that must apply together with the primary one
60/// for the result to be a valid document — for example, MD054's conversion
61/// of an inline link to a reference style produces the in-place link rewrite
62/// **and** a reference-definition append at end-of-file; applying only one
63/// half would leave a dangling reference.
64///
65/// All fix consumers (the LSP code-action layer, CLI counters, the
66/// `apply_warning_fixes` helper) treat the primary edit and the
67/// `additional_edits` as a single unit. The field is empty by default so
68/// rules that only need a single-location fix can keep using
69/// `Fix::new(range, replacement)`; only rules that need multi-location
70/// atomicity populate it via `Fix::with_additional_edits(...)`.
71///
72/// `additional_edits` is intentionally a flat `Vec<Fix>` — nesting beyond
73/// one level isn't needed today and would complicate the apply contract.
74/// Apply order is "primary first, then additional in their declared order"
75/// when offsets are non-overlapping; consumers that batch multiple fixes
76/// across warnings still sort by `range.start` descending so earlier offsets
77/// remain valid as later edits mutate the buffer.
78#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
79pub struct Fix {
80    pub range: Range<usize>,
81    pub replacement: String,
82    /// Edits applied atomically with the primary `range`/`replacement` pair.
83    /// Empty for the common single-edit case. See struct docs for semantics.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub additional_edits: Vec<Fix>,
86}
87
88impl Fix {
89    /// Construct a single-edit fix. Use this for the overwhelming common case
90    /// where a fix is one in-place replacement.
91    pub fn new(range: Range<usize>, replacement: String) -> Self {
92        Self {
93            range,
94            replacement,
95            additional_edits: Vec::new(),
96        }
97    }
98
99    /// Construct a multi-edit fix bundle. The primary edit is applied first,
100    /// followed by every entry in `additional_edits` as part of the same
101    /// atomic operation.
102    pub fn with_additional_edits(range: Range<usize>, replacement: String, additional_edits: Vec<Fix>) -> Self {
103        Self {
104            range,
105            replacement,
106            additional_edits,
107        }
108    }
109}
110
111#[derive(Debug, PartialEq, Clone, Copy, Serialize, schemars::JsonSchema)]
112#[serde(rename_all = "lowercase")]
113pub enum Severity {
114    Error,
115    Warning,
116    Info,
117}
118
119impl<'de> serde::Deserialize<'de> for Severity {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: serde::Deserializer<'de>,
123    {
124        let s = String::deserialize(deserializer)?;
125        match s.to_lowercase().as_str() {
126            "error" => Ok(Severity::Error),
127            "warning" => Ok(Severity::Warning),
128            "info" => Ok(Severity::Info),
129            _ => Err(serde::de::Error::custom(format!(
130                "Invalid severity: '{s}'. Valid values: error, warning, info"
131            ))),
132        }
133    }
134}
135
136/// Type of rule for selective processing
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RuleCategory {
139    Heading,
140    List,
141    CodeBlock,
142    Link,
143    Image,
144    Html,
145    Emphasis,
146    Whitespace,
147    Blockquote,
148    Table,
149    FrontMatter,
150    Other,
151}
152
153/// Capability of a rule to fix issues
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum FixCapability {
156    /// Rule can automatically fix all violations it detects
157    FullyFixable,
158    /// Rule can fix some violations based on context
159    ConditionallyFixable,
160    /// Rule cannot fix violations (by design)
161    Unfixable,
162}
163
164/// Declares what cross-file data a rule needs
165///
166/// Most rules only need single-file context and should use `None` (the default).
167/// Rules that need to validate references across files (like MD051) should use `Workspace`.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum CrossFileScope {
170    /// Single-file only - no cross-file analysis needed (default for 99% of rules)
171    #[default]
172    None,
173    /// Needs workspace-wide index for cross-file validation
174    Workspace,
175}
176
177/// A warning an inline disable comment kept out of a document's results.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct SuppressedWarning {
180    /// Canonical id of the rule that raised the warning, e.g. `MD013`
181    pub rule_name: String,
182    /// 1-indexed line of the warning's range the rule was found disabled on
183    pub line: usize,
184    /// The kind of directive that disabled the rule there
185    pub layer: crate::inline_config::DisableLayer,
186}
187
188/// What a run's inline disable comments actually suppressed.
189///
190/// Assembled once per document, after every single-file rule has run, so a rule
191/// reading it sees the complete picture.
192#[derive(Debug, Clone, Default)]
193pub struct SuppressionReport {
194    /// Every warning an inline disable comment removed, in the order raised
195    pub suppressed: Vec<SuppressedWarning>,
196    /// Canonical ids of the rules whose findings this report accounts for.
197    ///
198    /// A rule outside this set produced nothing the report can see, so nothing
199    /// can be concluded about a comment naming it.
200    pub judged_rules: std::collections::HashSet<String>,
201}
202
203pub trait Rule: DynClone + Send + Sync {
204    fn name(&self) -> &'static str;
205    fn description(&self) -> &'static str;
206    fn check(&self, ctx: &LintContext) -> LintResult;
207    fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
208
209    /// Check if this rule should quickly skip processing based on content
210    fn should_skip(&self, _ctx: &LintContext) -> bool {
211        false
212    }
213
214    /// Get the category of this rule for selective processing
215    fn category(&self) -> RuleCategory {
216        RuleCategory::Other // Default implementation returns Other
217    }
218
219    /// Whether the content-category prefilter may skip this rule.
220    ///
221    /// The prefilter reads the document's shape alone: a `Link` rule is skipped
222    /// for a document holding no links. A rule whose configuration widens what
223    /// it reads answers `false` for that configuration, so that `should_skip`
224    /// and `check` decide instead. MD051 and MD057 read frontmatter values on
225    /// request, and a document can carry those with no link syntax at all.
226    fn skippable_by_category(&self) -> bool {
227        true
228    }
229
230    fn as_any(&self) -> &dyn std::any::Any;
231
232    // DocumentStructure has been merged into LintContext - this method is no longer used
233    // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
234    //     None
235    // }
236
237    /// Returns the rule name and default config table if the rule has config.
238    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
239    /// not just the inherent impl.
240    fn default_config_section(&self) -> Option<(String, toml::Value)> {
241        None
242    }
243
244    /// Returns config key aliases for this rule
245    /// This allows rules to accept alternative config key names for backwards compatibility
246    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
247        None
248    }
249
250    /// Returns the list of config keys whose deserializer accepts more than one TOML
251    /// type (e.g. either a scalar or a list). The schema is built from a serialized
252    /// default that can only encode one variant, so the validator would reject the
253    /// alternative form. The registry replaces the schema entry for each listed key
254    /// with a polymorphic sentinel so type checking is skipped while the key name
255    /// is still validated. Keep `default_config_section()` returning clean defaults
256    /// — the sentinel is a schema concern and must not leak into user-facing output
257    /// like `rumdl config --defaults`.
258    fn polymorphic_config_keys(&self) -> &'static [&'static str] {
259        &[]
260    }
261
262    /// Declares the fix capability of this rule
263    fn fix_capability(&self) -> FixCapability {
264        FixCapability::FullyFixable // Safe default for backward compatibility
265    }
266
267    /// Declares cross-file analysis requirements for this rule
268    ///
269    /// Returns `CrossFileScope::None` by default, meaning the rule only needs
270    /// single-file context. Rules that need workspace-wide data should override
271    /// this to return `CrossFileScope::Workspace`.
272    fn cross_file_scope(&self) -> CrossFileScope {
273        CrossFileScope::None
274    }
275
276    /// Contribute data to the workspace index during linting
277    ///
278    /// Called during the single-file linting phase for rules that return
279    /// `CrossFileScope::Workspace`. Rules should extract headings, links,
280    /// and other data needed for cross-file validation.
281    ///
282    /// This is called as a side effect of linting, so LintContext is already
283    /// created - no duplicate parsing required.
284    fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
285        // Default: no contribution
286    }
287
288    /// Perform cross-file validation after all files have been linted
289    ///
290    /// Called once per file after the entire workspace has been indexed.
291    /// Rules receive the file_index (from contribute_to_index) and the full
292    /// workspace_index for cross-file lookups.
293    ///
294    /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
295    /// each file. The FileIndex was already populated during contribute_to_index.
296    ///
297    /// Rules can use workspace_index methods for cross-file validation:
298    /// - `get_file(path)` - to look up headings in target files (for MD051)
299    /// - `files()` - to iterate all indexed files
300    ///
301    /// Returns additional warnings for cross-file issues. These are appended
302    /// to the single-file warnings.
303    fn cross_file_check(
304        &self,
305        _file_path: &std::path::Path,
306        _file_index: &crate::workspace_index::FileIndex,
307        _workspace_index: &crate::workspace_index::WorkspaceIndex,
308    ) -> LintResult {
309        Ok(Vec::new()) // Default: no cross-file warnings
310    }
311
312    /// Whether this rule reports on the inline comments that suppressed warnings
313    ///
314    /// Recording every suppression costs work on each file, so the linting driver
315    /// does it only when a rule asks for it. A rule answering `true` receives the
316    /// result through `check_suppressions`.
317    fn observes_suppressions(&self) -> bool {
318        false
319    }
320
321    /// Report on a document's inline disable comments
322    ///
323    /// Called once per document after every single-file rule has run, for rules
324    /// that return `true` from `observes_suppressions`. The report says which
325    /// warnings the comments removed and which rules the run can account for.
326    ///
327    /// Returns warnings that join the single-file warnings.
328    fn check_suppressions(&self, _ctx: &LintContext, _report: &SuppressionReport) -> LintResult {
329        Ok(Vec::new()) // Default: nothing to report
330    }
331
332    /// Factory: create a rule from config (if present), or use defaults.
333    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
334    where
335        Self: Sized,
336    {
337        panic!(
338            "from_config not implemented for rule: {}",
339            std::any::type_name::<Self>()
340        );
341    }
342}
343
344// Implement the cloning logic for the Rule trait object
345dyn_clone::clone_trait_object!(Rule);
346
347/// Extension trait to add downcasting capabilities to Rule
348pub trait RuleExt {
349    fn downcast_ref<T: 'static>(&self) -> Option<&T>;
350}
351
352impl<R: Rule + 'static> RuleExt for Box<R> {
353    fn downcast_ref<T: 'static>(&self) -> Option<&T> {
354        if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
355            unsafe { Some(&*std::ptr::from_ref(self.as_ref()).cast::<T>()) }
356        } else {
357            None
358        }
359    }
360}
361
362// Inline config parsing functions are in inline_config.rs.
363// Use InlineConfig::from_content() for the full inline configuration system,
364// or inline_config::parse_disable_comment/parse_enable_comment for low-level parsing.
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_severity_serialization() {
372        let warning = LintWarning {
373            message: "Test warning".to_string(),
374            line: 1,
375            column: 1,
376            end_line: 1,
377            end_column: 10,
378            severity: Severity::Warning,
379            fix: None,
380            rule_name: Some("MD001".to_string()),
381        };
382
383        let serialized = serde_json::to_string(&warning).unwrap();
384        assert!(serialized.contains("\"severity\":\"warning\""));
385
386        let error = LintWarning {
387            severity: Severity::Error,
388            ..warning
389        };
390
391        let serialized = serde_json::to_string(&error).unwrap();
392        assert!(serialized.contains("\"severity\":\"error\""));
393    }
394
395    #[test]
396    fn test_fix_serialization() {
397        let fix = Fix::new(0..10, "fixed text".to_string());
398
399        let warning = LintWarning {
400            message: "Test warning".to_string(),
401            line: 1,
402            column: 1,
403            end_line: 1,
404            end_column: 10,
405            severity: Severity::Warning,
406            fix: Some(fix),
407            rule_name: Some("MD001".to_string()),
408        };
409
410        let serialized = serde_json::to_string(&warning).unwrap();
411        assert!(serialized.contains("\"fix\""));
412        assert!(serialized.contains("\"replacement\":\"fixed text\""));
413    }
414
415    #[test]
416    fn test_rule_category_equality() {
417        assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
418        assert_ne!(RuleCategory::Heading, RuleCategory::List);
419
420        // Test all categories are distinct
421        let categories = [
422            RuleCategory::Heading,
423            RuleCategory::List,
424            RuleCategory::CodeBlock,
425            RuleCategory::Link,
426            RuleCategory::Image,
427            RuleCategory::Html,
428            RuleCategory::Emphasis,
429            RuleCategory::Whitespace,
430            RuleCategory::Blockquote,
431            RuleCategory::Table,
432            RuleCategory::FrontMatter,
433            RuleCategory::Other,
434        ];
435
436        for (i, cat1) in categories.iter().enumerate() {
437            for (j, cat2) in categories.iter().enumerate() {
438                if i == j {
439                    assert_eq!(cat1, cat2);
440                } else {
441                    assert_ne!(cat1, cat2);
442                }
443            }
444        }
445    }
446
447    #[test]
448    fn test_lint_error_conversions() {
449        use std::io;
450
451        // Test From<io::Error>
452        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
453        let lint_error: LintError = io_error.into();
454        match lint_error {
455            LintError::IoError(_) => {}
456            _ => panic!("Expected IoError variant"),
457        }
458
459        // Test Display trait
460        let invalid_input = LintError::InvalidInput("bad input".to_string());
461        assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
462
463        let fix_failed = LintError::FixFailed("couldn't fix".to_string());
464        assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
465
466        let parsing_error = LintError::ParsingError("parse error".to_string());
467        assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
468    }
469}