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)]
67fn line_span_to_byte_range(text: &str, line: u32, span: (u32, u32)) -> (u32, u32) {
68 let target_line = line.saturating_sub(1) as usize;
69 let mut byte_offset: u32 = 0;
70
71 for (i, l) in text.split('\n').enumerate() {
72 if i == target_line {
73 let col_start = span.0.saturating_sub(1) as usize;
74 let col_end = span.1 as usize; let start = byte_offset + col_start.min(l.len()) as u32;
76 let end = byte_offset + col_end.min(l.len()) as u32;
77 return (start, end);
78 }
79 byte_offset += l.len() as u32 + 1;
80 }
81
82 (byte_offset, byte_offset)
83}
84
85fn map_severity(vale_severity: &str) -> i32 {
86 match vale_severity {
87 "error" => Severity::Error as i32,
88 "suggestion" => Severity::Hint as i32,
89 _ => Severity::Warning as i32,
91 }
92}
93
94fn suggestions_from_action(action: &ValeAction) -> Vec<String> {
95 match action.name.as_str() {
96 "replace" | "suggest" => action.params.clone(),
97 "remove" => vec![String::new()],
98 _ => Vec::new(),
99 }
100}
101
102#[async_trait::async_trait]
103impl Engine for ValeEngine {
104 fn name(&self) -> &'static str {
105 "vale"
106 }
107
108 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
109 use tokio::io::AsyncWriteExt;
110 use tokio::process::Command;
111
112 let ext = ext_for_language_id(language_id);
113 let mut cmd = Command::new("vale");
114 cmd.arg("--output=JSON")
115 .arg("--no-exit")
116 .arg(format!("--ext={ext}"));
117
118 if let Some(cfg) = &self.config_path {
119 cmd.arg(format!("--config={cfg}"));
120 }
121
122 cmd.stdin(std::process::Stdio::piped())
123 .stdout(std::process::Stdio::piped())
124 .stderr(std::process::Stdio::piped());
125
126 let output = match cmd.spawn() {
127 Ok(mut child) => {
128 if let Some(mut stdin) = child.stdin.take() {
129 let _ = stdin.write_all(text.as_bytes()).await;
130 let _ = stdin.shutdown().await;
131 }
132 child.wait_with_output().await?
133 }
134 Err(e) => {
135 warn!("Failed to spawn vale: {e}");
136 return Ok(vec![]);
137 }
138 };
139
140 if output.status.code() == Some(2) {
142 let stderr = String::from_utf8_lossy(&output.stderr);
143 warn!(stderr = stderr.trim(), "Vale runtime error");
144 return Ok(vec![]);
145 }
146
147 let stdout = String::from_utf8_lossy(&output.stdout);
148 if stdout.trim().is_empty() {
149 return Ok(vec![]);
150 }
151
152 let vale_output: HashMap<String, Vec<ValeAlert>> = match serde_json::from_str(&stdout) {
153 Ok(o) => o,
154 Err(e) => {
155 warn!("Failed to parse Vale JSON output: {e}");
156 debug!(stdout = %stdout, "Raw Vale output");
157 return Ok(vec![]);
158 }
159 };
160
161 let mut diagnostics = Vec::new();
162 for alerts in vale_output.into_values() {
163 for alert in alerts {
164 let (start_byte, end_byte) = line_span_to_byte_range(text, alert.line, alert.span);
165
166 diagnostics.push(Diagnostic {
167 start_byte,
168 end_byte,
169 message: alert.message,
170 suggestions: suggestions_from_action(&alert.action),
171 rule_id: format!("vale.{}", alert.check),
172 severity: map_severity(&alert.severity),
173 unified_id: String::new(),
174 confidence: 0.75,
175 language: String::new(),
176 pack_installable: false,
177 });
178 }
179 }
180
181 Ok(diagnostics)
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn line_span_to_byte_range_first_line() {
191 let text = "Hello world";
192 let (start, end) = line_span_to_byte_range(text, 1, (7, 11));
194 assert_eq!(&text[start as usize..end as usize], "world");
195 }
196
197 #[test]
198 fn line_span_to_byte_range_second_line() {
199 let text = "First line\nSecond line here";
200 let (start, end) = line_span_to_byte_range(text, 2, (8, 11));
202 assert_eq!(&text[start as usize..end as usize], "line");
203 }
204
205 #[test]
206 fn line_span_to_byte_range_clamped() {
207 let text = "short";
208 let (start, end) = line_span_to_byte_range(text, 1, (1, 100));
210 assert_eq!(start, 0);
211 assert_eq!(end, 5);
212 }
213
214 #[test]
215 fn map_severity_values() {
216 assert_eq!(map_severity("error"), Severity::Error as i32);
217 assert_eq!(map_severity("warning"), Severity::Warning as i32);
218 assert_eq!(map_severity("suggestion"), Severity::Hint as i32);
219 assert_eq!(map_severity("unknown"), Severity::Warning as i32);
220 }
221
222 #[test]
223 fn suggestions_from_replace_action() {
224 let action = ValeAction {
225 name: "replace".to_string(),
226 params: vec!["use".to_string(), "utilize".to_string()],
227 };
228 assert_eq!(suggestions_from_action(&action), vec!["use", "utilize"]);
229 }
230
231 #[test]
232 fn suggestions_from_remove_action() {
233 let action = ValeAction {
234 name: "remove".to_string(),
235 params: vec![],
236 };
237 assert_eq!(suggestions_from_action(&action), vec![""]);
238 }
239
240 #[test]
241 fn suggestions_from_empty_action() {
242 let action = ValeAction::default();
243 assert!(suggestions_from_action(&action).is_empty());
244 }
245
246 #[test]
247 fn ext_for_known_languages() {
248 assert_eq!(ext_for_language_id("markdown"), ".md");
249 assert_eq!(ext_for_language_id("html"), ".html");
250 assert_eq!(ext_for_language_id("latex"), ".tex");
251 assert_eq!(ext_for_language_id("restructuredtext"), ".rst");
252 assert_eq!(ext_for_language_id("org"), ".org");
253 }
254
255 #[test]
256 fn vale_alert_deserializes() {
257 let json = r#"{
258 "Action": {"Name": "replace", "Params": ["use"]},
259 "Span": [13, 20],
260 "Check": "Microsoft.Wordiness",
261 "Description": "",
262 "Link": "https://example.com",
263 "Message": "Consider using 'use' instead of 'utilize'.",
264 "Severity": "warning",
265 "Match": "utilize",
266 "Line": 5
267 }"#;
268 let alert: ValeAlert = serde_json::from_str(json).unwrap();
269 assert_eq!(alert.check, "Microsoft.Wordiness");
270 assert_eq!(alert.severity, "warning");
271 assert_eq!(alert.line, 5);
272 assert_eq!(alert.span, (13, 20));
273 assert_eq!(alert.action.name, "replace");
274 assert_eq!(alert.action.params, vec!["use"]);
275 }
276
277 #[test]
278 fn vale_full_json_output_deserializes() {
279 let json = r#"{
280 "stdin.md": [
281 {
282 "Action": {"Name": "replace", "Params": ["use"]},
283 "Span": [13, 20],
284 "Check": "Microsoft.Wordiness",
285 "Description": "",
286 "Link": "",
287 "Message": "Consider using 'use'.",
288 "Severity": "warning",
289 "Match": "utilize",
290 "Line": 1
291 }
292 ]
293 }"#;
294 let output: HashMap<String, Vec<ValeAlert>> = serde_json::from_str(json).unwrap();
295 assert_eq!(output.len(), 1);
296 let alerts = &output["stdin.md"];
297 assert_eq!(alerts.len(), 1);
298 assert_eq!(alerts[0].check, "Microsoft.Wordiness");
299 }
300
301 #[test]
302 fn vale_alert_null_params_deserializes() {
303 let json = r#"{
305 "Action": {"Name": "", "Params": null},
306 "Span": [1, 2],
307 "Check": "Google.We",
308 "Message": "Avoid first-person plural.",
309 "Severity": "warning",
310 "Match": "We",
311 "Line": 1
312 }"#;
313 let alert: ValeAlert = serde_json::from_str(json).unwrap();
314 assert!(alert.action.params.is_empty());
315 assert!(alert.action.name.is_empty());
316 }
317
318 #[tokio::test]
319 async fn vale_engine_missing_binary() -> Result<()> {
320 let mut engine = ValeEngine::new(None);
321 let result = engine.check("test text", "en-US").await;
323 assert!(result.is_ok());
324 Ok(())
325 }
326
327 #[tokio::test]
330 #[ignore]
331 async fn vale_engine_live() -> Result<()> {
332 let mut engine = ValeEngine::new(Some("/tmp/vale-test/.vale.ini".to_string()));
333 let text = "We would like to utilize this.";
334 let diagnostics = engine.check(text, "en-US").await?;
335
336 println!("Vale returned {} diagnostics:", diagnostics.len());
337 for d in &diagnostics {
338 println!(
339 " [{}-{}] {} (rule: {}, suggestions: {:?})",
340 d.start_byte, d.end_byte, d.message, d.rule_id, d.suggestions
341 );
342 }
343
344 assert!(
345 !diagnostics.is_empty(),
346 "Expected at least 1 diagnostic from Vale"
347 );
348 assert!(diagnostics[0].rule_id.starts_with("vale."));
350 Ok(())
351 }
352}