mdbook-lint-core 0.14.3

Core linting engine for mdbook-lint - library for markdown linting with mdBook support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
use crate::{Document, error::Result, violation::Violation};
use comrak::{Arena, nodes::AstNode};

/// Rule stability levels
#[derive(Debug, Clone, PartialEq)]
pub enum RuleStability {
    /// Rule is stable and recommended for production use
    Stable,
    /// Rule is experimental and may change
    Experimental,
    /// Rule is deprecated and may be removed in future versions
    Deprecated,
    /// Rule number reserved but never implemented
    Reserved,
}

/// Rule categories for grouping and filtering
#[derive(Debug, Clone, PartialEq)]
pub enum RuleCategory {
    /// Document structure and heading organization
    Structure,
    /// Whitespace, line length, and formatting consistency
    Formatting,
    /// Links, images, and content validation
    Content,
    /// Link-specific validation
    Links,
    /// Accessibility and usability rules
    Accessibility,
    /// mdBook-specific functionality and conventions
    MdBook,
}

/// Metadata about a rule's status, category, and properties
#[derive(Debug, Clone)]
pub struct RuleMetadata {
    /// Whether the rule is deprecated
    pub deprecated: bool,
    /// Reason for deprecation (if applicable)
    pub deprecated_reason: Option<&'static str>,
    /// Suggested replacement rule (if applicable)
    pub replacement: Option<&'static str>,
    /// Rule category for grouping
    pub category: RuleCategory,
    /// Version when rule was introduced
    pub introduced_in: Option<&'static str>,
    /// Stability level of the rule
    pub stability: RuleStability,
    /// Rules that this rule overrides (for context-specific rules)
    pub overrides: Option<&'static str>,
}

impl RuleMetadata {
    /// Create metadata for a stable, active rule
    pub fn stable(category: RuleCategory) -> Self {
        Self {
            deprecated: false,
            deprecated_reason: None,
            replacement: None,
            category,
            introduced_in: None,
            stability: RuleStability::Stable,
            overrides: None,
        }
    }

    /// Create metadata for a deprecated rule
    pub fn deprecated(
        category: RuleCategory,
        reason: &'static str,
        replacement: Option<&'static str>,
    ) -> Self {
        Self {
            deprecated: true,
            deprecated_reason: Some(reason),
            replacement,
            category,
            introduced_in: None,
            stability: RuleStability::Deprecated,
            overrides: None,
        }
    }

    /// Create metadata for an experimental rule
    pub fn experimental(category: RuleCategory) -> Self {
        Self {
            deprecated: false,
            deprecated_reason: None,
            replacement: None,
            category,
            introduced_in: None,
            stability: RuleStability::Experimental,
            overrides: None,
        }
    }

    /// Create metadata for a reserved rule number (never implemented)
    pub fn reserved(reason: &'static str) -> Self {
        Self {
            deprecated: false,
            deprecated_reason: Some(reason),
            replacement: None,
            category: RuleCategory::Structure,
            introduced_in: None,
            stability: RuleStability::Reserved,
            overrides: None,
        }
    }

    /// Set the version when this rule was introduced
    pub fn introduced_in(mut self, version: &'static str) -> Self {
        self.introduced_in = Some(version);
        self
    }

    /// Set which rule this rule overrides
    pub fn overrides(mut self, rule_id: &'static str) -> Self {
        self.overrides = Some(rule_id);
        self
    }
}

/// Trait that all linting rules must implement
pub trait Rule: Send + Sync {
    /// Unique identifier for the rule (e.g., "MD001")
    fn id(&self) -> &'static str;

    /// Human-readable name for the rule (e.g., "heading-increment")
    fn name(&self) -> &'static str;

    /// Description of what the rule checks
    fn description(&self) -> &'static str;

    /// Metadata about this rule's status and properties
    fn metadata(&self) -> RuleMetadata;

