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        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const EXISTENCE_YAML: &str = r#"
232- id: no-jargon
233  message: "Avoid jargon"
234  severity: warning
235  type: existence
236  tokens:
237    - leverage
238    - synergy
239    - paradigm
240  ignorecase: true
241"#;
242
243    const SUBSTITUTION_YAML: &str = r#"
244- id: contractions
245  message: "Use the expanded form"
246  severity: info
247  type: substitution
248  swap:
249    "don't": "do not"
250    "can't": "cannot"
251    "won't": "will not"
252  ignorecase: false
253"#;
254
255    const PATTERN_YAML: &str = r#"
256- id: no-passive
257  message: "Avoid passive voice"
258  severity: warning
259  type: pattern
260  regex: '\b(was|were|been|being)\s+\w+ed\b'
261"#;
262
263    #[test]
264    fn load_existence_rules() {
265        let mut engine = StyleRuleEngine::new();
266        let count = engine.load_yaml(EXISTENCE_YAML).unwrap();
267        assert_eq!(count, 1);
268        assert_eq!(engine.rule_count(), 1);
269    }
270
271    #[test]
272    fn existence_match() {
273        let mut engine = StyleRuleEngine::new();
274        engine.load_yaml(EXISTENCE_YAML).unwrap();
275        let diagnostics = engine.check("We should leverage our synergy.");
276        assert_eq!(diagnostics.len(), 2);
277        assert!(diagnostics.iter().any(|d| d.rule_id == "no-jargon"));
278    }
279
280    #[test]
281    fn existence_ignorecase() {
282        let mut engine = StyleRuleEngine::new();
283        engine.load_yaml(EXISTENCE_YAML).unwrap();
284        let diagnostics = engine.check("LEVERAGE the Paradigm.");
285        assert_eq!(diagnostics.len(), 2);
286    }
287
288    #[test]
289    fn existence_word_boundary() {
290        let mut engine = StyleRuleEngine::new();
291        engine.load_yaml(EXISTENCE_YAML).unwrap();
292        // "leveraged" should NOT match "leverage" due to word boundary
293        let diagnostics = engine.check("They leveraged their position.");
294        assert_eq!(diagnostics.len(), 0);
295    }
296
297    #[test]
298    fn substitution_match() {
299        let mut engine = StyleRuleEngine::new();
300        engine.load_yaml(SUBSTITUTION_YAML).unwrap();
301        let diagnostics = engine.check("You don't need to worry.");
302        assert_eq!(diagnostics.len(), 1);
303        assert_eq!(diagnostics[0].suggestions, vec!["do not"]);
304    }
305
306    #[test]
307    fn pattern_match() {
308        let mut engine = StyleRuleEngine::new();
309        engine.load_yaml(PATTERN_YAML).unwrap();
310        let diagnostics = engine.check("The ball was kicked by the player.");
311        assert_eq!(diagnostics.len(), 1);
312        assert_eq!(diagnostics[0].rule_id, "no-passive");
313    }
314
315    #[test]
316    fn no_matches_on_clean_text() {
317        let mut engine = StyleRuleEngine::new();
318        engine.load_yaml(EXISTENCE_YAML).unwrap();
319        let diagnostics = engine.check("The quick brown fox jumped over the lazy dog.");
320        assert!(diagnostics.is_empty());
321    }
322
323    #[test]
324    fn multiple_rule_files() {
325        let mut engine = StyleRuleEngine::new();
326        engine.load_yaml(EXISTENCE_YAML).unwrap();
327        engine.load_yaml(SUBSTITUTION_YAML).unwrap();
328        engine.load_yaml(PATTERN_YAML).unwrap();
329        assert_eq!(engine.rule_count(), 3);
330    }
331}