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    /// Byte range in the text supplied to the producing API. Public document
81    /// linting includes any leading BOM; raw `Rule::check` ranges address the
82    /// rule's `LintContext::content`.
83    pub range: Range<usize>,
84    pub replacement: String,
85    /// Edits applied atomically with the primary `range`/`replacement` pair.
86    /// Empty for the common single-edit case. See struct docs for semantics.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub additional_edits: Vec<Fix>,
89}
90
91impl Fix {
92    /// Construct a single-edit fix. Use this for the overwhelming common case
93    /// where a fix is one in-place replacement.
94    pub fn new(range: Range<usize>, replacement: String) -> Self {
95        Self {
96            range,
97            replacement,
98            additional_edits: Vec::new(),
99        }
100    }
101
102    /// Construct a multi-edit fix bundle. The primary edit is applied first,
103    /// followed by every entry in `additional_edits` as part of the same
104    /// atomic operation.
105    pub fn with_additional_edits(range: Range<usize>, replacement: String, additional_edits: Vec<Fix>) -> Self {
106        Self {
107            range,
108            replacement,
109            additional_edits,
110        }
111    }
112}
113
114#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, schemars::JsonSchema)]
115#[serde(rename_all = "lowercase")]
116pub enum Severity {
117    Error,
118    Warning,
119    Info,
120}
121
122impl<'de> serde::Deserialize<'de> for Severity {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: serde::Deserializer<'de>,
126    {
127        let s = String::deserialize(deserializer)?;
128        match s.to_lowercase().as_str() {
129            "error" => Ok(Severity::Error),
130            "warning" => Ok(Severity::Warning),
131            "info" => Ok(Severity::Info),
132            _ => Err(serde::de::Error::custom(format!(
133                "Invalid severity: '{s}'. Valid values: error, warning, info"
134            ))),
135        }
136    }
137}
138
139/// Type of rule for selective processing
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum RuleCategory {
142    Heading,
143    List,
144    CodeBlock,
145    Link,
146    Image,
147    Html,
148    Emphasis,
149    Whitespace,
150    Blockquote,
151    Table,
152    FrontMatter,
153    Other,
154}
155
156/// Capability of a rule to fix issues
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum FixCapability {
159    /// Rule can automatically fix all violations it detects
160    FullyFixable,
161    /// Rule can fix some violations based on context
162    ConditionallyFixable,
163    /// Rule cannot fix violations (by design)
164    Unfixable,
165}
166
167/// Declares what cross-file data a rule needs
168///
169/// Most rules only need single-file context and should use `None` (the default).
170/// Rules that need to validate references across files (like MD051) should use `Workspace`.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum CrossFileScope {
173    /// Single-file only - no cross-file analysis needed (default for 99% of rules)
174    #[default]
175    None,
176    /// Needs workspace-wide index for cross-file validation
177    Workspace,
178}
179
180/// A warning an inline disable comment kept out of a document's results.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct SuppressedWarning {
183    /// Canonical id of the rule that raised the warning, e.g. `MD013`
184    pub rule_name: String,
185    /// 1-indexed line of the warning's range the rule was found disabled on
186    pub line: usize,
187    /// The kind of directive that disabled the rule there
188    pub layer: crate::inline_config::DisableLayer,
189}
190
191/// What a run's inline disable comments actually suppressed.
192///
193/// Assembled once per document, after every single-file rule has run, so a rule
194/// reading it sees the complete picture.
195#[derive(Debug, Clone, Default)]
196pub struct SuppressionReport {
197    /// Every warning an inline disable comment removed, in the order raised
198    pub suppressed: Vec<SuppressedWarning>,
199    /// Canonical ids of the rules whose findings this report accounts for.
200    ///
201    /// A rule outside this set produced nothing the report can see, so nothing
202    /// can be concluded about a comment naming it.
203    pub judged_rules: std::collections::HashSet<String>,
204}
205
206pub trait Rule: DynClone + Send + Sync {
207    fn name(&self) -> &'static str;
208    fn description(&self) -> &'static str;
209    /// Return raw diagnostics, including findings covered by inline disables.
210    /// The lint driver filters them and records which comments suppressed them
211    /// for observers such as MD087. Filtering them here loses that accounting.
212    /// Disabled regions may still be excluded from inferred settings, and `fix`
213    /// must honor them when rewriting content.
214    fn check(&self, ctx: &LintContext) -> LintResult;
215    fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
216
217    /// Return the native fix when computing findings already produces it.
218    /// The result belongs only to this configured instance and exact context;
219    /// callers discard it with the findings after any source rewrite.
220    #[doc(hidden)]
221    fn check_with_prepared_fix(&self, ctx: &LintContext) -> Result<(Vec<LintWarning>, Option<String>), LintError> {
222        self.check(ctx).map(|warnings| (warnings, None))
223    }
224
225    /// Apply this rule's native fixer using its already-computed findings.
226    /// Callers must supply raw diagnostics from this configured instance and
227    /// this exact immutable context. No findings survive a source rewrite.
228    /// Custom rules keep their normal fixer unless they explicitly opt in.
229    #[doc(hidden)]
230    fn fix_with_diagnostics(&self, ctx: &LintContext, _warnings: &[LintWarning]) -> Result<String, LintError> {
231        self.fix(ctx)
232    }
233
234    /// Whether CLI fixing is exactly the application of this rule's diagnostic
235    /// edits, filtered by inline disables. The engine can then consume the
236    /// already-computed edits without checking the same source a second time.
237    ///
238    /// Opt in only when `fix()` performs no additional transformations. The
239    /// default preserves custom fix implementations and third-party rules.
240    /// When opting in, `should_skip(ctx)` must imply that applying the raw
241    /// check diagnostics would leave the source unchanged as well.
242    #[doc(hidden)]
243    fn fixes_from_diagnostics(&self) -> bool {
244        false
245    }
246
247    /// Built-in-only certificate for proving that a source revision needs no fix.
248    /// External rules retain their existing check and fix behavior by default.
249    #[doc(hidden)]
250    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
251        None
252    }
253
254    /// Check if this rule should quickly skip processing based on content.
255    fn should_skip(&self, _ctx: &LintContext) -> bool {
256        false
257    }
258
259    /// Get the category of this rule for selective processing
260    fn category(&self) -> RuleCategory {
261        RuleCategory::Other // Default implementation returns Other
262    }
263
264    /// Whether the content-category prefilter may skip this rule.
265    ///
266    /// The prefilter reads the document's shape alone: a `Link` rule is skipped
267    /// for a document holding no links. A rule whose configuration widens what
268    /// it reads answers `false` for that configuration, so that `should_skip`
269    /// and `check` decide instead. MD051 and MD057 read frontmatter values on
270    /// request, and a document can carry those with no link syntax at all.
271    fn skippable_by_category(&self) -> bool {
272        true
273    }
274
275    /// Return this concrete rule instance for type-based queries.
276    fn as_any(&self) -> &dyn std::any::Any;
277
278    // DocumentStructure has been merged into LintContext - this method is no longer used
279    // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
280    //     None
281    // }
282
283    /// Returns the rule name and default config table if the rule has config.
284    /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
285    /// not just the inherent impl.
286    ///
287    /// This is user-facing: it backs `rumdl config`, `rumdl config --defaults` and
288    /// `rumdl explain`, so every value here must be a real default the user could write
289    /// back into a config file. A key whose default cannot be written down (an unset
290    /// `Option`) is therefore absent. Config *validation* reads [`Rule::config_schema`]
291    /// instead, which keeps such keys.
292    fn default_config_section(&self) -> Option<(String, toml::Value)> {
293        None
294    }
295
296    /// Returns the rule name and every config key the rule accepts, for validation.
297    ///
298    /// A key with no representable default (an unset `Option`, or a deserializer that
299    /// accepts several TOML types) carries a sentinel value: the key name is recognized
300    /// while its type check is skipped. Sentinels contain a NUL byte and must never
301    /// reach user-facing output, which is why this is separate from
302    /// [`Rule::default_config_section`].
303    ///
304    /// Defaults to the user-facing table, which is correct for a rule whose every key
305    /// has a representable default.
306    fn config_schema(&self) -> Option<(String, toml::Value)> {
307        self.default_config_section()
308    }
309
310    /// Returns config key aliases for this rule
311    /// This allows rules to accept alternative config key names for backwards compatibility
312    fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
313        None
314    }
315
316    /// Returns the list of config keys whose deserializer accepts more than one TOML
317    /// type (e.g. either a scalar or a list). The schema is built from a serialized
318    /// default that can only encode one variant, so the validator would reject the
319    /// alternative form. The registry replaces the schema entry for each listed key
320    /// with a polymorphic sentinel so type checking is skipped while the key name
321    /// is still validated. The registry rewrites [`Rule::config_schema`], so
322    /// `default_config_section()` keeps returning the clean user-facing default.
323    fn polymorphic_config_keys(&self) -> &'static [&'static str] {
324        &[]
325    }
326
327    /// Declares the fix capability of this rule
328    fn fix_capability(&self) -> FixCapability {
329        FixCapability::FullyFixable // Safe default for backward compatibility
330    }
331
332    /// Declares cross-file analysis requirements for this rule
333    ///
334    /// Returns `CrossFileScope::None` by default, meaning the rule only needs
335    /// single-file context. Rules that need workspace-wide data should override
336    /// this to return `CrossFileScope::Workspace`.
337    fn cross_file_scope(&self) -> CrossFileScope {
338        CrossFileScope::None
339    }
340
341    /// Contribute data to the workspace index during linting
342    ///
343    /// Called during the single-file linting phase for rules that return
344    /// `CrossFileScope::Workspace`. Rules should extract headings, links,
345    /// and other data needed for cross-file validation.
346    ///
347    /// This is called as a side effect of linting, so LintContext is already
348    /// created - no duplicate parsing required.
349    fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
350        // Default: no contribution
351    }
352
353    /// Perform cross-file validation after all files have been linted
354    ///
355    /// Called once per file after the entire workspace has been indexed.
356    /// Rules receive the file_index (from contribute_to_index) and the full
357    /// workspace_index for cross-file lookups.
358    ///
359    /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
360    /// each file. The FileIndex was already populated during contribute_to_index.
361    ///
362    /// Rules can use workspace_index methods for cross-file validation:
363    /// - `get_file(path)` - to look up headings in target files (for MD051)
364    /// - `files()` - to iterate all indexed files
365    ///
366    /// Returns additional warnings for cross-file issues. These are appended
367    /// to the single-file warnings.
368    fn cross_file_check(
369        &self,
370        _file_path: &std::path::Path,
371        _file_index: &crate::workspace_index::FileIndex,
372        _workspace_index: &crate::workspace_index::WorkspaceIndex,
373    ) -> LintResult {
374        Ok(Vec::new()) // Default: no cross-file warnings
375    }
376
377    /// Whether this rule reports on the inline comments that suppressed warnings
378    ///
379    /// Recording every suppression costs work on each file, so the linting driver
380    /// does it only when a rule asks for it. A rule answering `true` receives the
381    /// result through `check_suppressions`.
382    fn observes_suppressions(&self) -> bool {
383        false
384    }
385
386    /// Report on a document's inline disable comments
387    ///
388    /// Called once per document after every single-file rule has run, for rules
389    /// that return `true` from `observes_suppressions`. The report says which
390    /// warnings the comments removed and which rules the run can account for.
391    ///
392    /// Returns warnings that join the single-file warnings.
393    fn check_suppressions(&self, _ctx: &LintContext, _report: &SuppressionReport) -> LintResult {
394        Ok(Vec::new()) // Default: nothing to report
395    }
396
397    /// Factory: create a rule from config (if present), or use defaults.
398    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
399    where
400        Self: Sized,
401    {
402        panic!(
403            "from_config not implemented for rule: {}",
404            std::any::type_name::<Self>()
405        );
406    }
407}
408
409// Implement the cloning logic for the Rule trait object
410dyn_clone::clone_trait_object!(Rule);
411
412/// Extension trait to add downcasting capabilities to Rule
413pub trait RuleExt {
414    fn downcast_ref<T: 'static>(&self) -> Option<&T>;
415}
416
417impl<R: Rule + 'static> RuleExt for Box<R> {
418    fn downcast_ref<T: 'static>(&self) -> Option<&T> {
419        if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
420            unsafe { Some(&*std::ptr::from_ref(self.as_ref()).cast::<T>()) }
421        } else {
422            None
423        }
424    }
425}
426
427// Inline config parsing functions are in inline_config.rs.
428// Use InlineConfig::from_content() for the full inline configuration system,
429// or inline_config::parse_disable_comment/parse_enable_comment for low-level parsing.
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_severity_serialization() {
437        let warning = LintWarning {
438            message: "Test warning".to_string(),
439            line: 1,
440            column: 1,
441            end_line: 1,
442            end_column: 10,
443            severity: Severity::Warning,
444            fix: None,
445            rule_name: Some("MD001".to_string()),
446        };
447
448        let serialized = serde_json::to_string(&warning).unwrap();
449        assert!(serialized.contains("\"severity\":\"warning\""));
450
451        let error = LintWarning {
452            severity: Severity::Error,
453            ..warning
454        };
455
456        let serialized = serde_json::to_string(&error).unwrap();
457        assert!(serialized.contains("\"severity\":\"error\""));
458    }
459
460    #[test]
461    fn test_fix_serialization() {
462        let fix = Fix::new(0..10, "fixed text".to_string());
463
464        let warning = LintWarning {
465            message: "Test warning".to_string(),
466            line: 1,
467            column: 1,
468            end_line: 1,
469            end_column: 10,
470            severity: Severity::Warning,
471            fix: Some(fix),
472            rule_name: Some("MD001".to_string()),
473        };
474
475        let serialized = serde_json::to_string(&warning).unwrap();
476        assert!(serialized.contains("\"fix\""));
477        assert!(serialized.contains("\"replacement\":\"fixed text\""));
478    }
479
480    #[test]
481    fn test_rule_category_equality() {
482        assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
483        assert_ne!(RuleCategory::Heading, RuleCategory::List);
484
485        // Test all categories are distinct
486        let categories = [
487            RuleCategory::Heading,
488            RuleCategory::List,
489            RuleCategory::CodeBlock,
490            RuleCategory::Link,
491            RuleCategory::Image,
492            RuleCategory::Html,
493            RuleCategory::Emphasis,
494            RuleCategory::Whitespace,
495            RuleCategory::Blockquote,
496            RuleCategory::Table,
497            RuleCategory::FrontMatter,
498            RuleCategory::Other,
499        ];
500
501        for (i, cat1) in categories.iter().enumerate() {
502            for (j, cat2) in categories.iter().enumerate() {
503                if i == j {
504                    assert_eq!(cat1, cat2);
505                } else {
506                    assert_ne!(cat1, cat2);
507                }
508            }
509        }
510    }
511
512    #[test]
513    fn test_lint_error_conversions() {
514        use std::io;
515
516        // Test From<io::Error>
517        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
518        let lint_error: LintError = io_error.into();
519        match lint_error {
520            LintError::IoError(_) => {}
521            _ => panic!("Expected IoError variant"),
522        }
523
524        // Test Display trait
525        let invalid_input = LintError::InvalidInput("bad input".to_string());
526        assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
527
528        let fix_failed = LintError::FixFailed("couldn't fix".to_string());
529        assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
530
531        let parsing_error = LintError::ParsingError("parse error".to_string());
532        assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
533    }
534}