Skip to main content

lang_check/engines/
vale.rs

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/// A single alert from Vale's `--output=JSON` format.
21#[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/// Fix action attached to a Vale alert.
34#[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
50/// Map a Vale file extension hint from the language ID.
51/// The orchestrator passes a BCP-47 tag (e.g. "en-US"), but we also accept
52/// file-type IDs for direct use in tests.
53fn 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        // "markdown", BCP-47 tags, and anything unknown default to .md
61        _ => ".md",
62    }
63}
64
65/// Convert a 1-based line number and 1-based column span to byte offsets.
66#[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; // span end is inclusive in Vale
75            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        // "warning" and anything unknown
90        _ => 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        // Vale exit code 2 = runtime error; 0 or 1 = normal
141        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        // Line 1, columns 7-11 (1-based) → "world"
193        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        // Line 2, columns 8-11 (1-based) → "line"
201        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        // Span extends beyond line length — should clamp
209        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        // Vale sends `"Params": null` when no action params exist
304        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        // If vale is not on PATH, should return empty (not error)
322        let result = engine.check("test text", "en-US").await;
323        assert!(result.is_ok());
324        Ok(())
325    }
326
327    /// Live integration test — requires `vale` on PATH with Google style.
328    /// Run with: `cargo test vale_engine_live -- --ignored --nocapture`
329    #[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        // Verify rule_id is namespaced with "vale."
349        assert!(diagnostics[0].rule_id.starts_with("vale."));
350        Ok(())
351    }
352}