    /// Check a document for violations of this rule with optional pre-parsed AST
    fn check_with_ast<'a>(
        &self,
        document: &Document,
        ast: Option<&'a AstNode<'a>>,
    ) -> Result<Vec<Violation>>;

    /// Check a document for violations of this rule (backward compatibility)
    fn check(&self, document: &Document) -> Result<Vec<Violation>> {
        self.check_with_ast(document, None)
    }

    /// Whether this rule can automatically fix violations
    fn can_fix(&self) -> bool {
        false
    }

    /// Attempt to fix a violation (if supported)
    fn fix(&self, _content: &str, _violation: &Violation) -> Option<String> {
        None
    }

    /// Create a violation for this rule
    fn create_violation(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message,
            line,
            column,
            severity,
            fix: None,
        }
    }

    /// Create a violation with a fix for this rule
    fn create_violation_with_fix(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
        fix: crate::violation::Fix,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message,
            line,
            column,
            severity,
            fix: Some(fix),
        }
    }
}

/// Helper trait for AST-based rules
///
/// # When to Use AstRule vs Rule
///
/// **Use `AstRule` when your rule needs to:**
/// - Analyze document structure (headings, lists, links, code blocks)
/// - Navigate parent-child relationships in the markdown tree
/// - Access precise position information from comrak's sourcepos
/// - Understand markdown semantics beyond simple text patterns
///
/// **Use `Rule` directly when your rule:**
/// - Only needs line-by-line text analysis
/// - Checks simple text patterns (trailing spaces, line length)
/// - Doesn't need to understand markdown structure
///
/// # Implementation Examples
///
/// **AstRule Examples:**
/// - `MD001` (heading-increment): Needs to traverse heading hierarchy
/// - `MDBOOK002` (link-validation): Needs to find and validate link nodes
/// - `MD031` (blanks-around-fences): Needs to identify fenced code blocks
///
/// **Rule Examples:**
/// - `MD013` (line-length): Simple line-by-line character counting
/// - `MD009` (no-trailing-spaces): Pattern matching on line endings
///
/// # Basic Implementation Pattern
///
/// ```rust
/// use mdbook_lint_core::rule::{AstRule, RuleMetadata, RuleCategory};
/// use mdbook_lint_core::{Document, Violation, Result};
/// use comrak::nodes::{AstNode, NodeValue};
///
/// pub struct MyRule;
///
/// impl AstRule for MyRule {
///     fn id(&self) -> &'static str { "MY001" }
///     fn name(&self) -> &'static str { "my-rule" }
///     fn description(&self) -> &'static str { "Description of what this rule checks" }
///
///     fn metadata(&self) -> RuleMetadata {
///         RuleMetadata::stable(RuleCategory::Structure)
///     }
///
///     fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
///         let mut violations = Vec::new();
///
///         // Find nodes of interest
///         for node in ast.descendants() {
///             if let NodeValue::Heading(heading) = &node.data.borrow().value {
///                 // Get position information
///                 if let Some((line, column)) = document.node_position(node) {
///                     // Check some condition
///                     if heading.level > 3 {
///                         violations.push(self.create_violation(
///                             "Heading too deep".to_string(),
///                             line,
///                             column,
///                             mdbook_lint_core::violation::Severity::Warning,
///                         ));
///                     }
///                 }
///             }
///         }
///
///         Ok(violations)
///     }
/// }
/// ```
///
/// # Key Methods Available
///
/// **From Document:**
/// - `document.node_position(node)` - Get (line, column) for any AST node
/// - `document.node_text(node)` - Extract text content from a node
/// - `document.headings(ast)` - Get all heading nodes
/// - `document.code_blocks(ast)` - Get all code block nodes
///
/// **From AstNode:**
/// - `node.descendants()` - Iterate all child nodes recursively
/// - `node.children()` - Get direct children only
/// - `node.parent()` - Get parent node (if any)
/// - `node.data.borrow().value` - Access the NodeValue enum
///
/// **Creating Violations:**
/// - `self.create_violation(message, line, column, severity)` - Standard violation creation
/// - `self.create_violation_with_fix(message, line, column, severity, fix)` - Violation with fix
pub trait AstRule: Send + Sync {
    /// Unique identifier for the rule (e.g., "MD001")
    fn id(&self) -> &'static str;

    /// Human-readable name for the rule (e.g., "heading-increment")
    fn name(&self) -> &'static str;

    /// Description of what the rule checks
    fn description(&self) -> &'static str;

    /// Metadata about this rule's status and properties
    fn metadata(&self) -> RuleMetadata;

    /// Check a document using its AST
    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>>;

    /// Whether this rule can automatically fix violations
    fn can_fix(&self) -> bool {
        false
    }

