Skip to main content

lang_check/
style_rules.rs

1use anyhow::Result;
2use serde::Deserialize;
3use std::path::Path;
4
5use crate::checker::{Diagnostic, Severity};
6
7/// A declarative style rule loaded from YAML, inspired by Vale.
8#[derive(Debug, Deserialize, Clone)]
9pub struct StyleRule {
10    /// Unique rule identifier (e.g. "custom.no-passive-voice").
11    pub id: String,
12    /// Human-readable message shown to the user.
13    pub message: String,
14    /// Severity level: "error", "warning", "info", "hint".
15    #[serde(default = "default_severity")]
16    pub severity: String,
17    /// The match pattern type.
18    #[serde(flatten)]
19    pub pattern: PatternType,
20    /// Optional replacement suggestion.
21    pub suggestion: Option<String>,
22}
23
24#[derive(Debug, Deserialize, Clone)]
25#[serde(tag = "type")]
26pub enum PatternType {
27    /// Match exact words/phrases (case-insensitive by default).
28    #[serde(rename = "existence")]
29    Existence {
30        tokens: Vec<String>,
31        #[serde(default)]
32        ignorecase: bool,
33    },
34    /// Match a regex pattern.
35    #[serde(rename = "pattern")]
36    Pattern { regex: String },
37    /// Match one token and suggest substitution with another.
38    #[serde(rename = "substitution")]
39    Substitution {
40        swap: std::collections::HashMap<String, String>,
41        #[serde(default)]
42        ignorecase: bool,
43    },
44}
45
46fn default_severity() -> String {
47    "warning".to_string()
48}
49
50/// Engine that applies declarative style rules to prose text.
51pub struct StyleRuleEngine {
52    rules: Vec<StyleRule>,
53}
54
55impl Default for StyleRuleEngine {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl StyleRuleEngine {
62    #[must_use]
63    pub const fn new() -> Self {
64        Self { rules: Vec::new() }
65    }
66
67    /// Load rules from a YAML file.
68    pub fn load_file(&mut self, path: &Path) -> Result<usize> {
69        let content = std::fs::read_to_string(path)?;
70        self.load_yaml(&content)
71    }
72
73    /// Load rules from a YAML string.
74    pub fn load_yaml(&mut self, yaml: &str) -> Result<usize> {
75        let rules: Vec<StyleRule> = serde_yaml::from_str(yaml)?;
76        let count = rules.len();
77        self.rules.extend(rules);
78        Ok(count)
79    }
80
81    /// Load all `.yaml`/`.yml` files from a directory, returning how many rules
82    /// they held.
83    pub fn load_dir(&mut self, dir: &Path) -> Result<usize> {
84        crate::fs_util::load_yaml_dir(dir, |path| self.load_file(path))
85    }
86
87    /// Number of loaded rules.
88    #[must_use]
89    pub const fn rule_count(&self) -> usize {
90        self.rules.len()
91    }
92
93    /// Check prose text against all loaded rules.
94    #[must_use]
95    pub fn check(&self, text: &str) -> Vec<Diagnostic> {
96        let mut diagnostics = Vec::new();
97
98        for rule in &self.rules {
99            match &rule.pattern {
100                PatternType::Existence { tokens, ignorecase } => {
101                    for token in tokens {
102                        Self::find_token_matches(text, token, *ignorecase, rule, &mut diagnostics);
103                    }
104                }
105                PatternType::Pattern { regex } => {
106                    if let Ok(re) = regex::Regex::new(regex) {
107                        for m in re.find_iter(text) {
108                            let suggestions = rule
109                                .suggestion
110                                .as_ref()
111                                .map_or_else(Vec::new, |s| vec![s.clone()]);
112                            diagnostics.push(Self::make_diagnostic(
113                                rule,
114                                m.start(),
115                                m.end(),
116                                suggestions,
117                            ));
118                        }
119                    }
120                }
121                PatternType::Substitution { swap, ignorecase } => {
122                    for (from, to) in swap {
123                        Self::find_token_matches_with_suggestion(
124                            text,
125                            from,
126                            *ignorecase,
127                            rule,
128                            to,
129                            &mut diagnostics,
130                        );
131                    }
132                }
133            }
134        }
135
136        diagnostics
137    }
138
139    fn find_token_matches(
140        text: &str,
141        token: &str,
142        ignorecase: bool,
143        rule: &StyleRule,
144        diagnostics: &mut Vec<Diagnostic>,
145    ) {
146        Self::find_token_matches_with_suggestion(
147            text,
148            token,
149            ignorecase,
150            rule,
151            rule.suggestion.as_deref().unwrap_or_default(),
152            diagnostics,
153        );
154    }
155
156    fn find_token_matches_with_suggestion(
157        text: &str,
158        token: &str,
159        ignorecase: bool,
160        rule: &StyleRule,
161        suggestion: &str,
162        diagnostics: &mut Vec<Diagnostic>,
163    ) {
164        let search_text = if ignorecase {
165            text.to_lowercase()
166        } else {
167            text.to_string()
168        };
169        let search_token = if ignorecase {
170            token.to_lowercase()
171        } else {
172            token.to_string()
173        };
174
175        let mut start = 0;
176        while let Some(pos) = search_text[start..].find(&search_token) {
177            let abs_pos = start + pos;
178            let end_pos = abs_pos + token.len();
179
180            // Ensure word boundary match (not part of a larger word)
181            let at_word_start =
182                abs_pos == 0 || !text.as_bytes()[abs_pos - 1].is_ascii_alphanumeric();
183            let at_word_end = end_pos >= text.len()
184                || !text.as_bytes()[end_pos.min(text.len() - 1)].is_ascii_alphanumeric();
185
186            if at_word_start && at_word_end {
187                let suggestions = if suggestion.is_empty() {
188                    vec![]
189                } else {
190                    vec![suggestion.to_string()]
191                };
192                diagnostics.push(Self::make_diagnostic(rule, abs_pos, end_pos, suggestions));
193            }
194
195            start = abs_pos + 1;
196        }
197    }
198
199    fn make_diagnostic(
200        rule: &StyleRule,
201        start: usize,
202        end: usize,
203        suggestions: Vec<String>,
204    ) -> Diagnostic {
205        let severity = match rule.severity.as_str() {
206            "error" => Severity::Error as i32,
207            "info" => Severity::Information as i32,
208            "hint" => Severity::Hint as i32,
209            _ => Severity::Warning as i32,
210        };
211
212        Diagnostic {
213            #[allow(clippy::cast_possible_truncation)]
214            start_byte: start as u32,
215            #[allow(clippy::cast_possible_truncation)]
216            end_byte: end as u32,
217            message: rule.message.clone(),
218            suggestions,
219            rule_id: rule.id.clone(),
220            severity,
221            unified_id: format!("style.custom.{}", rule.id),
222            confidence: 0.9,
223            language: String::new(),
224            pack_installable: false,
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    const EXISTENCE_YAML: &str = r#"
234- id: no-jargon
235  message: "Avoid jargon"
236  severity: warning
237  type: existence
238  tokens:
239    - leverage
240    - synergy
241    - paradigm
242  ignorecase: true
243"#;
244
245    const SUBSTITUTION_YAML: &str = r#"
246- id: contractions
247  message: "Use the expanded form"
248  severity: info
249  type: substitution
250  swap:
251    "don't": "do not"
252    "can't": "cannot"
253    "won't": "will not"
254  ignorecase: false
255"#;
256
257    const PATTERN_YAML: &str = r#"
258- id: no-passive
259  message: "Avoid passive voice"
260  severity: warning
261  type: pattern
262  regex: '\b(was|were|been|being)\s+\w+ed\b'
263"#;
264
265    #[test]
266    fn load_existence_rules() {
267        let mut engine = StyleRuleEngine::new();
268        let count = engine.load_yaml(EXISTENCE_YAML).unwrap();
269        assert_eq!(count, 1);
270        assert_eq!(engine.rule_count(), 1);
271    }
272
273    #[test]
274    fn existence_match() {
275        let mut engine = StyleRuleEngine::new();
276        engine.load_yaml(EXISTENCE_YAML).unwrap();
277        let diagnostics = engine.check("We should leverage our synergy.");
278        assert_eq!(diagnostics.len(), 2);
279        assert!(diagnostics.iter().any(|d| d.rule_id == "no-jargon"));
280    }
281
282    #[test]
283    fn existence_ignorecase() {
284        let mut engine = StyleRuleEngine::new();
285        engine.load_yaml(EXISTENCE_YAML).unwrap();
286        let diagnostics = engine.check("LEVERAGE the Paradigm.");
287        assert_eq!(diagnostics.len(), 2);
288    }
289
290    #[test]
291    fn existence_word_boundary() {
292        let mut engine = StyleRuleEngine::new();
293        engine.load_yaml(EXISTENCE_YAML).unwrap();
294        // "leveraged" should NOT match "leverage" due to word boundary
295        let diagnostics = engine.check("They leveraged their position.");
296        assert_eq!(diagnostics.len(), 0);
297    }
298
299    #[test]
300    fn substitution_match() {
301        let mut engine = StyleRuleEngine::new();
302        engine.load_yaml(SUBSTITUTION_YAML).unwrap();
303        let diagnostics = engine.check("You don't need to worry.");
304        assert_eq!(diagnostics.len(), 1);
305        assert_eq!(diagnostics[0].suggestions, vec!["do not"]);
306    }
307
308    #[test]
309    fn pattern_match() {
310        let mut engine = StyleRuleEngine::new();
311        engine.load_yaml(PATTERN_YAML).unwrap();
312        let diagnostics = engine.check("The ball was kicked by the player.");
313        assert_eq!(diagnostics.len(), 1);
314        assert_eq!(diagnostics[0].rule_id, "no-passive");
315    }
316
317    #[test]
318    fn no_matches_on_clean_text() {
319        let mut engine = StyleRuleEngine::new();
320        engine.load_yaml(EXISTENCE_YAML).unwrap();
321        let diagnostics = engine.check("The quick brown fox jumped over the lazy dog.");
322        assert!(diagnostics.is_empty());
323    }
324
325    #[test]
326    fn multiple_rule_files() {
327        let mut engine = StyleRuleEngine::new();
328        engine.load_yaml(EXISTENCE_YAML).unwrap();
329        engine.load_yaml(SUBSTITUTION_YAML).unwrap();
330        engine.load_yaml(PATTERN_YAML).unwrap();
331        assert_eq!(engine.rule_count(), 3);
332    }
333}