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 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 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}