1use std::sync::LazyLock;
2
3use crate::rule::Rule;
4
5use super::flavor::normalize_key;
6
7static 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
18pub fn default_registry() -> &'static RuleRegistry {
24 &DEFAULT_REGISTRY
25}
26
27pub struct RuleRegistry {
29 pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
31 pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
33}
34
35impl RuleRegistry {
36 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); 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()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
59 norm_name
60 };
61
62 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 pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
76 self.rule_schemas.keys().cloned().collect()
77 }
78
79 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 all_keys.insert("severity".to_string());
86 all_keys.insert("enabled".to_string());
87
88 for key in schema.keys() {
90 all_keys.insert(key.clone());
91 }
92
93 for key in schema.keys() {
95 all_keys.insert(key.replace('_', "-"));
97 all_keys.insert(key.replace('-', "_"));
99 all_keys.insert(normalize_key(key));
101 }
102
103 if let Some(aliases) = self.rule_aliases.get(rule) {
105 for alias_key in aliases.keys() {
106 all_keys.insert(alias_key.clone());
107 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 pub fn canonical_config_key(&self, rule: &str, key: &str) -> Option<&str> {
125 let schema = self.rule_schemas.get(rule)?;
126
127 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 if let Some((schema_key, _)) = schema.get_key_value(key) {
137 return Some(schema_key);
138 }
139
140 let key_variants = [
142 key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
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 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 pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
174 let normalized = normalize_key(name);
176 if self.rule_schemas.contains_key(&normalized) {
177 return Some(normalized);
178 }
179
180 resolve_rule_name_alias(name).map(std::string::ToString::to_string)
182 }
183}
184
185fn 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
197pub struct StaticMap {
205 source: StaticMapSource,
206}
207
208#[derive(Clone, Copy)]
209enum StaticMapSource {
210 Entries(&'static [(&'static str, &'static str)]),
211 RulePrimaryAliases,
212}
213
214struct StaticMapEntries {
215 source: StaticMapSource,
216 index: usize,
217}
218
219impl Iterator for StaticMapEntries {
220 type Item = (&'static str, &'static str);
221
222 fn next(&mut self) -> Option<Self::Item> {
223 let entry = match self.source {
224 StaticMapSource::Entries(entries) => entries.get(self.index).copied(),
225 StaticMapSource::RulePrimaryAliases => crate::rules::rule_identity(self.index),
226 };
227 self.index += usize::from(entry.is_some());
228 entry
229 }
230}
231
232impl StaticMap {
233 const fn new(entries: &'static [(&'static str, &'static str)]) -> Self {
234 Self {
235 source: StaticMapSource::Entries(entries),
236 }
237 }
238
239 const fn rule_primary_aliases() -> Self {
240 Self {
241 source: StaticMapSource::RulePrimaryAliases,
242 }
243 }
244
245 pub fn get(&self, key: &str) -> Option<&'static str> {
247 match self.source {
248 StaticMapSource::Entries(entries) => entries
249 .binary_search_by_key(&key, |(entry_key, _)| entry_key)
250 .ok()
251 .map(|index| entries[index].1),
252 StaticMapSource::RulePrimaryAliases => crate::rules::primary_alias(key),
253 }
254 }
255
256 pub fn keys(&self) -> impl Iterator<Item = &'static str> {
258 self.entries().map(|(key, _)| key)
259 }
260
261 pub fn entries(&self) -> impl Iterator<Item = (&'static str, &'static str)> {
263 StaticMapEntries {
264 source: self.source,
265 index: 0,
266 }
267 }
268
269 #[cfg(test)]
274 fn is_sorted_by_key(&self) -> bool {
275 self.entries()
276 .map(|(key, _)| key)
277 .try_fold(None, |previous, key| match previous {
278 Some(previous) if previous >= key => Err(()),
279 _ => Ok(Some(key)),
280 })
281 .is_ok()
282 }
283}
284
285pub static RULE_ALIAS_MAP: StaticMap = StaticMap::new(&[
291 ("BLANK-LINE-AFTER-FRONTMATTER", "MD071"),
292 ("BLANKS-AROUND-FENCES", "MD031"),
293 ("BLANKS-AROUND-HEADINGS", "MD022"),
294 ("BLANKS-AROUND-HORIZONTAL-RULES", "MD065"),
295 ("BLANKS-AROUND-LISTS", "MD032"),
296 ("BLANKS-AROUND-TABLES", "MD058"),
297 ("CHUNK-LABEL-SPACES", "MD079"),
298 ("CJK-SPACING", "MD089"),
299 ("CODE-BLOCK-STYLE", "MD046"),
300 ("CODE-FENCE-STYLE", "MD048"),
301 ("COMMANDS-SHOW-OUTPUT", "MD014"),
302 ("DESCRIPTIVE-LINK-TEXT", "MD059"),
303 ("EMPHASIS-STYLE", "MD049"),
304 ("EMPTY-FOOTNOTE-DEFINITION", "MD068"),
305 ("EXISTING-RELATIVE-LINKS", "MD057"),
306 ("FENCED-CODE-LANGUAGE", "MD040"),
307 ("FIRST-LINE-H1", "MD041"),
308 ("FIRST-LINE-HEADING", "MD041"),
309 ("FOOTNOTE-DEFINITION-ORDER", "MD067"),
310 ("FOOTNOTE-VALIDATION", "MD066"),
311 ("FORBIDDEN-TERMS", "MD061"),
312 ("FRONTMATTER-KEY-SORT", "MD072"),
313 ("HEADING-ANCHOR-COLLISION", "MD080"),
314 ("HEADING-CAPITALIZATION", "MD063"),
315 ("HEADING-INCREMENT", "MD001"),
316 ("HEADING-START-LEFT", "MD023"),
317 ("HEADING-STYLE", "MD003"),
318 ("HR-STYLE", "MD035"),
319 ("INVALID-ENCODING", "MD094"),
320 ("INVISIBLE-CHARACTERS", "MD084"),
321 ("LINE-LENGTH", "MD013"),
322 ("LINK-CONFUSABLES", "MD095"),
323 ("LINK-DESTINATION-WHITESPACE", "MD062"),
324 ("LINK-FRAGMENTS", "MD051"),
325 ("LINK-IMAGE-REFERENCE-DEFINITIONS", "MD053"),
326 ("LINK-IMAGE-STYLE", "MD054"),
327 ("LIST-CONTINUATION-INDENT", "MD077"),
328 ("LIST-INDENT", "MD005"),
329 ("LIST-ITEM-SPACING", "MD076"),
330 ("LIST-MARKER-SPACE", "MD030"),
331 ("MD001", "MD001"),
332 ("MD003", "MD003"),
333 ("MD004", "MD004"),
334 ("MD005", "MD005"),
335 ("MD007", "MD007"),
336 ("MD009", "MD009"),
337 ("MD010", "MD010"),
338 ("MD011", "MD011"),
339 ("MD012", "MD012"),
340 ("MD013", "MD013"),
341 ("MD014", "MD014"),
342 ("MD018", "MD018"),
343 ("MD019", "MD019"),
344 ("MD020", "MD020"),
345 ("MD021", "MD021"),
346 ("MD022", "MD022"),
347 ("MD023", "MD023"),
348 ("MD024", "MD024"),
349 ("MD025", "MD025"),
350 ("MD026", "MD026"),
351 ("MD027", "MD027"),
352 ("MD028", "MD028"),
353 ("MD029", "MD029"),
354 ("MD030", "MD030"),
355 ("MD031", "MD031"),
356 ("MD032", "MD032"),
357 ("MD033", "MD033"),
358 ("MD034", "MD034"),
359 ("MD035", "MD035"),
360 ("MD036", "MD036"),
361 ("MD037", "MD037"),
362 ("MD038", "MD038"),
363 ("MD039", "MD039"),
364 ("MD040", "MD040"),
365 ("MD041", "MD041"),
366 ("MD042", "MD042"),
367 ("MD043", "MD043"),
368 ("MD044", "MD044"),
369 ("MD045", "MD045"),
370 ("MD046", "MD046"),
371 ("MD047", "MD047"),
372 ("MD048", "MD048"),
373 ("MD049", "MD049"),
374 ("MD050", "MD050"),
375 ("MD051", "MD051"),
376 ("MD052", "MD052"),
377 ("MD053", "MD053"),
378 ("MD054", "MD054"),
379 ("MD055", "MD055"),
380 ("MD056", "MD056"),
381 ("MD057", "MD057"),
382 ("MD058", "MD058"),
383 ("MD059", "MD059"),
384 ("MD060", "MD060"),
385 ("MD061", "MD061"),
386 ("MD062", "MD062"),
387 ("MD063", "MD063"),
388 ("MD064", "MD064"),
389 ("MD065", "MD065"),
390 ("MD066", "MD066"),
391 ("MD067", "MD067"),
392 ("MD068", "MD068"),
393 ("MD069", "MD069"),
394 ("MD070", "MD070"),
395 ("MD071", "MD071"),
396 ("MD072", "MD072"),
397 ("MD073", "MD073"),
398 ("MD074", "MD074"),
399 ("MD075", "MD075"),
400 ("MD076", "MD076"),
401 ("MD077", "MD077"),
402 ("MD078", "MD078"),
403 ("MD079", "MD079"),
404 ("MD080", "MD080"),
405 ("MD081", "MD081"),
406 ("MD082", "MD082"),
407 ("MD083", "MD083"),
408 ("MD084", "MD084"),
409 ("MD085", "MD085"),
410 ("MD086", "MD086"),
411 ("MD087", "MD087"),
412 ("MD088", "MD088"),
413 ("MD089", "MD089"),
414 ("MD090", "MD090"),
415 ("MD091", "MD091"),
416 ("MD092", "MD092"),
417 ("MD093", "MD093"),
418 ("MD094", "MD094"),
419 ("MD095", "MD095"),
420 ("MD096", "MD096"),
421 ("MERGE-CONFLICT", "MD092"),
422 ("MISSING-CHUNK-LABELS", "MD078"),
423 ("MKDOCS-NAV", "MD074"),
424 ("MOJIBAKE", "MD083"),
425 ("NESTED-CODE-FENCE", "MD070"),
426 ("NO-ALT-TEXT", "MD045"),
427 ("NO-BARE-URLS", "MD034"),
428 ("NO-BLANKS-BLOCKQUOTE", "MD028"),
429 ("NO-DUPLICATE-HEADING", "MD024"),
430 ("NO-DUPLICATE-LIST-MARKERS", "MD069"),
431 ("NO-EMPHASIS-AS-HEADING", "MD036"),
432 ("NO-EMPTY-LINKS", "MD042"),
433 ("NO-EMPTY-SECTIONS", "MD082"),
434 ("NO-EXCESSIVE-EMPHASIS", "MD081"),
435 ("NO-FORMATTING-IN-HEADINGS", "MD093"),
436 ("NO-HARD-TABS", "MD010"),
437 ("NO-HR-BEFORE-HEADING", "MD090"),
438 ("NO-INLINE-HTML", "MD033"),
439 ("NO-MARKDOWN-IN-HTML", "MD091"),
440 ("NO-MISSING-SPACE-ATX", "MD018"),
441 ("NO-MISSING-SPACE-CLOSED-ATX", "MD020"),
442 ("NO-MULTIPLE-BLANKS", "MD012"),
443 ("NO-MULTIPLE-CONSECUTIVE-SPACES", "MD064"),
444 ("NO-MULTIPLE-SPACE-ATX", "MD019"),
445 ("NO-MULTIPLE-SPACE-BLOCKQUOTE", "MD027"),
446 ("NO-MULTIPLE-SPACE-CLOSED-ATX", "MD021"),
447 ("NO-REVERSED-LINKS", "MD011"),
448 ("NO-SPACE-IN-CODE", "MD038"),
449 ("NO-SPACE-IN-EMPHASIS", "MD037"),
450 ("NO-SPACE-IN-LINK-DESTINATION", "MD062"),
451 ("NO-SPACE-IN-LINKS", "MD039"),
452 ("NO-TRAILING-PUNCTUATION", "MD026"),
453 ("NO-TRAILING-SPACES", "MD009"),
454 ("NO-UNCLOSED-COMMENTS", "MD086"),
455 ("OL-PREFIX", "MD029"),
456 ("ORPHANED-TABLE-ROWS", "MD075"),
457 ("PARAGRAPH-CONTINUATION-INDENT", "MD085"),
458 ("PROPER-NAMES", "MD044"),
459 ("QUOTES-DASHES", "MD088"),
460 ("REFERENCE-LINKS-IMAGES", "MD052"),
461 ("REQUIRED-HEADINGS", "MD043"),
462 ("SINGLE-H1", "MD025"),
463 ("SINGLE-TITLE", "MD025"),
464 ("SINGLE-TRAILING-NEWLINE", "MD047"),
465 ("STRONG-STYLE", "MD050"),
466 ("TABLE-CELL-ALIGNMENT", "MD060"),
467 ("TABLE-COLUMN-COUNT", "MD056"),
468 ("TABLE-COLUMN-STYLE", "MD060"),
469 ("TABLE-FORMAT", "MD060"),
470 ("TABLE-PIPE-STYLE", "MD055"),
471 ("TABLE-SORT", "MD096"),
472 ("TOC-VALIDATION", "MD073"),
473 ("UL-INDENT", "MD007"),
474 ("UL-STYLE", "MD004"),
475 ("UNUSED-DISABLE-COMMENT", "MD087"),
476]);
477
478pub static RULE_PRIMARY_ALIAS: StaticMap = StaticMap::rule_primary_aliases();
489pub fn primary_alias(rule_id: &str) -> Option<&'static str> {
490 RULE_PRIMARY_ALIAS.get(rule_id)
491}
492
493pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
497 let normalized_key = key.to_ascii_uppercase().replace('_', "-");
499
500 RULE_ALIAS_MAP.get(normalized_key.as_str())
501}
502
503pub fn resolve_rule_name(name: &str) -> String {
511 resolve_rule_name_alias(name).map_or_else(|| normalize_key(name), std::string::ToString::to_string)
512}
513
514pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
518 input
519 .split(',')
520 .map(str::trim)
521 .filter(|s| !s.is_empty())
522 .map(resolve_rule_name)
523 .collect()
524}
525
526pub fn is_valid_rule_name(name: &str) -> bool {
530 if name.eq_ignore_ascii_case("all") {
532 return true;
533 }
534 resolve_rule_name_alias(name).is_some()
535}
536
537pub fn canonicalize_rule_list_in_place(list: &mut Vec<String>) {
549 if list.is_empty() {
550 return;
551 }
552 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(list.len());
553 let mut out: Vec<String> = Vec::with_capacity(list.len());
554 for entry in list.drain(..) {
555 let canonical = if entry.eq_ignore_ascii_case("all") {
556 "all".to_string()
557 } else {
558 resolve_rule_name(&entry)
559 };
560 if seen.insert(canonical.clone()) {
561 out.push(canonical);
562 }
563 }
564 *list = out;
565}
566
567#[cfg(test)]
568mod primary_alias_tests {
569 use super::{RULE_ALIAS_MAP, RULE_PRIMARY_ALIAS, default_registry, primary_alias, resolve_rule_name_alias};
570
571 fn aliases_by_rule() -> std::collections::BTreeMap<&'static str, Vec<&'static str>> {
573 let mut by_rule: std::collections::BTreeMap<&'static str, Vec<&'static str>> =
574 std::collections::BTreeMap::new();
575 for (alias, rule_id) in RULE_ALIAS_MAP.entries() {
576 let entry = by_rule.entry(rule_id).or_default();
577 if alias != rule_id {
578 entry.push(alias);
579 }
580 }
581 by_rule
582 }
583
584 #[test]
588 fn the_rule_name_tables_are_sorted_by_key() {
589 assert!(RULE_ALIAS_MAP.is_sorted_by_key(), "RULE_ALIAS_MAP is out of order");
590 assert!(
591 RULE_PRIMARY_ALIAS.is_sorted_by_key(),
592 "RULE_PRIMARY_ALIAS is out of order"
593 );
594 }
595
596 #[test]
599 fn every_key_in_the_rule_name_tables_is_reachable() {
600 for (key, value) in RULE_ALIAS_MAP.entries() {
601 assert_eq!(RULE_ALIAS_MAP.get(key), Some(value), "RULE_ALIAS_MAP lost '{key}'");
602 }
603 for (key, value) in RULE_PRIMARY_ALIAS.entries() {
604 assert_eq!(
605 RULE_PRIMARY_ALIAS.get(key),
606 Some(value),
607 "RULE_PRIMARY_ALIAS lost '{key}'"
608 );
609 }
610 assert_eq!(
611 RULE_ALIAS_MAP.get("NOT-A-RULE"),
612 None,
613 "control: a name the table does not hold answers None"
614 );
615 }
616
617 #[test]
618 fn every_rule_has_a_readable_name() {
619 let rule_ids = default_registry().rule_names();
620 assert!(
621 rule_ids.contains("MD013"),
622 "control: the registry lists rules by canonical ID, got {rule_ids:?}"
623 );
624 let missing: Vec<_> = rule_ids
625 .into_iter()
626 .filter(|rule_id| primary_alias(rule_id).is_none())
627 .collect();
628 assert!(
629 missing.is_empty(),
630 "these rules have no entry in RULE_PRIMARY_ALIAS: {missing:?}"
631 );
632 }
633
634 #[test]
635 fn a_readable_name_is_one_of_the_rules_own_aliases() {
636 let by_rule = aliases_by_rule();
637 for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
638 let aliases = by_rule
639 .get(rule_id)
640 .unwrap_or_else(|| panic!("{rule_id} has a readable name but is not in RULE_ALIAS_MAP"));
641 assert!(
642 aliases.iter().any(|alias| alias.eq_ignore_ascii_case(primary)),
643 "{rule_id}'s readable name '{primary}' is not one of its aliases {aliases:?}"
644 );
645 }
646 }
647
648 #[test]
649 fn a_readable_name_resolves_back_to_its_rule() {
650 for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
651 assert_eq!(
652 resolve_rule_name_alias(primary),
653 Some(rule_id),
654 "'{primary}' must be usable anywhere a rule name is accepted"
655 );
656 }
657 }
658
659 #[test]
660 fn a_name_that_is_not_a_rule_id_has_no_readable_name() {
661 assert_eq!(primary_alias("MD013"), Some("line-length"));
664 assert_eq!(primary_alias("line-length"), None);
665 assert_eq!(primary_alias("MD999"), None);
666 }
667}
668
669#[cfg(test)]
670mod canonicalize_tests {
671 use super::canonicalize_rule_list_in_place;
672
673 #[test]
674 fn rewrites_aliases_to_canonical_ids() {
675 let mut list = vec!["no-inline-html".to_string(), "line-length".to_string()];
676 canonicalize_rule_list_in_place(&mut list);
677 assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
678 }
679
680 #[test]
681 fn dedupes_alias_and_canonical_preserving_order() {
682 let mut list = vec!["MD033".to_string(), "no-inline-html".to_string(), "MD013".to_string()];
683 canonicalize_rule_list_in_place(&mut list);
684 assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
685 }
686
687 #[test]
688 fn preserves_all_keyword_normalized() {
689 let mut list = vec!["ALL".to_string(), "MD013".to_string()];
690 canonicalize_rule_list_in_place(&mut list);
691 assert_eq!(list, vec!["all".to_string(), "MD013".to_string()]);
692 }
693
694 #[test]
695 fn is_idempotent() {
696 let mut list = vec!["no-inline-html".to_string(), "MD013".to_string()];
697 canonicalize_rule_list_in_place(&mut list);
698 let once = list.clone();
699 canonicalize_rule_list_in_place(&mut list);
700 assert_eq!(list, once);
701 }
702
703 #[test]
704 fn handles_empty_and_unknown_inputs() {
705 let mut empty: Vec<String> = Vec::new();
706 canonicalize_rule_list_in_place(&mut empty);
707 assert!(empty.is_empty());
708
709 let mut unknown = vec!["custom-rule".to_string(), "Custom-Rule".to_string()];
710 canonicalize_rule_list_in_place(&mut unknown);
711 assert_eq!(unknown, vec!["custom-rule".to_string()]);
713 }
714}