1use crate::checker::{Diagnostic, Severity};
2use anyhow::Result;
3use serde::Deserialize;
4use std::collections::HashMap;
5use tracing::{debug, warn};
6
7use super::Engine;
8
9pub struct ValeEngine {
10 config_path: Option<String>,
11}
12
13impl ValeEngine {
14 #[must_use]
15 pub const fn new(config_path: Option<String>) -> Self {
16 Self { config_path }
17 }
18}
19
20#[derive(Deserialize)]
22#[serde(rename_all = "PascalCase")]
23struct ValeAlert {
24 message: String,
25 severity: String,
26 line: u32,
27 span: (u32, u32),
28 check: String,
29 #[serde(default)]
30 action: ValeAction,
31}
32
33#[derive(Deserialize, Default)]
35#[serde(rename_all = "PascalCase")]
36struct ValeAction {
37 #[serde(default)]
38 name: String,
39 #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
40 params: Vec<String>,
41}
42
43fn deserialize_null_as_empty_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
44where
45 D: serde::Deserializer<'de>,
46{
47 Option::<Vec<String>>::deserialize(deserializer).map(Option::unwrap_or_default)
48}
49
50fn ext_for_language_id(language_id: &str) -> &str {
54 match language_id {
55 "html" => ".html",
56 "latex" => ".tex",
57 "typst" => ".typ",
58 "restructuredtext" => ".rst",
59 "org" => ".org",
60 _ => ".md",
62 }
63}
64
65#[allow(clippy::cast_possible_truncation)]
74fn line_span_to_byte_range(text: &str, line: u32, span: (u32, u32)) -> (u32, u32) {
75 let target_line = line.saturating_sub(1) as usize;
76 let mut byte_offset: u32 = 0;
77
78 for (i, l) in text.split('\n').enumerate() {
79 if i == target_line {
80 let table = super::char_to_byte_table(l);
81 let start_char = span.0.saturating_sub(1) as usize;
82 let end_char = span.1 as usize;
84 let start = byte_offset + super::lookup_offset(&table, start_char);
85 let end = byte_offset + super::lookup_offset(&table, end_char);
86 return (start, end.max(start));
87 }
88 byte_offset += l.len() as u32 + 1;
89 }
90
91 (byte_offset, byte_offset)
92}
93
94fn map_severity(vale_severity: &str) -> i32 {
95 match vale_severity {
96 "error" => Severity::Error as i32,
97 "suggestion" => Severity::Hint as i32,
98 _ => Severity::Warning as i32,
100 }
101}
102
103fn suggestions_from_action(action: &ValeAction) -> Vec<String> {
104 match action.name.as_str() {
105 "replace" | "suggest" => action.params.clone(),
106 "remove" => vec![String::new()],
107 _ => Vec::new(),
108 }
109}
110
111#[async_trait::async_trait]
112impl Engine for ValeEngine {
113 fn name(&self) -> &'static str {
114 "vale"
115 }
116
117 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
118 use tokio::io::AsyncWriteExt;
119 use tokio::process::Command;
120
121 let ext = ext_for_language_id(language_id);
122 let mut cmd = Command::new("vale");
123 cmd.arg("--output=JSON")
124 .arg("--no-exit")
125 .arg(format!("--ext={ext}"));
126
127 if let Some(cfg) = &self.config_path {
128 cmd.arg(format!("--config={cfg}"));
129 }
130
131 cmd.stdin(std::process::Stdio::piped())
132 .stdout(std::process::Stdio::piped())
133 .stderr(std::process::Stdio::piped());
134
135 let output = match cmd.spawn() {
136 Ok(mut child) => {
137 if let Some(mut stdin) = child.stdin.take() {
138 let _ = stdin.write_all(text.as_bytes()).await;
139 let _ = stdin.shutdown().await;
140 }
141 child.wait_with_output().await?
142 }
143 Err(e) => {
144 warn!("Failed to spawn vale: {e}");
145 return Ok(vec![]);
146 }
147 };
148
149 if output.status.code() == Some(2) {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 warn!(stderr = stderr.trim(), "Vale runtime error");
153 return Ok(vec![]);
154 }
155
156 let stdout = String::from_utf8_lossy(&output.stdout);
157 if stdout.trim().is_empty() {
158 return Ok(vec![]);
159 }
160
161 let vale_output: HashMap<String, Vec<ValeAlert>> = match serde_json::from_str(&stdout) {
162 Ok(o) => o,
163 Err(e) => {
164 warn!("Failed to parse Vale JSON output: {e}");
165 debug!(stdout = %stdout, "Raw Vale output");
166 return Ok(vec![]);
167 }
168 };
169
170 let mut diagnostics = Vec::new();
171 for alerts in vale_output.into_values() {
172 for alert in alerts {
173 let (start_byte, end_byte) = line_span_to_byte_range(text, alert.line, alert.span);
174
175 diagnostics.push(Diagnostic {
176 start_byte,
177 end_byte,
178 message: alert.message,
179 suggestions: suggestions_from_action(&alert.action),
180 rule_id: format!("vale.{}", alert.check),
181 severity: map_severity(&alert.severity),
182 unified_id: String::new(),
183 confidence: 0.75,
184 language: String::new(),
185 pack_installable: false,
186 });
187 }
188 }
189
190 Ok(diagnostics)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn line_span_to_byte_range_first_line() {
200 let text = "Hello world";
201 let (start, end) = line_span_to_byte_range(text, 1, (7, 11));
203 assert_eq!(&text[start as usize..end as usize], "world");
204 }
205
206 #[test]
207 fn line_span_to_byte_range_second_line() {
208 let text = "First line\nSecond line here";
209 let (start, end) = line_span_to_byte_range(text, 2, (8, 11));
211 assert_eq!(&text[start as usize..end as usize], "line");
212 }
213
214 #[test]
225 fn a_span_after_an_em_dash_still_lands_on_the_word() {
226 let text = "The em dash — and morphisms here.";
227 let (start, end) = line_span_to_byte_range(text, 1, (19, 27));
228 assert_eq!(&text[start as usize..end as usize], "morphisms");
229 }
230
231 #[test]
232 fn a_span_on_a_later_line_counts_that_line_s_own_characters() {
233 let text = "Résumé — first
237The word naïve here";
238 let (start, end) = line_span_to_byte_range(text, 2, (10, 14));
240 assert_eq!(&text[start as usize..end as usize], "naïve");
241 }
242
243 #[test]
244 fn a_span_reaching_past_the_line_is_clamped_to_its_end() {
245 let text = "Short — line";
246 let (start, end) = line_span_to_byte_range(text, 1, (9, 999));
247 assert_eq!(&text[start as usize..end as usize], "line");
248 }
249
250 #[test]
251 fn line_span_to_byte_range_clamped() {
252 let text = "short";
253 let (start, end) = line_span_to_byte_range(text, 1, (1, 100));
255 assert_eq!(start, 0);
256 assert_eq!(end, 5);
257 }
258
259 #[test]
260 fn map_severity_values() {
261 assert_eq!(map_severity("error"), Severity::Error as i32);
262 assert_eq!(map_severity("warning"), Severity::Warning as i32);
263 assert_eq!(map_severity("suggestion"), Severity::Hint as i32);
264 assert_eq!(map_severity("unknown"), Severity::Warning as i32);
265 }
266
267 #[test]
268 fn suggestions_from_replace_action() {
269 let action = ValeAction {
270 name: "replace".to_string(),
271 params: vec!["use".to_string(), "utilize".to_string()],
272 };
273 assert_eq!(suggestions_from_action(&action), vec!["use", "utilize"]);
274 }
275
276 #[test]
277 fn suggestions_from_remove_action() {
278 let action = ValeAction {
279 name: "remove".to_string(),
280 params: vec![],
281 };
282 assert_eq!(suggestions_from_action(&action), vec![""]);
283 }
284
285 #[test]
286 fn suggestions_from_empty_action() {
287 let action = ValeAction::default();
288 assert!(suggestions_from_action(&action).is_empty());
289 }
290
291 #[test]
292 fn ext_for_known_languages() {
293 assert_eq!(ext_for_language_id("markdown"), ".md");
294 assert_eq!(ext_for_language_id("html"), ".html");
295 assert_eq!(ext_for_language_id("latex"), ".tex");
296 assert_eq!(ext_for_language_id("restructuredtext"), ".rst");
297 assert_eq!(ext_for_language_id("org"), ".org");
298 }
299
300 #[test]
301 fn vale_alert_deserializes() {
302 let json = r#"{
303 "Action": {"Name": "replace", "Params": ["use"]},
304 "Span": [13, 20],
305 "Check": "Microsoft.Wordiness",
306 "Description": "",
307 "Link": "https://example.com",
308 "Message": "Consider using 'use' instead of 'utilize'.",
309 "Severity": "warning",
310 "Match": "utilize",
311 "Line": 5
312 }"#;
313 let alert: ValeAlert = serde_json::from_str(json).unwrap();
314 assert_eq!(alert.check, "Microsoft.Wordiness");
315 assert_eq!(alert.severity, "warning");
316 assert_eq!(alert.line, 5);
317 assert_eq!(alert.span, (13, 20));
318 assert_eq!(alert.action.name, "replace");
319 assert_eq!(alert.action.params, vec!["use"]);
320 }
321
322 #[test]
323 fn vale_full_json_output_deserializes() {
324 let json = r#"{
325 "stdin.md": [
326 {
327 "Action": {"Name": "replace", "Params": ["use"]},
328 "Span": [13, 20],
329 "Check": "Microsoft.Wordiness",
330 "Description": "",
331 "Link": "",
332 "Message": "Consider using 'use'.",
333 "Severity": "warning",
334 "Match": "utilize",
335 "Line": 1
336 }
337 ]
338 }"#;
339 let output: HashMap<String, Vec<ValeAlert>> = serde_json::from_str(json).unwrap();
340 assert_eq!(output.len(), 1);
341 let alerts = &output["stdin.md"];
342 assert_eq!(alerts.len(), 1);
343 assert_eq!(alerts[0].check, "Microsoft.Wordiness");
344 }
345
346 #[test]
347 fn vale_alert_null_params_deserializes() {
348 let json = r#"{
350 "Action": {"Name": "", "Params": null},
351 "Span": [1, 2],
352 "Check": "Google.We",
353 "Message": "Avoid first-person plural.",
354 "Severity": "warning",
355 "Match": "We",
356 "Line": 1
357 }"#;
358 let alert: ValeAlert = serde_json::from_str(json).unwrap();
359 assert!(alert.action.params.is_empty());
360 assert!(alert.action.name.is_empty());
361 }
362
363 #[tokio::test]
364 async fn vale_engine_missing_binary() -> Result<()> {
365 let mut engine = ValeEngine::new(None);
366 let result = engine.check("test text", "en-US").await;
368 assert!(result.is_ok());
369 Ok(())
370 }
371
372 #[tokio::test]
375 #[ignore]
376 async fn vale_engine_live() -> Result<()> {
377 let mut engine = ValeEngine::new(Some("/tmp/vale-test/.vale.ini".to_string()));
378 let text = "We would like to utilize this.";
379 let diagnostics = engine.check(text, "en-US").await?;
380
381 println!("Vale returned {} diagnostics:", diagnostics.len());
382 for d in &diagnostics {
383 println!(
384 " [{}-{}] {} (rule: {}, suggestions: {:?})",
385 d.start_byte, d.end_byte, d.message, d.rule_id, d.suggestions
386 );
387 }
388
389 assert!(
390 !diagnostics.is_empty(),
391 "Expected at least 1 diagnostic from Vale"
392 );
393 assert!(diagnostics[0].rule_id.starts_with("vale."));
395 Ok(())
396 }
397}