Skip to main content

mdbook_lint_core/
rule.rs

1use crate::{Document, error::Result, violation::Violation};
2use comrak::{Arena, nodes::AstNode};
3
4/// Rule stability levels
5#[derive(Debug, Clone, PartialEq)]
6pub enum RuleStability {
7    /// Rule is stable and recommended for production use
8    Stable,
9    /// Rule is experimental and may change
10    Experimental,
11    /// Rule is deprecated and may be removed in future versions
12    Deprecated,
13    /// Rule number reserved but never implemented
14    Reserved,
15}
16
17/// Rule categories for grouping and filtering
18#[derive(Debug, Clone, PartialEq)]
19pub enum RuleCategory {
20    /// Document structure and heading organization
21    Structure,
22    /// Whitespace, line length, and formatting consistency
23    Formatting,
24    /// Links, images, and content validation
25    Content,
26    /// Link-specific validation
27    Links,
28    /// Accessibility and usability rules
29    Accessibility,
30    /// mdBook-specific functionality and conventions
31    MdBook,
32}
33
34/// Metadata about a rule's status, category, and properties
35#[derive(Debug, Clone)]
36pub struct RuleMetadata {
37    /// Whether the rule is deprecated
38    pub deprecated: bool,
39    /// Reason for deprecation (if applicable)
40    pub deprecated_reason: Option<&'static str>,
41    /// Suggested replacement rule (if applicable)
42    pub replacement: Option<&'static str>,
43    /// Rule category for grouping
44    pub category: RuleCategory,
45    /// Version when rule was introduced
46    pub introduced_in: Option<&'static str>,
47    /// Stability level of the rule
48    pub stability: RuleStability,
49    /// Rules that this rule overrides (for context-specific rules)
50    pub overrides: Option<&'static str>,
51}
52
53impl RuleMetadata {
54    /// Create metadata for a stable, active rule
55    pub fn stable(category: RuleCategory) -> Self {
56        Self {
57            deprecated: false,
58            deprecated_reason: None,
59            replacement: None,
60            category,
61            introduced_in: None,
62            stability: RuleStability::Stable,
63            overrides: None,
64        }
65    }
66
67    /// Create metadata for a deprecated rule
68    pub fn deprecated(
69        category: RuleCategory,
70        reason: &'static str,
71        replacement: Option<&'static str>,
72    ) -> Self {
73        Self {
74            deprecated: true,
75            deprecated_reason: Some(reason),
76            replacement,
77            category,
78            introduced_in: None,
79            stability: RuleStability::Deprecated,
80            overrides: None,
81        }
82    }
83
84    /// Create metadata for an experimental rule
85    pub fn experimental(category: RuleCategory) -> Self {
86        Self {
87            deprecated: false,
88            deprecated_reason: None,
89            replacement: None,
90            category,
91            introduced_in: None,
92            stability: RuleStability::Experimental,
93            overrides: None,
94        }
95    }
96
97    /// Create metadata for a reserved rule number (never implemented)
98    pub fn reserved(reason: &'static str) -> Self {
99        Self {
100            deprecated: false,
101            deprecated_reason: Some(reason),
102            replacement: None,
103            category: RuleCategory::Structure,
104            introduced_in: None,
105            stability: RuleStability::Reserved,
106            overrides: None,
107        }
108    }
109
110    /// Set the version when this rule was introduced
111    pub fn introduced_in(mut self, version: &'static str) -> Self {
112        self.introduced_in = Some(version);
113        self
114    }
115
116    /// Set which rule this rule overrides
117    pub fn overrides(mut self, rule_id: &'static str) -> Self {
118        self.overrides = Some(rule_id);
119        self
120    }
121}
122
123/// Trait that all linting rules must implement
124pub trait Rule: Send + Sync {
125    /// Unique identifier for the rule (e.g., "MD001")
126    fn id(&self) -> &'static str;
127
128    /// Human-readable name for the rule (e.g., "heading-increment")
129    fn name(&self) -> &'static str;
130
131    /// Description of what the rule checks
132    fn description(&self) -> &'static str;
133
134    /// Metadata about this rule's status and properties
135    fn metadata(&self) -> RuleMetadata;
136
137    /// Check a document for violations of this rule with optional pre-parsed AST
138    fn check_with_ast<'a>(
139        &self,
140        document: &Document,
141        ast: Option<&'a AstNode<'a>>,
142    ) -> Result<Vec<Violation>>;
143
144    /// Check a document for violations of this rule (backward compatibility)
145    fn check(&self, document: &Document) -> Result<Vec<Violation>> {
146        self.check_with_ast(document, None)
147    }
148
149    /// Whether this rule can automatically fix violations
150    fn can_fix(&self) -> bool {
151        false
152    }
153
154    /// Attempt to fix a violation (if supported)
155    fn fix(&self, _content: &str, _violation: &Violation) -> Option<String> {
156        None
157    }
158
159    /// Create a violation for this rule
160    fn create_violation(
161        &self,
162        message: String,
163        line: usize,
164        column: usize,
165        severity: crate::violation::Severity,
166    ) -> Violation {
167        Violation {
168            rule_id: self.id().to_string(),
169            rule_name: self.name().to_string(),
170            message,
171            line,
172            column,
173            severity,
174            fix: None,
175        }
176    }
177
178    /// Create a violation with a fix for this rule
179    fn create_violation_with_fix(
180        &self,
181        message: String,
182        line: usize,
183        column: usize,
184        severity: crate::violation::Severity,
185        fix: crate::violation::Fix,
186    ) -> Violation {
187        Violation {
188            rule_id: self.id().to_string(),
189            rule_name: self.name().to_string(),
190            message,
191            line,
192            column,
193            severity,
194            fix: Some(fix),
195        }
196    }
197}
198
199/// Helper trait for AST-based rules
200///
201/// # When to Use AstRule vs Rule
202///
203/// **Use `AstRule` when your rule needs to:**
204/// - Analyze document structure (headings, lists, links, code blocks)
205/// - Navigate parent-child relationships in the markdown tree
206/// - Access precise position information from comrak's sourcepos
207/// - Understand markdown semantics beyond simple text patterns
208///
209/// **Use `Rule` directly when your rule:**
210/// - Only needs line-by-line text analysis
211/// - Checks simple text patterns (trailing spaces, line length)
212/// - Doesn't need to understand markdown structure
213///
214/// # Implementation Examples
215///
216/// **AstRule Examples:**
217/// - `MD001` (heading-increment): Needs to traverse heading hierarchy
218/// - `MDBOOK002` (link-validation): Needs to find and validate link nodes
219/// - `MD031` (blanks-around-fences): Needs to identify fenced code blocks
220///
221/// **Rule Examples:**
222/// - `MD013` (line-length): Simple line-by-line character counting
223/// - `MD009` (no-trailing-spaces): Pattern matching on line endings
224///
225/// # Basic Implementation Pattern
226///
227/// ```rust
228/// use mdbook_lint_core::rule::{AstRule, RuleMetadata, RuleCategory};
229/// use mdbook_lint_core::{Document, Violation, Result};
230/// use comrak::nodes::{AstNode, NodeValue};
231///
232/// pub struct MyRule;
233///
234/// impl AstRule for MyRule {
235///     fn id(&self) -> &'static str { "MY001" }
236///     fn name(&self) -> &'static str { "my-rule" }
237///     fn description(&self) -> &'static str { "Description of what this rule checks" }
238///
239///     fn metadata(&self) -> RuleMetadata {
240///         RuleMetadata::stable(RuleCategory::Structure)
241///     }
242///
243///     fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
244///         let mut violations = Vec::new();
245///
246///         // Find nodes of interest
247///         for node in ast.descendants() {
248///             if let NodeValue::Heading(heading) = &node.data.borrow().value {
249///                 // Get position information
250///                 if let Some((line, column)) = document.node_position(node) {
251///                     // Check some condition
252///                     if heading.level > 3 {
253///                         violations.push(self.create_violation(
254///                             "Heading too deep".to_string(),
255///                             line,
256///                             column,
257///                             mdbook_lint_core::violation::Severity::Warning,
258///                         ));
259///                     }
260///                 }
261///             }
262///         }
263///
264///         Ok(violations)
265///     }
266/// }
267/// ```
268///
269/// # Key Methods Available
270///
271/// **From Document:**
272/// - `document.node_position(node)` - Get (line, column) for any AST node
273/// - `document.node_text(node)` - Extract text content from a node
274/// - `document.headings(ast)` - Get all heading nodes
275/// - `document.code_blocks(ast)` - Get all code block nodes
276///
277/// **From AstNode:**
278/// - `node.descendants()` - Iterate all child nodes recursively
279/// - `node.children()` - Get direct children only
280/// - `node.parent()` - Get parent node (if any)
281/// - `node.data.borrow().value` - Access the NodeValue enum
282///
283/// **Creating Violations:**
284/// - `self.create_violation(message, line, column, severity)` - Standard violation creation
285/// - `self.create_violation_with_fix(message, line, column, severity, fix)` - Violation with fix
286pub trait AstRule: Send + Sync {
287    /// Unique identifier for the rule (e.g., "MD001")
288    fn id(&self) -> &'static str;
289
290    /// Human-readable name for the rule (e.g., "heading-increment")
291    fn name(&self) -> &'static str;
292
293    /// Description of what the rule checks
294    fn description(&self) -> &'static str;
295
296    /// Metadata about this rule's status and properties
297    fn metadata(&self) -> RuleMetadata;
298
299    /// Check a document using its AST
300    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>>;
301
302    /// Whether this rule can automatically fix violations
303    fn can_fix(&self) -> bool {
304        false
305    }
306
307    /// Attempt to fix a violation (if supported)
308    fn fix(&self, _content: &str, _violation: &Violation) -> Option<String> {
309        None
310    }
311
312    /// Create a violation for this rule
313    fn create_violation(
314        &self,
315        message: String,
316        line: usize,
317        column: usize,
318        severity: crate::violation::Severity,
319    ) -> Violation {
320        Violation {
321            rule_id: self.id().to_string(),
322            rule_name: self.name().to_string(),
323            message,
324            line,
325            column,
326            severity,
327            fix: None,
328        }
329    }
330
331    /// Create a violation with a fix for this rule
332    fn create_violation_with_fix(
333        &self,
334        message: String,
335        line: usize,
336        column: usize,
337        severity: crate::violation::Severity,
338        fix: crate::violation::Fix,
339    ) -> Violation {
340        Violation {
341            rule_id: self.id().to_string(),
342            rule_name: self.name().to_string(),
343            message,
344            line,
345            column,
346            severity,
347            fix: Some(fix),
348        }
349    }
350}
351
352/// Trait for rules that analyze multiple documents together
353///
354/// Collection rules are useful for cross-document validation such as:
355/// - Checking for duplicate identifiers across files
356/// - Validating inter-document links
357/// - Ensuring sequential numbering across a set of documents
358/// - Detecting inconsistencies between related documents
359///
360/// Unlike regular `Rule` implementations which process documents one at a time,
361/// `CollectionRule` implementations receive all documents at once, allowing them
362/// to perform comparisons and validations across the entire collection.
363///
364/// # Implementation Example
365///
366/// ```rust
367/// use mdbook_lint_core::rule::{CollectionRule, RuleMetadata, RuleCategory};
368/// use mdbook_lint_core::{Document, Violation, Result};
369///
370/// pub struct NoDuplicateTitles;
371///
372/// impl CollectionRule for NoDuplicateTitles {
373///     fn id(&self) -> &'static str { "COLL001" }
374///     fn name(&self) -> &'static str { "no-duplicate-titles" }
375///     fn description(&self) -> &'static str { "No two documents should have the same title" }
376///
377///     fn metadata(&self) -> RuleMetadata {
378///         RuleMetadata::stable(RuleCategory::Structure)
379///     }
380///
381///     fn check_collection(&self, documents: &[Document]) -> Result<Vec<Violation>> {
382///         // Implementation would collect titles and check for duplicates
383///         Ok(Vec::new())
384///     }
385/// }
386/// ```
387pub trait CollectionRule: Send + Sync {
388    /// Unique identifier for the rule (e.g., "ADR010")
389    fn id(&self) -> &'static str;
390
391    /// Human-readable name for the rule (e.g., "adr-sequential-numbering")
392    fn name(&self) -> &'static str;
393
394    /// Description of what the rule checks
395    fn description(&self) -> &'static str;
396
397    /// Metadata about this rule's status and properties
398    fn metadata(&self) -> RuleMetadata;
399
400    /// Check a collection of documents for violations
401    ///
402    /// This method receives all documents that should be analyzed together.
403    /// Implementations should filter the documents as needed (e.g., only ADR files)
404    /// and return violations that reference specific documents by path.
405    fn check_collection(&self, documents: &[Document]) -> Result<Vec<Violation>>;
406
407    /// Create a violation for this rule
408    fn create_violation(
409        &self,
410        message: String,
411        line: usize,
412        column: usize,
413        severity: crate::violation::Severity,
414    ) -> Violation {
415        Violation {
416            rule_id: self.id().to_string(),
417            rule_name: self.name().to_string(),
418            message,
419            line,
420            column,
421            severity,
422            fix: None,
423        }
424    }
425
426    /// Create a violation with a file path prefix in the message
427    fn create_violation_for_file(
428        &self,
429        path: &std::path::Path,
430        message: String,
431        line: usize,
432        column: usize,
433        severity: crate::violation::Severity,
434    ) -> Violation {
435        Violation {
436            rule_id: self.id().to_string(),
437            rule_name: self.name().to_string(),
438            message: format!("{}: {}", path.display(), message),
439            line,
440            column,
441            severity,
442            fix: None,
443        }
444    }
445}
446
447// Blanket implementation so AstRule types automatically implement Rule
448impl<T: AstRule> Rule for T {
449    fn id(&self) -> &'static str {
450        T::id(self)
451    }
452
453    fn name(&self) -> &'static str {
454        T::name(self)
455    }
456
457    fn description(&self) -> &'static str {
458        T::description(self)
459    }
460
461    fn metadata(&self) -> RuleMetadata {
462        T::metadata(self)
463    }
464
465    fn check_with_ast<'a>(
466        &self,
467        document: &Document,
468        ast: Option<&'a AstNode<'a>>,
469    ) -> Result<Vec<Violation>> {
470        if let Some(ast) = ast {
471            self.check_ast(document, ast)
472        } else {
473            let arena = Arena::new();
474            let ast = document.parse_ast(&arena);
475            self.check_ast(document, ast)
476        }
477    }
478
479    fn check(&self, document: &Document) -> Result<Vec<Violation>> {
480        self.check_with_ast(document, None)
481    }
482
483    fn can_fix(&self) -> bool {
484        T::can_fix(self)
485    }
486
487    fn fix(&self, content: &str, violation: &Violation) -> Option<String> {
488        T::fix(self, content, violation)
489    }
490
491    fn create_violation(
492        &self,
493        message: String,
494        line: usize,
495        column: usize,
496        severity: crate::violation::Severity,
497    ) -> Violation {
498        T::create_violation(self, message, line, column, severity)
499    }
500}