Skip to main content

rumdl_lib/config/
registry.rs

1use std::sync::LazyLock;
2
3use crate::rule::Rule;
4
5use super::flavor::normalize_key;
6
7/// Lazily-initialized default `RuleRegistry` built from rules with default config.
8///
9/// Rule config schemas (valid keys, types, aliases) are intrinsic to each rule type
10/// and do not change based on runtime configuration. This static registry avoids
11/// repeatedly constructing 67+ rule instances just to extract their schemas.
12static DEFAULT_REGISTRY: LazyLock<RuleRegistry> = LazyLock::new(|| {
13    let default_config = super::types::Config::default();
14    let rules = crate::rules::all_rules(&default_config);
15    RuleRegistry::from_rules(&rules)
16});
17
18/// Returns a reference to the lazily-initialized default `RuleRegistry`.
19///
20/// Use this instead of `all_rules(&Config::default())` + `RuleRegistry::from_rules()`
21/// when you only need rule metadata (names, config schemas, aliases) rather than
22/// configured rule instances for linting.
23pub fn default_registry() -> &'static RuleRegistry {
24    &DEFAULT_REGISTRY
25}
26
27/// Registry of all known rules and their config schemas
28pub struct RuleRegistry {
29    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
30    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
31    /// Map of rule name to config key aliases
32    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
33}
34
35impl RuleRegistry {
36    /// Build a registry from a list of rules
37    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
38        let mut rule_schemas = std::collections::BTreeMap::new();
39        let mut rule_aliases = std::collections::BTreeMap::new();
40
41        for rule in rules {
42            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
43                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
44                rule_schemas.insert(norm_name.clone(), table);
45                norm_name
46            } else {
47                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
48                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
49                norm_name
50            };
51
52            // Store aliases if the rule provides them
53            if let Some(aliases) = rule.config_aliases() {
54                rule_aliases.insert(norm_name, aliases);
55            }
56        }
57
58        RuleRegistry {
59            rule_schemas,
60            rule_aliases,
61        }
62    }
63
64    /// Get all known rule names
65    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
66        self.rule_schemas.keys().cloned().collect()
67    }
68
69    /// Get the valid configuration keys for a rule, including both original and normalized variants
70    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
71        self.rule_schemas.get(rule).map(|schema| {
72            let mut all_keys = std::collections::BTreeSet::new();
73
74            // Always allow 'severity' and 'enabled' for any rule
75            all_keys.insert("severity".to_string());
76            all_keys.insert("enabled".to_string());
77
78            // Add original keys from schema
79            for key in schema.keys() {
80                all_keys.insert(key.clone());
81            }
82
83            // Add normalized variants for markdownlint compatibility
84            for key in schema.keys() {
85                // Add kebab-case variant
86                all_keys.insert(key.replace('_', "-"));
87                // Add snake_case variant
88                all_keys.insert(key.replace('-', "_"));
89                // Add normalized variant
90                all_keys.insert(normalize_key(key));
91            }
92
93            // Add any aliases defined by the rule
94            if let Some(aliases) = self.rule_aliases.get(rule) {
95                for alias_key in aliases.keys() {
96                    all_keys.insert(alias_key.clone());
97                    // Also add normalized variants of the alias
98                    all_keys.insert(alias_key.replace('_', "-"));
99                    all_keys.insert(alias_key.replace('-', "_"));
100                    all_keys.insert(normalize_key(alias_key));
101                }
102            }
103
104            all_keys
105        })
106    }
107
108    /// Get the expected value type for a rule's configuration key, trying variants.
109    /// Returns `None` for nullable sentinel values (Option fields with default None),
110    /// which signals the caller to skip type checking for that key.
111    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
112        let schema = self.rule_schemas.get(rule)?;
113
114        // Check if this key is an alias
115        if let Some(aliases) = self.rule_aliases.get(rule)
116            && let Some(canonical_key) = aliases.get(key)
117            && let Some(value) = schema.get(canonical_key)
118        {
119            return filter_nullable_sentinel(value);
120        }
121
122        // Try the original key
123        if let Some(value) = schema.get(key) {
124            return filter_nullable_sentinel(value);
125        }
126
127        // Try key variants
128        let key_variants = [
129            key.replace('-', "_"), // Convert kebab-case to snake_case
130            key.replace('_', "-"), // Convert snake_case to kebab-case
131            normalize_key(key),    // Normalized key (lowercase, kebab-case)
132        ];
133
134        for variant in &key_variants {
135            if let Some(value) = schema.get(variant) {
136                return filter_nullable_sentinel(value);
137            }
138        }
139
140        None
141    }
142
143    /// Resolve any rule name (canonical or alias) to its canonical form
144    /// Returns None if the rule name is not recognized
145    ///
146    /// Resolution order:
147    /// 1. Direct canonical name match
148    /// 2. Static aliases (built-in markdownlint aliases)
149    pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
150        // Try normalized canonical name first
151        let normalized = normalize_key(name);
152        if self.rule_schemas.contains_key(&normalized) {
153            return Some(normalized);
154        }
155
156        // Try static alias resolution (O(1) perfect hash lookup)
157        resolve_rule_name_alias(name).map(std::string::ToString::to_string)
158    }
159}
160
161/// Returns `None` if the value is a nullable sentinel, otherwise returns `Some(value)`.
162/// Used by `expected_value_for` to skip type checking for Option fields with default None.
163fn filter_nullable_sentinel(value: &toml::Value) -> Option<&toml::Value> {
164    if crate::rule_config_serde::is_nullable_sentinel(value) {
165        None
166    } else {
167        Some(value)
168    }
169}
170
171/// Compile-time perfect hash map for O(1) rule alias lookups
172/// Uses phf for zero-cost abstraction - compiles to direct jumps
173pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
174    // Canonical names (identity mapping for consistency)
175    "MD001" => "MD001",
176    "MD003" => "MD003",
177    "MD004" => "MD004",
178    "MD005" => "MD005",
179    "MD007" => "MD007",
180    "MD009" => "MD009",
181    "MD010" => "MD010",
182    "MD011" => "MD011",
183    "MD012" => "MD012",
184    "MD013" => "MD013",
185    "MD014" => "MD014",
186    "MD018" => "MD018",
187    "MD019" => "MD019",
188    "MD020" => "MD020",
189    "MD021" => "MD021",
190    "MD022" => "MD022",
191    "MD023" => "MD023",
192    "MD024" => "MD024",
193    "MD025" => "MD025",
194    "MD026" => "MD026",
195    "MD027" => "MD027",
196    "MD028" => "MD028",
197    "MD029" => "MD029",
198    "MD030" => "MD030",
199    "MD031" => "MD031",
200    "MD032" => "MD032",
201    "MD033" => "MD033",
202    "MD034" => "MD034",
203    "MD035" => "MD035",
204    "MD036" => "MD036",
205    "MD037" => "MD037",
206    "MD038" => "MD038",
207    "MD039" => "MD039",
208    "MD040" => "MD040",
209    "MD041" => "MD041",
210    "MD042" => "MD042",
211    "MD043" => "MD043",
212    "MD044" => "MD044",
213    "MD045" => "MD045",
214    "MD046" => "MD046",
215    "MD047" => "MD047",
216    "MD048" => "MD048",
217    "MD049" => "MD049",
218    "MD050" => "MD050",
219    "MD051" => "MD051",
220    "MD052" => "MD052",
221    "MD053" => "MD053",
222    "MD054" => "MD054",
223    "MD055" => "MD055",
224    "MD056" => "MD056",
225    "MD057" => "MD057",
226    "MD058" => "MD058",
227    "MD059" => "MD059",
228    "MD060" => "MD060",
229    "MD061" => "MD061",
230    "MD062" => "MD062",
231    "MD063" => "MD063",
232    "MD064" => "MD064",
233    "MD065" => "MD065",
234    "MD066" => "MD066",
235    "MD067" => "MD067",
236    "MD068" => "MD068",
237    "MD069" => "MD069",
238    "MD070" => "MD070",
239    "MD071" => "MD071",
240    "MD072" => "MD072",
241    "MD073" => "MD073",
242    "MD074" => "MD074",
243    "MD075" => "MD075",
244    "MD076" => "MD076",
245    "MD077" => "MD077",
246
247    // Aliases (hyphen format)
248    "HEADING-INCREMENT" => "MD001",
249    "HEADING-STYLE" => "MD003",
250    "UL-STYLE" => "MD004",
251    "LIST-INDENT" => "MD005",
252    "UL-INDENT" => "MD007",
253    "NO-TRAILING-SPACES" => "MD009",
254    "NO-HARD-TABS" => "MD010",
255    "NO-REVERSED-LINKS" => "MD011",
256    "NO-MULTIPLE-BLANKS" => "MD012",
257    "LINE-LENGTH" => "MD013",
258    "COMMANDS-SHOW-OUTPUT" => "MD014",
259    "NO-MISSING-SPACE-ATX" => "MD018",
260    "NO-MULTIPLE-SPACE-ATX" => "MD019",
261    "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
262    "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
263    "BLANKS-AROUND-HEADINGS" => "MD022",
264    "HEADING-START-LEFT" => "MD023",
265    "NO-DUPLICATE-HEADING" => "MD024",
266    "SINGLE-TITLE" => "MD025",
267    "SINGLE-H1" => "MD025",
268    "NO-TRAILING-PUNCTUATION" => "MD026",
269    "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
270    "NO-BLANKS-BLOCKQUOTE" => "MD028",
271    "OL-PREFIX" => "MD029",
272    "LIST-MARKER-SPACE" => "MD030",
273    "BLANKS-AROUND-FENCES" => "MD031",
274    "BLANKS-AROUND-LISTS" => "MD032",
275    "NO-INLINE-HTML" => "MD033",
276    "NO-BARE-URLS" => "MD034",
277    "HR-STYLE" => "MD035",
278    "NO-EMPHASIS-AS-HEADING" => "MD036",
279    "NO-SPACE-IN-EMPHASIS" => "MD037",
280    "NO-SPACE-IN-CODE" => "MD038",
281    "NO-SPACE-IN-LINKS" => "MD039",
282    "FENCED-CODE-LANGUAGE" => "MD040",
283    "FIRST-LINE-HEADING" => "MD041",
284    "FIRST-LINE-H1" => "MD041",
285    "NO-EMPTY-LINKS" => "MD042",
286    "REQUIRED-HEADINGS" => "MD043",
287    "PROPER-NAMES" => "MD044",
288    "NO-ALT-TEXT" => "MD045",
289    "CODE-BLOCK-STYLE" => "MD046",
290    "SINGLE-TRAILING-NEWLINE" => "MD047",
291    "CODE-FENCE-STYLE" => "MD048",
292    "EMPHASIS-STYLE" => "MD049",
293    "STRONG-STYLE" => "MD050",
294    "LINK-FRAGMENTS" => "MD051",
295    "REFERENCE-LINKS-IMAGES" => "MD052",
296    "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
297    "LINK-IMAGE-STYLE" => "MD054",
298    "TABLE-PIPE-STYLE" => "MD055",
299    "TABLE-COLUMN-COUNT" => "MD056",
300    "EXISTING-RELATIVE-LINKS" => "MD057",
301    "BLANKS-AROUND-TABLES" => "MD058",
302    "DESCRIPTIVE-LINK-TEXT" => "MD059",
303    "TABLE-CELL-ALIGNMENT" => "MD060",
304    "TABLE-FORMAT" => "MD060",
305    "FORBIDDEN-TERMS" => "MD061",
306    "LINK-DESTINATION-WHITESPACE" => "MD062",
307    "HEADING-CAPITALIZATION" => "MD063",
308    "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
309    "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
310    "FOOTNOTE-VALIDATION" => "MD066",
311    "FOOTNOTE-DEFINITION-ORDER" => "MD067",
312    "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
313    "NO-DUPLICATE-LIST-MARKERS" => "MD069",
314    "NESTED-CODE-FENCE" => "MD070",
315    "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
316    "FRONTMATTER-KEY-SORT" => "MD072",
317    "TOC-VALIDATION" => "MD073",
318    "MKDOCS-NAV" => "MD074",
319    "ORPHANED-TABLE-ROWS" => "MD075",
320    "LIST-ITEM-SPACING" => "MD076",
321    "LIST-CONTINUATION-INDENT" => "MD077",
322};
323
324/// Resolve a rule name alias to its canonical form with O(1) perfect hash lookup
325/// Converts rule aliases (like "ul-style", "line-length") to canonical IDs (like "MD004", "MD013")
326/// Returns None if the rule name is not recognized
327pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
328    // Normalize: uppercase and replace underscores with hyphens
329    let normalized_key = key.to_ascii_uppercase().replace('_', "-");
330
331    // O(1) perfect hash lookup
332    RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
333}
334
335/// Resolves a rule name to its canonical ID, supporting both rule IDs and aliases.
336/// Returns the canonical ID (e.g., "MD001") for any valid input:
337/// - "MD001" → "MD001" (canonical)
338/// - "heading-increment" → "MD001" (alias)
339/// - "HEADING_INCREMENT" → "MD001" (case-insensitive, underscore variant)
340///
341/// For unknown names, falls back to normalization (uppercase for MDxxx pattern, otherwise kebab-case).
342pub fn resolve_rule_name(name: &str) -> String {
343    resolve_rule_name_alias(name).map_or_else(|| normalize_key(name), std::string::ToString::to_string)
344}
345
346/// Resolves a comma-separated list of rule names to canonical IDs.
347/// Handles CLI input like "MD001,line-length,heading-increment".
348/// Empty entries and whitespace are filtered out.
349pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
350    input
351        .split(',')
352        .map(str::trim)
353        .filter(|s| !s.is_empty())
354        .map(resolve_rule_name)
355        .collect()
356}
357
358/// Checks if a rule name (or alias) is valid.
359/// Returns true if the name resolves to a known rule.
360/// Handles the special "all" value and all aliases.
361pub fn is_valid_rule_name(name: &str) -> bool {
362    // Check for special "all" value (case-insensitive)
363    if name.eq_ignore_ascii_case("all") {
364        return true;
365    }
366    resolve_rule_name_alias(name).is_some()
367}