    /// Attempt to fix a violation (if supported)
    fn fix(&self, _content: &str, _violation: &Violation) -> Option<String> {
        None
    }

    /// Create a violation for this rule
    fn create_violation(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message,
            line,
            column,
            severity,
            fix: None,
        }
    }

    /// Create a violation with a fix for this rule
    fn create_violation_with_fix(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
        fix: crate::violation::Fix,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message,
            line,
            column,
            severity,
            fix: Some(fix),
        }
    }
}

/// Trait for rules that analyze multiple documents together
///
/// Collection rules are useful for cross-document validation such as:
/// - Checking for duplicate identifiers across files
/// - Validating inter-document links
/// - Ensuring sequential numbering across a set of documents
/// - Detecting inconsistencies between related documents
///
/// Unlike regular `Rule` implementations which process documents one at a time,
/// `CollectionRule` implementations receive all documents at once, allowing them
/// to perform comparisons and validations across the entire collection.
///
/// # Implementation Example
///
/// ```rust
/// use mdbook_lint_core::rule::{CollectionRule, RuleMetadata, RuleCategory};
/// use mdbook_lint_core::{Document, Violation, Result};
///
/// pub struct NoDuplicateTitles;
///
/// impl CollectionRule for NoDuplicateTitles {
///     fn id(&self) -> &'static str { "COLL001" }
///     fn name(&self) -> &'static str { "no-duplicate-titles" }
///     fn description(&self) -> &'static str { "No two documents should have the same title" }
///
///     fn metadata(&self) -> RuleMetadata {
///         RuleMetadata::stable(RuleCategory::Structure)
///     }
///
///     fn check_collection(&self, documents: &[Document]) -> Result<Vec<Violation>> {
///         // Implementation would collect titles and check for duplicates
///         Ok(Vec::new())
///     }
/// }
/// ```
pub trait CollectionRule: Send + Sync {
    /// Unique identifier for the rule (e.g., "ADR010")
    fn id(&self) -> &'static str;

    /// Human-readable name for the rule (e.g., "adr-sequential-numbering")
    fn name(&self) -> &'static str;

    /// Description of what the rule checks
    fn description(&self) -> &'static str;

    /// Metadata about this rule's status and properties
    fn metadata(&self) -> RuleMetadata;

    /// Check a collection of documents for violations
    ///
    /// This method receives all documents that should be analyzed together.
    /// Implementations should filter the documents as needed (e.g., only ADR files)
    /// and return violations that reference specific documents by path.
    fn check_collection(&self, documents: &[Document]) -> Result<Vec<Violation>>;

    /// Create a violation for this rule
    fn create_violation(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message,
            line,
            column,
            severity,
            fix: None,
        }
    }

    /// Create a violation with a file path prefix in the message
    fn create_violation_for_file(
        &self,
        path: &std::path::Path,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
    ) -> Violation {
        Violation {
            rule_id: self.id().to_string(),
            rule_name: self.name().to_string(),
            message: format!("{}: {}", path.display(), message),
            line,
            column,
            severity,
            fix: None,
        }
    }
}

// Blanket implementation so AstRule types automatically implement Rule
impl<T: AstRule> Rule for T {
    fn id(&self) -> &'static str {
        T::id(self)
    }

    fn name(&self) -> &'static str {
        T::name(self)
    }

    fn description(&self) -> &'static str {
        T::description(self)
    }

    fn metadata(&self) -> RuleMetadata {
        T::metadata(self)
    }

    fn check_with_ast<'a>(
        &self,
        document: &Document,
        ast: Option<&'a AstNode<'a>>,
    ) -> Result<Vec<Violation>> {
        if let Some(ast) = ast {
            self.check_ast(document, ast)
        } else {
            let arena = Arena::new();
            let ast = document.parse_ast(&arena);
            self.check_ast(document, ast)
        }
    }

    fn check(&self, document: &Document) -> Result<Vec<Violation>> {
        self.check_with_ast(document, None)
    }

    fn can_fix(&self) -> bool {
        T::can_fix(self)
    }

    fn fix(&self, content: &str, violation: &Violation) -> Option<String> {
        T::fix(self, content, violation)
    }

    fn create_violation(
        &self,
        message: String,
        line: usize,
        column: usize,
        severity: crate::violation::Severity,
    ) -> Violation {
        T::create_violation(self, message, line, column, severity)
    }
}