Skip to main content

lang_check/
rules.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4#[derive(Debug, Deserialize)]
5pub struct RuleMapping {
6    pub provider: String,
7    pub mappings: Vec<MappingEntry>,
8}
9
10#[derive(Debug, Deserialize)]
11pub struct MappingEntry {
12    pub native_id: String,
13    pub unified_id: String,
14}
15
16pub struct RuleNormalizer {
17    mappings: HashMap<String, HashMap<String, String>>,
18}
19
20impl Default for RuleNormalizer {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl RuleNormalizer {
27    #[must_use]
28    pub fn new() -> Self {
29        let mut normalizer = Self {
30            mappings: HashMap::new(),
31        };
32
33        // Load default mappings
34        normalizer.load_defaults();
35
36        normalizer
37    }
38
39    fn load_defaults(&mut self) {
40        const HARPER_YAML: &str = include_str!("../data/harper_mapping.yaml");
41        const LT_YAML: &str = include_str!("../data/languagetool_mapping.yaml");
42        const HUNSPELL_YAML: &str = include_str!("../data/hunspell_mapping.yaml");
43
44        for yaml_src in [HARPER_YAML, LT_YAML, HUNSPELL_YAML] {
45            let mapping: RuleMapping =
46                serde_yaml::from_str(yaml_src).expect("embedded YAML mapping should be valid");
47            let mut map = HashMap::new();
48            for entry in mapping.mappings {
49                map.insert(entry.native_id, entry.unified_id);
50            }
51            self.mappings.insert(mapping.provider, map);
52        }
53    }
54
55    /// Returns all (provider, native\_id, unified\_id) triples, sorted for stable output.
56    #[must_use]
57    pub fn all_mappings(&self) -> Vec<(String, String, String)> {
58        let mut result = Vec::new();
59        for (provider, map) in &self.mappings {
60            for (native, unified) in map {
61                result.push((provider.clone(), native.clone(), unified.clone()));
62            }
63        }
64        result.sort();
65        result
66    }
67
68    #[must_use]
69    pub fn normalize(&self, provider: &str, native_id: &str) -> String {
70        if let Some(provider_mappings) = self.mappings.get(provider)
71            && let Some(unified_id) = provider_mappings.get(native_id)
72        {
73            return unified_id.clone();
74        }
75
76        // Default to a generic category if no mapping exists.
77        //
78        // Matched case-insensitively because engine rule ids are shouted:
79        // LanguageTool's French speller is FR_SPELLING_RULE and its German one
80        // GERMAN_SPELLER_RULE, and a case-sensitive `contains("spell")` put
81        // both in `style.unknown` -- where the user dictionary, the name filter
82        // and `lang-check-begin spelling.typo` stopped applying to them.
83        let lowered = native_id.to_ascii_lowercase();
84        if lowered.contains("spell") {
85            "spelling.unknown".to_string()
86        } else if lowered.contains("grammar") {
87            "grammar.unknown".to_string()
88        } else {
89            "style.unknown".to_string()
90        }
91    }
92}
93
94/// The severity a unified rule category carries, before any config override.
95///
96/// Severity is a property of the problem, not of the engine that noticed it.
97/// Each engine had its own opinion -- `LanguageTool` reports a misspelling as
98/// an error, Harper and Hunspell as a warning -- so the same typo came back
99/// red or yellow depending on which of them got there, and a word all three
100/// found whose spans did not merge showed both colours at once. Deciding it
101/// here makes the colour mean how serious the problem is and never which
102/// checker found it, and leaves the merge's "keep the highest severity" rule
103/// a real tiebreak rather than a vote between arbitrary defaults.
104///
105/// A user's `rules:` override still wins: this is the default, applied first.
106#[must_use]
107pub fn default_severity(unified_id: &str) -> Option<i32> {
108    // Categories, not individual rules: a rule this table does not know about
109    // keeps whatever its engine said, which is the right answer for an
110    // external provider's own vocabulary.
111    let category = unified_id.split('.').next().unwrap_or(unified_id);
112    Some(match category {
113        // Wrong, and unambiguously so.
114        "spelling" | "grammar" => 2, // warning
115        // A judgement about how the prose reads, which the author may disagree
116        // with. Never an error.
117        "style" | "typography" => 1, // information
118        _ => return None,
119    })
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn normalize_harper_spelling() {
128        let normalizer = RuleNormalizer::new();
129        assert_eq!(
130            normalizer.normalize("harper", "harper.Spelling"),
131            "spelling.typo"
132        );
133        assert_eq!(
134            normalizer.normalize("harper", "harper.Typo"),
135            "spelling.typo"
136        );
137    }
138
139    #[test]
140    fn normalize_lt_spelling() {
141        let normalizer = RuleNormalizer::new();
142        assert_eq!(
143            normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_US"),
144            "spelling.typo"
145        );
146        assert_eq!(
147            normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_GB"),
148            "spelling.typo"
149        );
150    }
151
152    #[test]
153    fn normalize_article_rules() {
154        let normalizer = RuleNormalizer::new();
155        assert_eq!(
156            normalizer.normalize("harper", "harper.AnA"),
157            "grammar.article"
158        );
159        assert_eq!(
160            normalizer.normalize("languagetool", "languagetool.EN_A_VS_AN"),
161            "grammar.article"
162        );
163    }
164
165    #[test]
166    fn normalize_agreement_rules() {
167        let normalizer = RuleNormalizer::new();
168        assert_eq!(
169            normalizer.normalize("harper", "harper.Agreement"),
170            "grammar.agreement"
171        );
172        assert_eq!(
173            normalizer.normalize("languagetool", "languagetool.SUBJECT_VERB_AGREEMENT"),
174            "grammar.agreement"
175        );
176    }
177
178    #[test]
179    fn normalize_style_rules() {
180        let normalizer = RuleNormalizer::new();
181        assert_eq!(
182            normalizer.normalize("harper", "harper.Readability"),
183            "style.readability"
184        );
185        assert_eq!(
186            normalizer.normalize("harper", "harper.WordChoice"),
187            "style.word_choice"
188        );
189        assert_eq!(
190            normalizer.normalize("languagetool", "languagetool.PASSIVE_VOICE"),
191            "style.passive_voice"
192        );
193    }
194
195    #[test]
196    fn normalize_typography_rules() {
197        let normalizer = RuleNormalizer::new();
198        assert_eq!(
199            normalizer.normalize("harper", "harper.Punctuation"),
200            "typography.punctuation"
201        );
202        assert_eq!(
203            normalizer.normalize("harper", "harper.Capitalization"),
204            "typography.capitalization"
205        );
206        assert_eq!(
207            normalizer.normalize("languagetool", "languagetool.DOUBLE_PUNCTUATION"),
208            "typography.punctuation"
209        );
210    }
211
212    #[test]
213    fn normalize_unknown_spelling_rule() {
214        let normalizer = RuleNormalizer::new();
215        assert_eq!(
216            normalizer.normalize("harper", "harper.SomeSpellRule_spell"),
217            "spelling.unknown"
218        );
219    }
220
221    #[test]
222    fn normalize_unknown_grammar_rule() {
223        let normalizer = RuleNormalizer::new();
224        assert_eq!(
225            normalizer.normalize("harper", "harper.SomeGrammarCheck_grammar"),
226            "grammar.unknown"
227        );
228    }
229
230    #[test]
231    fn normalize_completely_unknown_rule() {
232        let normalizer = RuleNormalizer::new();
233        assert_eq!(
234            normalizer.normalize("unknown_provider", "some.random.rule"),
235            "style.unknown"
236        );
237    }
238
239    #[test]
240    fn spelling_has_one_severity_whichever_engine_found_it() {
241        // The reason this exists: LanguageTool reports a misspelling as an
242        // error and Harper as a warning, so `recieved` came back red from one
243        // and yellow from the other.
244        let normalizer = RuleNormalizer::new();
245        let harper = normalizer.normalize("harper", "harper.Spelling");
246        let lt = normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_US");
247        assert_eq!(default_severity(&harper), default_severity(&lt));
248        assert_eq!(default_severity(&harper), Some(2));
249    }
250
251    #[test]
252    fn style_is_never_an_error() {
253        assert_eq!(default_severity("style.passive_voice"), Some(1));
254        assert_eq!(default_severity("typography.punctuation"), Some(1));
255    }
256
257    #[test]
258    fn an_unknown_category_keeps_what_its_engine_said() {
259        // An external provider's own vocabulary is not this table's business.
260        assert_eq!(default_severity("vale.Custom"), None);
261    }
262}