mdbook_lint_core/
registry.rs

1use crate::{Document, config::Config, error::Result, rule::Rule, violation::Violation};
2
3/// Registry for managing linting rules
4pub struct RuleRegistry {
5    rules: Vec<Box<dyn Rule>>,
6}
7
8impl RuleRegistry {
9    /// Create a new empty registry
10    pub fn new() -> Self {
11        Self { rules: Vec::new() }
12    }
13
14    /// Register a rule with the registry
15    ///
16    /// Rules are stored in registration order and will be executed
17    /// in the same order during document checking.
18    pub fn register(&mut self, rule: Box<dyn Rule>) {
19        self.rules.push(rule);
20    }
21
22    /// Get all registered rules
23    pub fn rules(&self) -> &[Box<dyn Rule>] {
24        &self.rules
25    }
26
27    /// Get a rule by ID
28    ///
29    /// Returns the first rule with the matching ID, or None if no such rule exists.
30    pub fn get_rule(&self, id: &str) -> Option<&dyn Rule> {
31        self.rules.iter().find(|r| r.id() == id).map(|r| r.as_ref())
32    }
33
34    /// Get all rule IDs
35    ///
36    /// Returns a vector of all registered rule IDs in registration order.
37    pub fn rule_ids(&self) -> Vec<&'static str> {
38        self.rules.iter().map(|r| r.id()).collect()
39    }
40
41    /// Get rules that should be enabled based on configuration
42    ///
43    /// This method applies configuration filters to determine which rules
44    /// should actually run, considering:
45    /// - Explicitly enabled/disabled rules
46    /// - Rule deprecation status
47    /// - Category-based filtering
48    pub fn get_enabled_rules(&self, config: &Config) -> Vec<&dyn Rule> {
49        self.rules
50            .iter()
51            .filter(|rule| self.should_run_rule(rule.as_ref(), config))
52            .map(|rule| rule.as_ref())
53            .collect()
54    }
55
56    /// Get rules that should be enabled based on configuration and rule overrides for a specific document
57    ///
58    /// This method applies configuration filters and handles rule overrides:
59    /// - Basic configuration filtering (enabled/disabled rules, deprecation, categories)
60    /// - Rule override resolution (context-specific rules can override general rules)
61    pub fn get_enabled_rules_with_overrides(
62        &self,
63        document: &Document,
64        config: &Config,
65    ) -> Vec<&dyn Rule> {
66        let mut enabled_rules: Vec<&dyn Rule> = self
67            .rules
68            .iter()
69            .filter(|rule| self.should_run_rule(rule.as_ref(), config))
70            .map(|rule| rule.as_ref())
71            .collect();
72
73        // Handle rule overrides - remove overridden rules when override conditions are met
74        let mut rules_to_remove = Vec::new();
75
76        for rule in &enabled_rules {
77            let metadata = rule.metadata();
78            if let Some(overrides_rule_id) = metadata.overrides {
79                // Check if this override rule is applicable for this document
80                if self.is_override_applicable(rule.id(), document) {
81                    // This override rule is applicable, so mark the overridden rule for removal
82                    rules_to_remove.push(overrides_rule_id);
83                }
84            }
85        }
86
87        // Remove overridden rules
88        enabled_rules.retain(|rule| !rules_to_remove.contains(&rule.id()));
89
90        enabled_rules
91    }
92
93    /// Check if a rule override is applicable for a specific document
94    /// This is used for rules like MDBOOK025 that should override based on file name/context
95    /// rather than just violation presence
96    fn is_override_applicable(&self, rule_id: &str, document: &Document) -> bool {
97        match rule_id {
98            "MDBOOK025" => {
99                // MDBOOK025 overrides MD025 for SUMMARY.md files
100                document
101                    .path
102                    .file_name()
103                    .and_then(|name| name.to_str())
104                    .map(|name| name == "SUMMARY.md")
105                    .unwrap_or(false)
106            }
107            _ => false,
108        }
109    }
110
111    /// Check if a rule should run based on configuration and metadata
112    ///
113    /// This implements the rule filtering logic that considers:
114    /// 1. Explicitly disabled rules (always excluded)
115    /// 2. Explicitly enabled rules (always included, with deprecation warnings)
116    /// 3. Category-based filtering (enabled/disabled categories)
117    /// 4. Default behavior (exclude deprecated rules unless explicitly enabled)
118    pub fn should_run_rule(&self, rule: &dyn Rule, config: &Config) -> bool {
119        let rule_id = rule.id();
120        let metadata = rule.metadata();
121
122        // Check explicit disabled rules first
123        if config.disabled_rules.contains(&rule_id.to_string()) {
124            return false;
125        }
126
127        // Check explicit enabled rules
128        if config.enabled_rules.contains(&rule_id.to_string()) {
129            // Show deprecation warning if needed
130            if metadata.deprecated {
131                self.show_deprecation_warning(rule, config);
132            }
133            return true;
134        }
135
136        // If enabled_rules is specified, only run rules in that list
137        if !config.enabled_rules.is_empty() {
138            return false;
139        }
140
141        // Check markdownlint compatibility mode - disable rules that are disabled by default in markdownlint
142        if config.markdownlint_compatible && rule_id == "MD044" {
143            return false; // proper-names: disabled by default in markdownlint
144        }
145
146        // Check category-based filtering
147        let category_name = self.category_to_string(&metadata.category);
148
149        // If disabled categories specified, exclude rules in those categories
150        if config.disabled_categories.contains(&category_name) {
151            return false;
152        }
153
154        // If enabled categories specified, only include rules in those categories
155        if !config.enabled_categories.is_empty()
156            && !config.enabled_categories.contains(&category_name)
157        {
158            return false;
159        }
160
161        // For rules not explicitly configured, only enable non-deprecated rules by default
162        !metadata.deprecated
163    }
164
165    /// Convert RuleCategory to string for configuration matching
166    fn category_to_string(&self, category: &crate::rule::RuleCategory) -> String {
167        match category {
168            crate::rule::RuleCategory::Structure => "structure".to_string(),
169            crate::rule::RuleCategory::Formatting => "style".to_string(),
170            crate::rule::RuleCategory::Content => "code".to_string(),
171            crate::rule::RuleCategory::Links => "links".to_string(),
172            crate::rule::RuleCategory::Accessibility => "accessibility".to_string(),
173            crate::rule::RuleCategory::MdBook => "mdbook".to_string(),
174        }
175    }
176
177    /// Show deprecation warning based on configuration
178    ///
179    /// Displays deprecation warnings according to the configured warning level.
180    fn show_deprecation_warning(&self, rule: &dyn Rule, config: &Config) {
181        let metadata = rule.metadata();
182
183        if !metadata.deprecated {
184            return;
185        }
186
187        let message = if let Some(replacement) = metadata.replacement {
188            format!(
189                "Rule {} is deprecated - {}. Consider using {} instead.",
190                rule.id(),
191                metadata
192                    .deprecated_reason
193                    .unwrap_or("superseded by newer implementation"),
194                replacement
195            )
196        } else {
197            format!(
198                "Rule {} is deprecated - {}.",
199                rule.id(),
200                metadata
201                    .deprecated_reason
202                    .unwrap_or("no longer recommended")
203            )
204        };
205
206        match config.deprecated_warning {
207            crate::config::DeprecatedWarningLevel::Warn => {
208                eprintln!("Warning: {message}");
209            }
210            crate::config::DeprecatedWarningLevel::Info => {
211                eprintln!("Info: {message}");
212            }
213            crate::config::DeprecatedWarningLevel::Silent => {
214                // No output
215            }
216        }
217    }
218
219    /// Check a document with enabled rules using a single AST parse
220    pub fn check_document_optimized_with_config(
221        &self,
222        document: &Document,
223        config: &Config,
224    ) -> Result<Vec<Violation>> {
225        use comrak::Arena;
226
227        // Parse AST once
228        let arena = Arena::new();
229        let ast = document.parse_ast(&arena);
230
231        let mut all_violations = Vec::new();
232        let enabled_rules = self.get_enabled_rules_with_overrides(document, config);
233
234        // Run enabled rules with the pre-parsed AST
235        for rule in enabled_rules {
236            let violations = rule.check_with_ast(document, Some(ast))?;
237            all_violations.extend(violations);
238        }
239
240        // Apply deduplication to eliminate duplicate violations
241        let dedup_config = crate::deduplication::DeduplicationConfig::default();
242        let deduplicated_violations =
243            crate::deduplication::deduplicate_violations(all_violations, &dedup_config);
244
245        Ok(deduplicated_violations)
246    }
247
248    /// Check a document with enabled rules
249    pub fn check_document_with_config(
250        &self,
251        document: &Document,
252        config: &Config,
253    ) -> Result<Vec<Violation>> {
254        let mut all_violations = Vec::new();
255        let enabled_rules = self.get_enabled_rules_with_overrides(document, config);
256
257        for rule in enabled_rules {
258            let violations = rule.check(document)?;
259            all_violations.extend(violations);
260        }
261
262        // Apply deduplication to eliminate duplicate violations
263        let dedup_config = crate::deduplication::DeduplicationConfig::default();
264        let deduplicated_violations =
265            crate::deduplication::deduplicate_violations(all_violations, &dedup_config);
266
267        Ok(deduplicated_violations)
268    }
269
270    /// Check a document with all rules using a single AST parse
271    pub fn check_document_optimized(&self, document: &Document) -> Result<Vec<Violation>> {
272        // Use default config when no config is provided
273        let default_config = Config::default();
274        self.check_document_optimized_with_config(document, &default_config)
275    }
276
277    /// Check a document with all rules
278    pub fn check_document(&self, document: &Document) -> Result<Vec<Violation>> {
279        let mut all_violations = Vec::new();
280
281        for rule in &self.rules {
282            let violations = rule.check(document)?;
283            all_violations.extend(violations);
284        }
285
286        // Apply deduplication to eliminate duplicate violations
287        let dedup_config = crate::deduplication::DeduplicationConfig::default();
288        let deduplicated_violations =
289            crate::deduplication::deduplicate_violations(all_violations, &dedup_config);
290
291        Ok(deduplicated_violations)
292    }
293
294    /// Get the number of registered rules
295    pub fn len(&self) -> usize {
296        self.rules.len()
297    }
298
299    /// Check if the registry is empty
300    pub fn is_empty(&self) -> bool {
301        self.rules.is_empty()
302    }
303}
304
305impl Default for RuleRegistry {
306    /// Create a new empty registry
307    ///
308    /// Note: Unlike the original implementation, this does NOT register
309    /// any default rules. This is intentional for the core library to
310    /// remain rule-agnostic.
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::rule::{Rule, RuleCategory, RuleMetadata};
320    use std::path::PathBuf;
321
322    // Test rule for registry testing
323    struct TestRule {
324        id: &'static str,
325        name: &'static str,
326    }
327
328    impl TestRule {
329        fn new(id: &'static str, name: &'static str) -> Self {
330            Self { id, name }
331        }
332    }
333
334    impl Rule for TestRule {
335        fn id(&self) -> &'static str {
336            self.id
337        }
338
339        fn name(&self) -> &'static str {
340            self.name
341        }
342
343        fn description(&self) -> &'static str {
344            "A test rule for testing"
345        }
346
347        fn metadata(&self) -> RuleMetadata {
348            RuleMetadata::stable(RuleCategory::Structure)
349        }
350
351        fn check_with_ast<'a>(
352            &self,
353            _document: &Document,
354            _ast: Option<&'a comrak::nodes::AstNode<'a>>,
355        ) -> Result<Vec<Violation>> {
356            Ok(vec![self.create_violation(
357                format!("Test violation from {}", self.id),
358                1,
359                1,
360                crate::violation::Severity::Warning,
361            )])
362        }
363    }
364
365    #[test]
366    fn test_empty_registry() {
367        let registry = RuleRegistry::new();
368        assert_eq!(registry.len(), 0);
369        assert!(registry.is_empty());
370        assert_eq!(registry.rule_ids(), Vec::<&str>::new());
371    }
372
373    #[test]
374    fn test_rule_registration() {
375        let mut registry = RuleRegistry::new();
376        registry.register(Box::new(TestRule::new("TEST001", "test-rule-1")));
377        registry.register(Box::new(TestRule::new("TEST002", "test-rule-2")));
378
379        assert_eq!(registry.len(), 2);
380        assert!(!registry.is_empty());
381        assert_eq!(registry.rule_ids(), vec!["TEST001", "TEST002"]);
382    }
383
384    #[test]
385    fn test_get_rule() {
386        let mut registry = RuleRegistry::new();
387        registry.register(Box::new(TestRule::new("TEST001", "test-rule")));
388
389        let rule = registry.get_rule("TEST001").unwrap();
390        assert_eq!(rule.id(), "TEST001");
391        assert_eq!(rule.name(), "test-rule");
392
393        assert!(registry.get_rule("NONEXISTENT").is_none());
394    }
395
396    #[test]
397    fn test_rule_filtering_with_config() {
398        let mut registry = RuleRegistry::new();
399        registry.register(Box::new(TestRule::new("TEST001", "test-rule-1")));
400        registry.register(Box::new(TestRule::new("TEST002", "test-rule-2")));
401
402        // Default config should enable all non-deprecated rules
403        let config = Config::default();
404        let enabled = registry.get_enabled_rules(&config);
405        assert_eq!(enabled.len(), 2);
406
407        // Config with enabled rules should only run those rules
408        let config = Config {
409            enabled_rules: vec!["TEST001".to_string()],
410            ..Default::default()
411        };
412        let enabled = registry.get_enabled_rules(&config);
413        assert_eq!(enabled.len(), 1);
414        assert_eq!(enabled[0].id(), "TEST001");
415
416        // Config with disabled rules should exclude them
417        let config = Config {
418            disabled_rules: vec!["TEST002".to_string()],
419            ..Default::default()
420        };
421        let enabled = registry.get_enabled_rules(&config);
422        assert_eq!(enabled.len(), 1);
423        assert_eq!(enabled[0].id(), "TEST001");
424    }
425
426    #[test]
427    fn test_document_checking() {
428        let mut registry = RuleRegistry::new();
429        registry.register(Box::new(TestRule::new("TEST001", "test-rule")));
430
431        let document = Document::new("# Test".to_string(), PathBuf::from("test.md")).unwrap();
432
433        // Test optimized checking
434        let violations = registry.check_document_optimized(&document).unwrap();
435        assert_eq!(violations.len(), 1);
436        assert_eq!(violations[0].rule_id, "TEST001");
437
438        // Test traditional checking
439        let violations = registry.check_document(&document).unwrap();
440        assert_eq!(violations.len(), 1);
441        assert_eq!(violations[0].rule_id, "TEST001");
442
443        // Test config-based checking
444        let config = Config::default();
445        let violations = registry
446            .check_document_optimized_with_config(&document, &config)
447            .unwrap();
448        assert_eq!(violations.len(), 1);
449        assert_eq!(violations[0].rule_id, "TEST001");
450    }
451
452    #[test]
453    fn test_default_registry_is_empty() {
454        let registry = RuleRegistry::default();
455        assert!(registry.is_empty());
456        assert_eq!(registry.len(), 0);
457    }
458}