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(mut table))) = rule.config_schema() {
43                let norm_name = normalize_key(&name); // Normalize the name from config_schema
44                // Overwrite polymorphic keys with the sentinel so the validator skips
45                // type checking for fields whose deserializer accepts multiple TOML
46                // types. The clean default is preserved for `rumdl config --defaults`
47                // because that path calls `default_config_section()` directly.
48                for key in rule.polymorphic_config_keys() {
49                    table.insert(
50                        (*key).to_string(),
51                        crate::rule_config_serde::polymorphic_sentinel_value(),
52                    );
53                }
54                rule_schemas.insert(norm_name.clone(), table);
55                norm_name
56            } else {
57                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
58                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
59                norm_name
60            };
61
62            // Store aliases if the rule provides them
63            if let Some(aliases) = rule.config_aliases() {
64                rule_aliases.insert(norm_name, aliases);
65            }
66        }
67
68        RuleRegistry {
69            rule_schemas,
70            rule_aliases,
71        }
72    }
73
74    /// Get all known rule names
75    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
76        self.rule_schemas.keys().cloned().collect()
77    }
78
79    /// Get the valid configuration keys for a rule, including both original and normalized variants
80    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
81        self.rule_schemas.get(rule).map(|schema| {
82            let mut all_keys = std::collections::BTreeSet::new();
83
84            // Always allow 'severity' and 'enabled' for any rule
85            all_keys.insert("severity".to_string());
86            all_keys.insert("enabled".to_string());
87
88            // Add original keys from schema
89            for key in schema.keys() {
90                all_keys.insert(key.clone());
91            }
92
93            // Add normalized variants for markdownlint compatibility
94            for key in schema.keys() {
95                // Add kebab-case variant
96                all_keys.insert(key.replace('_', "-"));
97                // Add snake_case variant
98                all_keys.insert(key.replace('-', "_"));
99                // Add normalized variant
100                all_keys.insert(normalize_key(key));
101            }
102
103            // Add any aliases defined by the rule
104            if let Some(aliases) = self.rule_aliases.get(rule) {
105                for alias_key in aliases.keys() {
106                    all_keys.insert(alias_key.clone());
107                    // Also add normalized variants of the alias
108                    all_keys.insert(alias_key.replace('_', "-"));
109                    all_keys.insert(alias_key.replace('-', "_"));
110                    all_keys.insert(normalize_key(alias_key));
111                }
112            }
113
114            all_keys
115        })
116    }
117
118    /// Resolve a key as the user wrote it to the schema key it names, trying the rule's
119    /// aliases and the separator/case variants.
120    ///
121    /// Returns `None` when the rule does not accept the key at all. A key that resolves
122    /// may still carry a sentinel value, so this answers "is this key known?" where
123    /// [`RuleRegistry::expected_value_for`] answers "what type must it be?".
124    pub fn canonical_config_key(&self, rule: &str, key: &str) -> Option<&str> {
125        let schema = self.rule_schemas.get(rule)?;
126
127        // Check if this key is an alias
128        if let Some(aliases) = self.rule_aliases.get(rule)
129            && let Some(canonical_key) = aliases.get(key)
130            && let Some((schema_key, _)) = schema.get_key_value(canonical_key)
131        {
132            return Some(schema_key);
133        }
134
135        // Try the original key
136        if let Some((schema_key, _)) = schema.get_key_value(key) {
137            return Some(schema_key);
138        }
139
140        // Try key variants
141        let key_variants = [
142            key.replace('-', "_"), // Convert kebab-case to snake_case
143            key.replace('_', "-"), // Convert snake_case to kebab-case
144            normalize_key(key),    // Normalized key (lowercase, kebab-case)
145        ];
146
147        for variant in &key_variants {
148            if let Some((schema_key, _)) = schema.get_key_value(variant) {
149                return Some(schema_key);
150            }
151        }
152
153        None
154    }
155
156    /// Get the expected value type for a rule's configuration key, trying variants.
157    /// Returns `None` both for an unknown key and for sentinel values (nullable Option
158    /// fields, polymorphic fields that accept multiple TOML types), which signals the
159    /// caller to skip type checking while still recognizing the key as valid. Use
160    /// [`RuleRegistry::canonical_config_key`] to tell those two cases apart.
161    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
162        let schema = self.rule_schemas.get(rule)?;
163        let canonical = self.canonical_config_key(rule, key)?;
164        filter_type_check_sentinels(schema.get(canonical)?)
165    }
166
167    /// Resolve any rule name (canonical or alias) to its canonical form
168    /// Returns None if the rule name is not recognized
169    ///
170    /// Resolution order:
171    /// 1. Direct canonical name match
172    /// 2. Static aliases (built-in markdownlint aliases)
173    pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
174        // Try normalized canonical name first
175        let normalized = normalize_key(name);
176        if self.rule_schemas.contains_key(&normalized) {
177            return Some(normalized);
178        }
179
180        // Try static alias resolution (built-in markdownlint aliases)
181        resolve_rule_name_alias(name).map(std::string::ToString::to_string)
182    }
183}
184
185/// Returns `None` if the value is a sentinel that signals "skip type check"
186/// (nullable Option fields, polymorphic fields that accept multiple types).
187/// Otherwise returns `Some(value)` so the validator can compare types.
188fn filter_type_check_sentinels(value: &toml::Value) -> Option<&toml::Value> {
189    if crate::rule_config_serde::is_nullable_sentinel(value) || crate::rule_config_serde::is_polymorphic_sentinel(value)
190    {
191        None
192    } else {
193        Some(value)
194    }
195}
196
197/// A read-only string map held as a sorted array and looked up by binary search.
198///
199/// The rule-name tables below are fixed at compile time and read on every config
200/// load, so they want a lookup with no build step and no hashing: at these sizes
201/// a binary search is a handful of comparisons over data the linker places in
202/// `.rodata`. Keeping the sort order is the caller's job, which the tests pin.
203pub struct StaticMap {
204    entries: &'static [(&'static str, &'static str)],
205}
206
207impl StaticMap {
208    const fn new(entries: &'static [(&'static str, &'static str)]) -> Self {
209        Self { entries }
210    }
211
212    /// The value for `key`, or `None` if the map has no such key.
213    pub fn get(&self, key: &str) -> Option<&'static str> {
214        self.entries
215            .binary_search_by_key(&key, |(k, _)| k)
216            .ok()
217            .map(|i| self.entries[i].1)
218    }
219
220    /// Every key, in sorted order.
221    pub fn keys(&self) -> impl Iterator<Item = &'static str> {
222        self.entries.iter().map(|(k, _)| *k)
223    }
224
225    /// Every key/value pair, in sorted order.
226    pub fn entries(&self) -> impl Iterator<Item = (&'static str, &'static str)> {
227        self.entries.iter().copied()
228    }
229
230    /// Whether the backing array is sorted by key, the invariant [`get`](Self::get) needs.
231    ///
232    /// Nothing at runtime can act on the answer, so this exists for the tests that
233    /// hold the tables to the order they are written in.
234    #[cfg(test)]
235    fn is_sorted_by_key(&self) -> bool {
236        self.entries.windows(2).all(|w| w[0].0 < w[1].0)
237    }
238}
239
240/// Every spelling rumdl accepts for a rule, mapped to its canonical ID.
241///
242/// Keys are the normalized form [`resolve_rule_name_alias`] produces: uppercase
243/// with hyphens. Sorted, because [`StaticMap`] binary-searches it; the
244/// `rule_alias_map_is_sorted` test holds new entries to that.
245pub static RULE_ALIAS_MAP: StaticMap = StaticMap::new(&[
246    ("BLANK-LINE-AFTER-FRONTMATTER", "MD071"),
247    ("BLANKS-AROUND-FENCES", "MD031"),
248    ("BLANKS-AROUND-HEADINGS", "MD022"),
249    ("BLANKS-AROUND-HORIZONTAL-RULES", "MD065"),
250    ("BLANKS-AROUND-LISTS", "MD032"),
251    ("BLANKS-AROUND-TABLES", "MD058"),
252    ("CHUNK-LABEL-SPACES", "MD079"),
253    ("CODE-BLOCK-STYLE", "MD046"),
254    ("CODE-FENCE-STYLE", "MD048"),
255    ("COMMANDS-SHOW-OUTPUT", "MD014"),
256    ("DESCRIPTIVE-LINK-TEXT", "MD059"),
257    ("EMPHASIS-STYLE", "MD049"),
258    ("EMPTY-FOOTNOTE-DEFINITION", "MD068"),
259    ("EXISTING-RELATIVE-LINKS", "MD057"),
260    ("FENCED-CODE-LANGUAGE", "MD040"),
261    ("FIRST-LINE-H1", "MD041"),
262    ("FIRST-LINE-HEADING", "MD041"),
263    ("FOOTNOTE-DEFINITION-ORDER", "MD067"),
264    ("FOOTNOTE-VALIDATION", "MD066"),
265    ("FORBIDDEN-TERMS", "MD061"),
266    ("FRONTMATTER-KEY-SORT", "MD072"),
267    ("HEADING-ANCHOR-COLLISION", "MD080"),
268    ("HEADING-CAPITALIZATION", "MD063"),
269    ("HEADING-INCREMENT", "MD001"),
270    ("HEADING-START-LEFT", "MD023"),
271    ("HEADING-STYLE", "MD003"),
272    ("HR-STYLE", "MD035"),
273    ("INVISIBLE-CHARACTERS", "MD084"),
274    ("LINE-LENGTH", "MD013"),
275    ("LINK-DESTINATION-WHITESPACE", "MD062"),
276    ("LINK-FRAGMENTS", "MD051"),
277    ("LINK-IMAGE-REFERENCE-DEFINITIONS", "MD053"),
278    ("LINK-IMAGE-STYLE", "MD054"),
279    ("LIST-CONTINUATION-INDENT", "MD077"),
280    ("LIST-INDENT", "MD005"),
281    ("LIST-ITEM-SPACING", "MD076"),
282    ("LIST-MARKER-SPACE", "MD030"),
283    ("MD001", "MD001"),
284    ("MD003", "MD003"),
285    ("MD004", "MD004"),
286    ("MD005", "MD005"),
287    ("MD007", "MD007"),
288    ("MD009", "MD009"),
289    ("MD010", "MD010"),
290    ("MD011", "MD011"),
291    ("MD012", "MD012"),
292    ("MD013", "MD013"),
293    ("MD014", "MD014"),
294    ("MD018", "MD018"),
295    ("MD019", "MD019"),
296    ("MD020", "MD020"),
297    ("MD021", "MD021"),
298    ("MD022", "MD022"),
299    ("MD023", "MD023"),
300    ("MD024", "MD024"),
301    ("MD025", "MD025"),
302    ("MD026", "MD026"),
303    ("MD027", "MD027"),
304    ("MD028", "MD028"),
305    ("MD029", "MD029"),
306    ("MD030", "MD030"),
307    ("MD031", "MD031"),
308    ("MD032", "MD032"),
309    ("MD033", "MD033"),
310    ("MD034", "MD034"),
311    ("MD035", "MD035"),
312    ("MD036", "MD036"),
313    ("MD037", "MD037"),
314    ("MD038", "MD038"),
315    ("MD039", "MD039"),
316    ("MD040", "MD040"),
317    ("MD041", "MD041"),
318    ("MD042", "MD042"),
319    ("MD043", "MD043"),
320    ("MD044", "MD044"),
321    ("MD045", "MD045"),
322    ("MD046", "MD046"),
323    ("MD047", "MD047"),
324    ("MD048", "MD048"),
325    ("MD049", "MD049"),
326    ("MD050", "MD050"),
327    ("MD051", "MD051"),
328    ("MD052", "MD052"),
329    ("MD053", "MD053"),
330    ("MD054", "MD054"),
331    ("MD055", "MD055"),
332    ("MD056", "MD056"),
333    ("MD057", "MD057"),
334    ("MD058", "MD058"),
335    ("MD059", "MD059"),
336    ("MD060", "MD060"),
337    ("MD061", "MD061"),
338    ("MD062", "MD062"),
339    ("MD063", "MD063"),
340    ("MD064", "MD064"),
341    ("MD065", "MD065"),
342    ("MD066", "MD066"),
343    ("MD067", "MD067"),
344    ("MD068", "MD068"),
345    ("MD069", "MD069"),
346    ("MD070", "MD070"),
347    ("MD071", "MD071"),
348    ("MD072", "MD072"),
349    ("MD073", "MD073"),
350    ("MD074", "MD074"),
351    ("MD075", "MD075"),
352    ("MD076", "MD076"),
353    ("MD077", "MD077"),
354    ("MD078", "MD078"),
355    ("MD079", "MD079"),
356    ("MD080", "MD080"),
357    ("MD081", "MD081"),
358    ("MD082", "MD082"),
359    ("MD083", "MD083"),
360    ("MD084", "MD084"),
361    ("MD085", "MD085"),
362    ("MD086", "MD086"),
363    ("MD087", "MD087"),
364    ("MD088", "MD088"),
365    ("MISSING-CHUNK-LABELS", "MD078"),
366    ("MKDOCS-NAV", "MD074"),
367    ("MOJIBAKE", "MD083"),
368    ("NESTED-CODE-FENCE", "MD070"),
369    ("NO-ALT-TEXT", "MD045"),
370    ("NO-BARE-URLS", "MD034"),
371    ("NO-BLANKS-BLOCKQUOTE", "MD028"),
372    ("NO-DUPLICATE-HEADING", "MD024"),
373    ("NO-DUPLICATE-LIST-MARKERS", "MD069"),
374    ("NO-EMPHASIS-AS-HEADING", "MD036"),
375    ("NO-EMPTY-LINKS", "MD042"),
376    ("NO-EMPTY-SECTIONS", "MD082"),
377    ("NO-EXCESSIVE-EMPHASIS", "MD081"),
378    ("NO-HARD-TABS", "MD010"),
379    ("NO-INLINE-HTML", "MD033"),
380    ("NO-MISSING-SPACE-ATX", "MD018"),
381    ("NO-MISSING-SPACE-CLOSED-ATX", "MD020"),
382    ("NO-MULTIPLE-BLANKS", "MD012"),
383    ("NO-MULTIPLE-CONSECUTIVE-SPACES", "MD064"),
384    ("NO-MULTIPLE-SPACE-ATX", "MD019"),
385    ("NO-MULTIPLE-SPACE-BLOCKQUOTE", "MD027"),
386    ("NO-MULTIPLE-SPACE-CLOSED-ATX", "MD021"),
387    ("NO-REVERSED-LINKS", "MD011"),
388    ("NO-SPACE-IN-CODE", "MD038"),
389    ("NO-SPACE-IN-EMPHASIS", "MD037"),
390    ("NO-SPACE-IN-LINK-DESTINATION", "MD062"),
391    ("NO-SPACE-IN-LINKS", "MD039"),
392    ("NO-TRAILING-PUNCTUATION", "MD026"),
393    ("NO-TRAILING-SPACES", "MD009"),
394    ("NO-UNCLOSED-COMMENTS", "MD086"),
395    ("OL-PREFIX", "MD029"),
396    ("ORPHANED-TABLE-ROWS", "MD075"),
397    ("PARAGRAPH-CONTINUATION-INDENT", "MD085"),
398    ("PROPER-NAMES", "MD044"),
399    ("QUOTES-DASHES", "MD088"),
400    ("REFERENCE-LINKS-IMAGES", "MD052"),
401    ("REQUIRED-HEADINGS", "MD043"),
402    ("SINGLE-H1", "MD025"),
403    ("SINGLE-TITLE", "MD025"),
404    ("SINGLE-TRAILING-NEWLINE", "MD047"),
405    ("STRONG-STYLE", "MD050"),
406    ("TABLE-CELL-ALIGNMENT", "MD060"),
407    ("TABLE-COLUMN-COUNT", "MD056"),
408    ("TABLE-FORMAT", "MD060"),
409    ("TABLE-PIPE-STYLE", "MD055"),
410    ("TOC-VALIDATION", "MD073"),
411    ("UL-INDENT", "MD007"),
412    ("UL-STYLE", "MD004"),
413    ("UNUSED-DISABLE-COMMENT", "MD087"),
414]);
415
416/// The name rumdl uses when it writes a rule name itself, one per rule.
417///
418/// A rule can answer to several aliases, so the readable name it is given in
419/// generated output (a disable comment written by the language server, the name
420/// `rumdl rule` reports) has to be chosen rather than derived. The choice is the
421/// alias each rule's documentation lists first.
422///
423/// Sorted for the same reason [`RULE_ALIAS_MAP`] is.
424pub static RULE_PRIMARY_ALIAS: StaticMap = StaticMap::new(&[
425    ("MD001", "heading-increment"),
426    ("MD003", "heading-style"),
427    ("MD004", "ul-style"),
428    ("MD005", "list-indent"),
429    ("MD007", "ul-indent"),
430    ("MD009", "no-trailing-spaces"),
431    ("MD010", "no-hard-tabs"),
432    ("MD011", "no-reversed-links"),
433    ("MD012", "no-multiple-blanks"),
434    ("MD013", "line-length"),
435    ("MD014", "commands-show-output"),
436    ("MD018", "no-missing-space-atx"),
437    ("MD019", "no-multiple-space-atx"),
438    ("MD020", "no-missing-space-closed-atx"),
439    ("MD021", "no-multiple-space-closed-atx"),
440    ("MD022", "blanks-around-headings"),
441    ("MD023", "heading-start-left"),
442    ("MD024", "no-duplicate-heading"),
443    ("MD025", "single-title"),
444    ("MD026", "no-trailing-punctuation"),
445    ("MD027", "no-multiple-space-blockquote"),
446    ("MD028", "no-blanks-blockquote"),
447    ("MD029", "ol-prefix"),
448    ("MD030", "list-marker-space"),
449    ("MD031", "blanks-around-fences"),
450    ("MD032", "blanks-around-lists"),
451    ("MD033", "no-inline-html"),
452    ("MD034", "no-bare-urls"),
453    ("MD035", "hr-style"),
454    ("MD036", "no-emphasis-as-heading"),
455    ("MD037", "no-space-in-emphasis"),
456    ("MD038", "no-space-in-code"),
457    ("MD039", "no-space-in-links"),
458    ("MD040", "fenced-code-language"),
459    ("MD041", "first-line-heading"),
460    ("MD042", "no-empty-links"),
461    ("MD043", "required-headings"),
462    ("MD044", "proper-names"),
463    ("MD045", "no-alt-text"),
464    ("MD046", "code-block-style"),
465    ("MD047", "single-trailing-newline"),
466    ("MD048", "code-fence-style"),
467    ("MD049", "emphasis-style"),
468    ("MD050", "strong-style"),
469    ("MD051", "link-fragments"),
470    ("MD052", "reference-links-images"),
471    ("MD053", "link-image-reference-definitions"),
472    ("MD054", "link-image-style"),
473    ("MD055", "table-pipe-style"),
474    ("MD056", "table-column-count"),
475    ("MD057", "existing-relative-links"),
476    ("MD058", "blanks-around-tables"),
477    ("MD059", "descriptive-link-text"),
478    ("MD060", "table-format"),
479    ("MD061", "forbidden-terms"),
480    ("MD062", "link-destination-whitespace"),
481    ("MD063", "heading-capitalization"),
482    ("MD064", "no-multiple-consecutive-spaces"),
483    ("MD065", "blanks-around-horizontal-rules"),
484    ("MD066", "footnote-validation"),
485    ("MD067", "footnote-definition-order"),
486    ("MD068", "empty-footnote-definition"),
487    ("MD069", "no-duplicate-list-markers"),
488    ("MD070", "nested-code-fence"),
489    ("MD071", "blank-line-after-frontmatter"),
490    ("MD072", "frontmatter-key-sort"),
491    ("MD073", "toc-validation"),
492    ("MD074", "mkdocs-nav"),
493    ("MD075", "orphaned-table-rows"),
494    ("MD076", "list-item-spacing"),
495    ("MD077", "list-continuation-indent"),
496    ("MD078", "missing-chunk-labels"),
497    ("MD079", "chunk-label-spaces"),
498    ("MD080", "heading-anchor-collision"),
499    ("MD081", "no-excessive-emphasis"),
500    ("MD082", "no-empty-sections"),
501    ("MD083", "mojibake"),
502    ("MD084", "invisible-characters"),
503    ("MD085", "paragraph-continuation-indent"),
504    ("MD086", "no-unclosed-comments"),
505    ("MD087", "unused-disable-comment"),
506    ("MD088", "quotes-dashes"),
507]);
508
509/// The readable name for a rule ID, or `None` for a name that is not a rule ID.
510///
511/// The argument is a canonical ID such as `MD013`; resolve an alias with
512/// [`resolve_rule_name_alias`] first.
513pub fn primary_alias(rule_id: &str) -> Option<&'static str> {
514    RULE_PRIMARY_ALIAS.get(rule_id)
515}
516
517/// Resolve a rule name alias to its canonical form
518/// Converts rule aliases (like "ul-style", "line-length") to canonical IDs (like "MD004", "MD013")
519/// Returns None if the rule name is not recognized
520pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
521    // Normalize: uppercase and replace underscores with hyphens
522    let normalized_key = key.to_ascii_uppercase().replace('_', "-");
523
524    RULE_ALIAS_MAP.get(normalized_key.as_str())
525}
526
527/// Resolves a rule name to its canonical ID, supporting both rule IDs and aliases.
528/// Returns the canonical ID (e.g., "MD001") for any valid input:
529/// - "MD001" → "MD001" (canonical)
530/// - "heading-increment" → "MD001" (alias)
531/// - "HEADING_INCREMENT" → "MD001" (case-insensitive, underscore variant)
532///
533/// For unknown names, falls back to normalization (uppercase for MDxxx pattern, otherwise kebab-case).
534pub fn resolve_rule_name(name: &str) -> String {
535    resolve_rule_name_alias(name).map_or_else(|| normalize_key(name), std::string::ToString::to_string)
536}
537
538/// Resolves a comma-separated list of rule names to canonical IDs.
539/// Handles CLI input like "MD001,line-length,heading-increment".
540/// Empty entries and whitespace are filtered out.
541pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
542    input
543        .split(',')
544        .map(str::trim)
545        .filter(|s| !s.is_empty())
546        .map(resolve_rule_name)
547        .collect()
548}
549
550/// Checks if a rule name (or alias) is valid.
551/// Returns true if the name resolves to a known rule.
552/// Handles the special "all" value and all aliases.
553pub fn is_valid_rule_name(name: &str) -> bool {
554    // Check for special "all" value (case-insensitive)
555    if name.eq_ignore_ascii_case("all") {
556        return true;
557    }
558    resolve_rule_name_alias(name).is_some()
559}
560
561/// Canonicalizes a rule-name list in place: every entry is rewritten to its canonical
562/// rule ID via [`resolve_rule_name`], duplicates are removed (keeping first occurrence),
563/// and the special `"all"` keyword is preserved as-is (case-insensitive).
564///
565/// This enforces the runtime invariant that rule lists in `Config` (`enable`, `disable`,
566/// `extend_enable`, `extend_disable`, `fixable`, `unfixable`, and per-file ignore values)
567/// always contain canonical rule IDs. Consumers can therefore compare against
568/// `rule.name()` with simple string equality without needing alias resolution at every
569/// call site.
570///
571/// The operation is idempotent: running it twice produces the same result as once.
572pub fn canonicalize_rule_list_in_place(list: &mut Vec<String>) {
573    if list.is_empty() {
574        return;
575    }
576    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(list.len());
577    let mut out: Vec<String> = Vec::with_capacity(list.len());
578    for entry in list.drain(..) {
579        let canonical = if entry.eq_ignore_ascii_case("all") {
580            "all".to_string()
581        } else {
582            resolve_rule_name(&entry)
583        };
584        if seen.insert(canonical.clone()) {
585            out.push(canonical);
586        }
587    }
588    *list = out;
589}
590
591#[cfg(test)]
592mod primary_alias_tests {
593    use super::{RULE_ALIAS_MAP, RULE_PRIMARY_ALIAS, default_registry, primary_alias, resolve_rule_name_alias};
594
595    /// Every rule ID the alias map knows, paired with the aliases it answers to.
596    fn aliases_by_rule() -> std::collections::BTreeMap<&'static str, Vec<&'static str>> {
597        let mut by_rule: std::collections::BTreeMap<&'static str, Vec<&'static str>> =
598            std::collections::BTreeMap::new();
599        for (alias, rule_id) in RULE_ALIAS_MAP.entries() {
600            let entry = by_rule.entry(rule_id).or_default();
601            if alias != rule_id {
602                entry.push(alias);
603            }
604        }
605        by_rule
606    }
607
608    /// A lookup answers correctly only while the array it binary-searches is sorted,
609    /// and an out-of-order entry silently becomes unreachable rather than failing to
610    /// compile, so the order is asserted rather than assumed.
611    #[test]
612    fn the_rule_name_tables_are_sorted_by_key() {
613        assert!(RULE_ALIAS_MAP.is_sorted_by_key(), "RULE_ALIAS_MAP is out of order");
614        assert!(
615            RULE_PRIMARY_ALIAS.is_sorted_by_key(),
616            "RULE_PRIMARY_ALIAS is out of order"
617        );
618    }
619
620    /// The control for the test above: every key the tables hold is reachable, which
621    /// is what sortedness buys and what an unsorted table would quietly break.
622    #[test]
623    fn every_key_in_the_rule_name_tables_is_reachable() {
624        for (key, value) in RULE_ALIAS_MAP.entries() {
625            assert_eq!(RULE_ALIAS_MAP.get(key), Some(value), "RULE_ALIAS_MAP lost '{key}'");
626        }
627        for (key, value) in RULE_PRIMARY_ALIAS.entries() {
628            assert_eq!(
629                RULE_PRIMARY_ALIAS.get(key),
630                Some(value),
631                "RULE_PRIMARY_ALIAS lost '{key}'"
632            );
633        }
634        assert_eq!(
635            RULE_ALIAS_MAP.get("NOT-A-RULE"),
636            None,
637            "control: a name the table does not hold answers None"
638        );
639    }
640
641    #[test]
642    fn every_rule_has_a_readable_name() {
643        let rule_ids = default_registry().rule_names();
644        assert!(
645            rule_ids.contains("MD013"),
646            "control: the registry lists rules by canonical ID, got {rule_ids:?}"
647        );
648        let missing: Vec<_> = rule_ids
649            .into_iter()
650            .filter(|rule_id| primary_alias(rule_id).is_none())
651            .collect();
652        assert!(
653            missing.is_empty(),
654            "these rules have no entry in RULE_PRIMARY_ALIAS: {missing:?}"
655        );
656    }
657
658    #[test]
659    fn a_readable_name_is_one_of_the_rules_own_aliases() {
660        let by_rule = aliases_by_rule();
661        for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
662            let aliases = by_rule
663                .get(rule_id)
664                .unwrap_or_else(|| panic!("{rule_id} has a readable name but is not in RULE_ALIAS_MAP"));
665            assert!(
666                aliases.iter().any(|alias| alias.eq_ignore_ascii_case(primary)),
667                "{rule_id}'s readable name '{primary}' is not one of its aliases {aliases:?}"
668            );
669        }
670    }
671
672    #[test]
673    fn a_readable_name_resolves_back_to_its_rule() {
674        for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
675            assert_eq!(
676                resolve_rule_name_alias(primary),
677                Some(rule_id),
678                "'{primary}' must be usable anywhere a rule name is accepted"
679            );
680        }
681    }
682
683    #[test]
684    fn a_name_that_is_not_a_rule_id_has_no_readable_name() {
685        // Control: the lookup takes canonical IDs, so an alias or a typo answers None
686        // rather than something plausible.
687        assert_eq!(primary_alias("MD013"), Some("line-length"));
688        assert_eq!(primary_alias("line-length"), None);
689        assert_eq!(primary_alias("MD999"), None);
690    }
691}
692
693#[cfg(test)]
694mod canonicalize_tests {
695    use super::canonicalize_rule_list_in_place;
696
697    #[test]
698    fn rewrites_aliases_to_canonical_ids() {
699        let mut list = vec!["no-inline-html".to_string(), "line-length".to_string()];
700        canonicalize_rule_list_in_place(&mut list);
701        assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
702    }
703
704    #[test]
705    fn dedupes_alias_and_canonical_preserving_order() {
706        let mut list = vec!["MD033".to_string(), "no-inline-html".to_string(), "MD013".to_string()];
707        canonicalize_rule_list_in_place(&mut list);
708        assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
709    }
710
711    #[test]
712    fn preserves_all_keyword_normalized() {
713        let mut list = vec!["ALL".to_string(), "MD013".to_string()];
714        canonicalize_rule_list_in_place(&mut list);
715        assert_eq!(list, vec!["all".to_string(), "MD013".to_string()]);
716    }
717
718    #[test]
719    fn is_idempotent() {
720        let mut list = vec!["no-inline-html".to_string(), "MD013".to_string()];
721        canonicalize_rule_list_in_place(&mut list);
722        let once = list.clone();
723        canonicalize_rule_list_in_place(&mut list);
724        assert_eq!(list, once);
725    }
726
727    #[test]
728    fn handles_empty_and_unknown_inputs() {
729        let mut empty: Vec<String> = Vec::new();
730        canonicalize_rule_list_in_place(&mut empty);
731        assert!(empty.is_empty());
732
733        let mut unknown = vec!["custom-rule".to_string(), "Custom-Rule".to_string()];
734        canonicalize_rule_list_in_place(&mut unknown);
735        // Both normalize to the same kebab-case form, so they dedupe.
736        assert_eq!(unknown, vec!["custom-rule".to_string()]);
737    }
738}