use std::sync::LazyLock;
use crate::rule::Rule;
use super::flavor::normalize_key;
static DEFAULT_REGISTRY: LazyLock<RuleRegistry> = LazyLock::new(|| {
let default_config = super::types::Config::default();
let rules = crate::rules::all_rules(&default_config);
RuleRegistry::from_rules(&rules)
});
pub fn default_registry() -> &'static RuleRegistry {
&DEFAULT_REGISTRY
}
pub struct RuleRegistry {
pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
}
impl RuleRegistry {
pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
let mut rule_schemas = std::collections::BTreeMap::new();
let mut rule_aliases = std::collections::BTreeMap::new();
for rule in rules {
let norm_name = if let Some((name, toml::Value::Table(mut table))) = rule.config_schema() {
let norm_name = normalize_key(&name); for key in rule.polymorphic_config_keys() {
table.insert(
(*key).to_string(),
crate::rule_config_serde::polymorphic_sentinel_value(),
);
}
rule_schemas.insert(norm_name.clone(), table);
norm_name
} else {
let norm_name = normalize_key(rule.name()); rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
norm_name
};
if let Some(aliases) = rule.config_aliases() {
rule_aliases.insert(norm_name, aliases);
}
}
RuleRegistry {
rule_schemas,
rule_aliases,
}
}
pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
self.rule_schemas.keys().cloned().collect()
}
pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
self.rule_schemas.get(rule).map(|schema| {
let mut all_keys = std::collections::BTreeSet::new();
all_keys.insert("severity".to_string());
all_keys.insert("enabled".to_string());
for key in schema.keys() {
all_keys.insert(key.clone());
}
for key in schema.keys() {
all_keys.insert(key.replace('_', "-"));
all_keys.insert(key.replace('-', "_"));
all_keys.insert(normalize_key(key));
}
if let Some(aliases) = self.rule_aliases.get(rule) {
for alias_key in aliases.keys() {
all_keys.insert(alias_key.clone());
all_keys.insert(alias_key.replace('_', "-"));
all_keys.insert(alias_key.replace('-', "_"));
all_keys.insert(normalize_key(alias_key));
}
}
all_keys
})
}
pub fn canonical_config_key(&self, rule: &str, key: &str) -> Option<&str> {
let schema = self.rule_schemas.get(rule)?;
if let Some(aliases) = self.rule_aliases.get(rule)
&& let Some(canonical_key) = aliases.get(key)
&& let Some((schema_key, _)) = schema.get_key_value(canonical_key)
{
return Some(schema_key);
}
if let Some((schema_key, _)) = schema.get_key_value(key) {
return Some(schema_key);
}
let key_variants = [
key.replace('-', "_"), key.replace('_', "-"), normalize_key(key), ];
for variant in &key_variants {
if let Some((schema_key, _)) = schema.get_key_value(variant) {
return Some(schema_key);
}
}
None
}
pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
let schema = self.rule_schemas.get(rule)?;
let canonical = self.canonical_config_key(rule, key)?;
filter_type_check_sentinels(schema.get(canonical)?)
}
pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
let normalized = normalize_key(name);
if self.rule_schemas.contains_key(&normalized) {
return Some(normalized);
}
resolve_rule_name_alias(name).map(std::string::ToString::to_string)
}
}
fn filter_type_check_sentinels(value: &toml::Value) -> Option<&toml::Value> {
if crate::rule_config_serde::is_nullable_sentinel(value) || crate::rule_config_serde::is_polymorphic_sentinel(value)
{
None
} else {
Some(value)
}
}
pub struct StaticMap {
source: StaticMapSource,
}
#[derive(Clone, Copy)]
enum StaticMapSource {
Entries(&'static [(&'static str, &'static str)]),
RulePrimaryAliases,
}
struct StaticMapEntries {
source: StaticMapSource,
index: usize,
}
impl Iterator for StaticMapEntries {
type Item = (&'static str, &'static str);
fn next(&mut self) -> Option<Self::Item> {
let entry = match self.source {
StaticMapSource::Entries(entries) => entries.get(self.index).copied(),
StaticMapSource::RulePrimaryAliases => crate::rules::rule_identity(self.index),
};
self.index += usize::from(entry.is_some());
entry
}
}
impl StaticMap {
const fn new(entries: &'static [(&'static str, &'static str)]) -> Self {
Self {
source: StaticMapSource::Entries(entries),
}
}
const fn rule_primary_aliases() -> Self {
Self {
source: StaticMapSource::RulePrimaryAliases,
}
}
pub fn get(&self, key: &str) -> Option<&'static str> {
match self.source {
StaticMapSource::Entries(entries) => entries
.binary_search_by_key(&key, |(entry_key, _)| entry_key)
.ok()
.map(|index| entries[index].1),
StaticMapSource::RulePrimaryAliases => crate::rules::primary_alias(key),
}
}
pub fn keys(&self) -> impl Iterator<Item = &'static str> {
self.entries().map(|(key, _)| key)
}
pub fn entries(&self) -> impl Iterator<Item = (&'static str, &'static str)> {
StaticMapEntries {
source: self.source,
index: 0,
}
}
#[cfg(test)]
fn is_sorted_by_key(&self) -> bool {
self.entries()
.map(|(key, _)| key)
.try_fold(None, |previous, key| match previous {
Some(previous) if previous >= key => Err(()),
_ => Ok(Some(key)),
})
.is_ok()
}
}
pub static RULE_ALIAS_MAP: StaticMap = StaticMap::new(&[
("BLANK-LINE-AFTER-FRONTMATTER", "MD071"),
("BLANKS-AROUND-FENCES", "MD031"),
("BLANKS-AROUND-HEADINGS", "MD022"),
("BLANKS-AROUND-HORIZONTAL-RULES", "MD065"),
("BLANKS-AROUND-LISTS", "MD032"),
("BLANKS-AROUND-TABLES", "MD058"),
("CHUNK-LABEL-SPACES", "MD079"),
("CODE-BLOCK-STYLE", "MD046"),
("CODE-FENCE-STYLE", "MD048"),
("COMMANDS-SHOW-OUTPUT", "MD014"),
("DESCRIPTIVE-LINK-TEXT", "MD059"),
("EMPHASIS-STYLE", "MD049"),
("EMPTY-FOOTNOTE-DEFINITION", "MD068"),
("EXISTING-RELATIVE-LINKS", "MD057"),
("FENCED-CODE-LANGUAGE", "MD040"),
("FIRST-LINE-H1", "MD041"),
("FIRST-LINE-HEADING", "MD041"),
("FOOTNOTE-DEFINITION-ORDER", "MD067"),
("FOOTNOTE-VALIDATION", "MD066"),
("FORBIDDEN-TERMS", "MD061"),
("FRONTMATTER-KEY-SORT", "MD072"),
("HEADING-ANCHOR-COLLISION", "MD080"),
("HEADING-CAPITALIZATION", "MD063"),
("HEADING-INCREMENT", "MD001"),
("HEADING-START-LEFT", "MD023"),
("HEADING-STYLE", "MD003"),
("HR-STYLE", "MD035"),
("INVISIBLE-CHARACTERS", "MD084"),
("LINE-LENGTH", "MD013"),
("LINK-DESTINATION-WHITESPACE", "MD062"),
("LINK-FRAGMENTS", "MD051"),
("LINK-IMAGE-REFERENCE-DEFINITIONS", "MD053"),
("LINK-IMAGE-STYLE", "MD054"),
("LIST-CONTINUATION-INDENT", "MD077"),
("LIST-INDENT", "MD005"),
("LIST-ITEM-SPACING", "MD076"),
("LIST-MARKER-SPACE", "MD030"),
("MD001", "MD001"),
("MD003", "MD003"),
("MD004", "MD004"),
("MD005", "MD005"),
("MD007", "MD007"),
("MD009", "MD009"),
("MD010", "MD010"),
("MD011", "MD011"),
("MD012", "MD012"),
("MD013", "MD013"),
("MD014", "MD014"),
("MD018", "MD018"),
("MD019", "MD019"),
("MD020", "MD020"),
("MD021", "MD021"),
("MD022", "MD022"),
("MD023", "MD023"),
("MD024", "MD024"),
("MD025", "MD025"),
("MD026", "MD026"),
("MD027", "MD027"),
("MD028", "MD028"),
("MD029", "MD029"),
("MD030", "MD030"),
("MD031", "MD031"),
("MD032", "MD032"),
("MD033", "MD033"),
("MD034", "MD034"),
("MD035", "MD035"),
("MD036", "MD036"),
("MD037", "MD037"),
("MD038", "MD038"),
("MD039", "MD039"),
("MD040", "MD040"),
("MD041", "MD041"),
("MD042", "MD042"),
("MD043", "MD043"),
("MD044", "MD044"),
("MD045", "MD045"),
("MD046", "MD046"),
("MD047", "MD047"),
("MD048", "MD048"),
("MD049", "MD049"),
("MD050", "MD050"),
("MD051", "MD051"),
("MD052", "MD052"),
("MD053", "MD053"),
("MD054", "MD054"),
("MD055", "MD055"),
("MD056", "MD056"),
("MD057", "MD057"),
("MD058", "MD058"),
("MD059", "MD059"),
("MD060", "MD060"),
("MD061", "MD061"),
("MD062", "MD062"),
("MD063", "MD063"),
("MD064", "MD064"),
("MD065", "MD065"),
("MD066", "MD066"),
("MD067", "MD067"),
("MD068", "MD068"),
("MD069", "MD069"),
("MD070", "MD070"),
("MD071", "MD071"),
("MD072", "MD072"),
("MD073", "MD073"),
("MD074", "MD074"),
("MD075", "MD075"),
("MD076", "MD076"),
("MD077", "MD077"),
("MD078", "MD078"),
("MD079", "MD079"),
("MD080", "MD080"),
("MD081", "MD081"),
("MD082", "MD082"),
("MD083", "MD083"),
("MD084", "MD084"),
("MD085", "MD085"),
("MD086", "MD086"),
("MD087", "MD087"),
("MD088", "MD088"),
("MISSING-CHUNK-LABELS", "MD078"),
("MKDOCS-NAV", "MD074"),
("MOJIBAKE", "MD083"),
("NESTED-CODE-FENCE", "MD070"),
("NO-ALT-TEXT", "MD045"),
("NO-BARE-URLS", "MD034"),
("NO-BLANKS-BLOCKQUOTE", "MD028"),
("NO-DUPLICATE-HEADING", "MD024"),
("NO-DUPLICATE-LIST-MARKERS", "MD069"),
("NO-EMPHASIS-AS-HEADING", "MD036"),
("NO-EMPTY-LINKS", "MD042"),
("NO-EMPTY-SECTIONS", "MD082"),
("NO-EXCESSIVE-EMPHASIS", "MD081"),
("NO-HARD-TABS", "MD010"),
("NO-INLINE-HTML", "MD033"),
("NO-MISSING-SPACE-ATX", "MD018"),
("NO-MISSING-SPACE-CLOSED-ATX", "MD020"),
("NO-MULTIPLE-BLANKS", "MD012"),
("NO-MULTIPLE-CONSECUTIVE-SPACES", "MD064"),
("NO-MULTIPLE-SPACE-ATX", "MD019"),
("NO-MULTIPLE-SPACE-BLOCKQUOTE", "MD027"),
("NO-MULTIPLE-SPACE-CLOSED-ATX", "MD021"),
("NO-REVERSED-LINKS", "MD011"),
("NO-SPACE-IN-CODE", "MD038"),
("NO-SPACE-IN-EMPHASIS", "MD037"),
("NO-SPACE-IN-LINK-DESTINATION", "MD062"),
("NO-SPACE-IN-LINKS", "MD039"),
("NO-TRAILING-PUNCTUATION", "MD026"),
("NO-TRAILING-SPACES", "MD009"),
("NO-UNCLOSED-COMMENTS", "MD086"),
("OL-PREFIX", "MD029"),
("ORPHANED-TABLE-ROWS", "MD075"),
("PARAGRAPH-CONTINUATION-INDENT", "MD085"),
("PROPER-NAMES", "MD044"),
("QUOTES-DASHES", "MD088"),
("REFERENCE-LINKS-IMAGES", "MD052"),
("REQUIRED-HEADINGS", "MD043"),
("SINGLE-H1", "MD025"),
("SINGLE-TITLE", "MD025"),
("SINGLE-TRAILING-NEWLINE", "MD047"),
("STRONG-STYLE", "MD050"),
("TABLE-CELL-ALIGNMENT", "MD060"),
("TABLE-COLUMN-COUNT", "MD056"),
("TABLE-FORMAT", "MD060"),
("TABLE-PIPE-STYLE", "MD055"),
("TOC-VALIDATION", "MD073"),
("UL-INDENT", "MD007"),
("UL-STYLE", "MD004"),
("UNUSED-DISABLE-COMMENT", "MD087"),
]);
pub static RULE_PRIMARY_ALIAS: StaticMap = StaticMap::rule_primary_aliases();
pub fn primary_alias(rule_id: &str) -> Option<&'static str> {
RULE_PRIMARY_ALIAS.get(rule_id)
}
pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
let normalized_key = key.to_ascii_uppercase().replace('_', "-");
RULE_ALIAS_MAP.get(normalized_key.as_str())
}
pub fn resolve_rule_name(name: &str) -> String {
resolve_rule_name_alias(name).map_or_else(|| normalize_key(name), std::string::ToString::to_string)
}
pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
input
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(resolve_rule_name)
.collect()
}
pub fn is_valid_rule_name(name: &str) -> bool {
if name.eq_ignore_ascii_case("all") {
return true;
}
resolve_rule_name_alias(name).is_some()
}
pub fn canonicalize_rule_list_in_place(list: &mut Vec<String>) {
if list.is_empty() {
return;
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(list.len());
let mut out: Vec<String> = Vec::with_capacity(list.len());
for entry in list.drain(..) {
let canonical = if entry.eq_ignore_ascii_case("all") {
"all".to_string()
} else {
resolve_rule_name(&entry)
};
if seen.insert(canonical.clone()) {
out.push(canonical);
}
}
*list = out;
}
#[cfg(test)]
mod primary_alias_tests {
use super::{RULE_ALIAS_MAP, RULE_PRIMARY_ALIAS, default_registry, primary_alias, resolve_rule_name_alias};
fn aliases_by_rule() -> std::collections::BTreeMap<&'static str, Vec<&'static str>> {
let mut by_rule: std::collections::BTreeMap<&'static str, Vec<&'static str>> =
std::collections::BTreeMap::new();
for (alias, rule_id) in RULE_ALIAS_MAP.entries() {
let entry = by_rule.entry(rule_id).or_default();
if alias != rule_id {
entry.push(alias);
}
}
by_rule
}
#[test]
fn the_rule_name_tables_are_sorted_by_key() {
assert!(RULE_ALIAS_MAP.is_sorted_by_key(), "RULE_ALIAS_MAP is out of order");
assert!(
RULE_PRIMARY_ALIAS.is_sorted_by_key(),
"RULE_PRIMARY_ALIAS is out of order"
);
}
#[test]
fn every_key_in_the_rule_name_tables_is_reachable() {
for (key, value) in RULE_ALIAS_MAP.entries() {
assert_eq!(RULE_ALIAS_MAP.get(key), Some(value), "RULE_ALIAS_MAP lost '{key}'");
}
for (key, value) in RULE_PRIMARY_ALIAS.entries() {
assert_eq!(
RULE_PRIMARY_ALIAS.get(key),
Some(value),
"RULE_PRIMARY_ALIAS lost '{key}'"
);
}
assert_eq!(
RULE_ALIAS_MAP.get("NOT-A-RULE"),
None,
"control: a name the table does not hold answers None"
);
}
#[test]
fn every_rule_has_a_readable_name() {
let rule_ids = default_registry().rule_names();
assert!(
rule_ids.contains("MD013"),
"control: the registry lists rules by canonical ID, got {rule_ids:?}"
);
let missing: Vec<_> = rule_ids
.into_iter()
.filter(|rule_id| primary_alias(rule_id).is_none())
.collect();
assert!(
missing.is_empty(),
"these rules have no entry in RULE_PRIMARY_ALIAS: {missing:?}"
);
}
#[test]
fn a_readable_name_is_one_of_the_rules_own_aliases() {
let by_rule = aliases_by_rule();
for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
let aliases = by_rule
.get(rule_id)
.unwrap_or_else(|| panic!("{rule_id} has a readable name but is not in RULE_ALIAS_MAP"));
assert!(
aliases.iter().any(|alias| alias.eq_ignore_ascii_case(primary)),
"{rule_id}'s readable name '{primary}' is not one of its aliases {aliases:?}"
);
}
}
#[test]
fn a_readable_name_resolves_back_to_its_rule() {
for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
assert_eq!(
resolve_rule_name_alias(primary),
Some(rule_id),
"'{primary}' must be usable anywhere a rule name is accepted"
);
}
}
#[test]
fn a_name_that_is_not_a_rule_id_has_no_readable_name() {
assert_eq!(primary_alias("MD013"), Some("line-length"));
assert_eq!(primary_alias("line-length"), None);
assert_eq!(primary_alias("MD999"), None);
}
}
#[cfg(test)]
mod canonicalize_tests {
use super::canonicalize_rule_list_in_place;
#[test]
fn rewrites_aliases_to_canonical_ids() {
let mut list = vec!["no-inline-html".to_string(), "line-length".to_string()];
canonicalize_rule_list_in_place(&mut list);
assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
}
#[test]
fn dedupes_alias_and_canonical_preserving_order() {
let mut list = vec!["MD033".to_string(), "no-inline-html".to_string(), "MD013".to_string()];
canonicalize_rule_list_in_place(&mut list);
assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
}
#[test]
fn preserves_all_keyword_normalized() {
let mut list = vec!["ALL".to_string(), "MD013".to_string()];
canonicalize_rule_list_in_place(&mut list);
assert_eq!(list, vec!["all".to_string(), "MD013".to_string()]);
}
#[test]
fn is_idempotent() {
let mut list = vec!["no-inline-html".to_string(), "MD013".to_string()];
canonicalize_rule_list_in_place(&mut list);
let once = list.clone();
canonicalize_rule_list_in_place(&mut list);
assert_eq!(list, once);
}
#[test]
fn handles_empty_and_unknown_inputs() {
let mut empty: Vec<String> = Vec::new();
canonicalize_rule_list_in_place(&mut empty);
assert!(empty.is_empty());
let mut unknown = vec!["custom-rule".to_string(), "Custom-Rule".to_string()];
canonicalize_rule_list_in_place(&mut unknown);
assert_eq!(unknown, vec!["custom-rule".to_string()]);
}
}