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