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