1use anyhow::Result;
2use serde::Deserialize;
3use std::path::Path;
4
5use crate::checker::{Diagnostic, Severity};
6
7#[derive(Debug, Deserialize, Clone)]
9pub struct StyleRule {
10 pub id: String,
12 pub message: String,
14 #[serde(default = "default_severity")]
16 pub severity: String,
17 #[serde(flatten)]
19 pub pattern: PatternType,
20 pub suggestion: Option<String>,
22}
23
24#[derive(Debug, Deserialize, Clone)]
25#[serde(tag = "type")]
26pub enum PatternType {
27 #[serde(rename = "existence")]
29 Existence {
30 tokens: Vec<String>,
31 #[serde(default)]
32 ignorecase: bool,
33 },
34 #[serde(rename = "pattern")]
36 Pattern { regex: String },
37 #[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
50pub 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 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 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 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 #[must_use]
89 pub const fn rule_count(&self) -> usize {
90 self.rules.len()
91 }
92
93 #[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 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 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}