Skip to main content

rumdl_lib/rules/md040_fenced_code_language/
md040_config.rs

1use crate::linguist_data::{get_aliases, is_valid_alias, resolve_canonical};
2use crate::rule_config_serde::RuleConfig;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Style for language label normalization
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum LanguageStyle {
10    /// No normalization, only check for missing language (default)
11    #[default]
12    Disabled,
13    /// Normalize to most prevalent alias per canonical language
14    Consistent,
15}
16
17/// Action to take for unknown language labels
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum UnknownLanguageAction {
21    /// Silently ignore unknown languages (default)
22    #[default]
23    Ignore,
24    /// Emit a warning for unknown languages
25    Warn,
26    /// Treat unknown languages as errors
27    Error,
28}
29
30/// Configuration for MD040 (Fenced code language)
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "kebab-case")]
33pub struct MD040Config {
34    /// Language normalization style
35    #[serde(default)]
36    pub style: LanguageStyle,
37
38    /// Override preferred label for specific languages
39    /// Keys: Linguist canonical names (case-insensitive), Values: preferred alias
40    #[serde(default, alias = "preferred_aliases")]
41    pub preferred_aliases: HashMap<String, String>,
42
43    /// Only allow these languages (empty = allow all)
44    /// Uses Linguist canonical language names (case-insensitive)
45    #[serde(default, alias = "allowed_languages")]
46    pub allowed_languages: Vec<String>,
47
48    /// Block these languages (ignored if allowed_languages is non-empty)
49    /// Uses Linguist canonical language names (case-insensitive)
50    #[serde(default, alias = "disallowed_languages")]
51    pub disallowed_languages: Vec<String>,
52
53    /// Action for unknown language labels not in Linguist
54    #[serde(default, alias = "unknown_language_action")]
55    pub unknown_language_action: UnknownLanguageAction,
56
57    /// Languages Linguist does not know that this project uses anyway
58    /// Matched against fence labels case-insensitively
59    #[serde(default)]
60    pub custom_languages: Vec<String>,
61}
62
63impl MD040Config {
64    /// The spelling `custom-languages` declares for `label`, if it declares one.
65    pub fn custom_language(&self, label: &str) -> Option<&str> {
66        self.custom_languages
67            .iter()
68            .find(|declared| declared.eq_ignore_ascii_case(label))
69            .map(String::as_str)
70    }
71
72    /// Why `preferred-aliases` cannot make `alias` stand for `language`, when it cannot.
73    ///
74    /// This is the single definition of a usable entry: it both phrases the
75    /// configuration error and decides whether the entry is honored, so a label
76    /// the user is told is invalid is never one that gets suggested or written.
77    pub fn preferred_alias_problem(&self, language: &str, alias: &str) -> Option<String> {
78        if let Some(canonical) = resolve_canonical(language) {
79            if is_valid_alias(canonical, alias) {
80                return None;
81            }
82            let examples = get_aliases(canonical).map_or_else(String::new, |valid_aliases| {
83                let valid_str = valid_aliases
84                    .iter()
85                    .take(5)
86                    .map(|s| format!("'{s}'"))
87                    .collect::<Vec<_>>()
88                    .join(", ");
89                let suffix = if valid_aliases.len() > 5 { ", ..." } else { "" };
90                format!(" Valid aliases include: {valid_str}{suffix}")
91            });
92            return Some(format!("Invalid alias '{alias}' for language '{canonical}'.{examples}"));
93        }
94
95        // A custom language has no alias list, so the only label that can stand
96        // for it is another spelling of its own name.
97        let Some(declared) = self.custom_language(language) else {
98            return Some(format!(
99                "Unknown language '{language}' in preferred-aliases. Use GitHub Linguist canonical names, or declare it in custom-languages."
100            ));
101        };
102        if alias.eq_ignore_ascii_case(declared) {
103            return None;
104        }
105        Some(format!(
106            "Invalid alias '{alias}' for custom language '{declared}'. A custom language has no aliases, so only a spelling of '{declared}' can be preferred."
107        ))
108    }
109
110    /// The label `preferred-aliases` sets for `language`, when it sets a usable one.
111    pub fn preferred_label(&self, language: &str) -> Option<&str> {
112        self.preferred_aliases
113            .iter()
114            .find(|(configured, _)| configured.eq_ignore_ascii_case(language))
115            .map(|(_, alias)| alias.as_str())
116            .filter(|alias| self.preferred_alias_problem(language, alias).is_none())
117    }
118}
119
120impl RuleConfig for MD040Config {
121    const RULE_NAME: &'static str = "MD040";
122}