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}