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///
67/// Vale is written in Go and counts its columns in runes, so the span is a
68/// character index into the line and not a byte one. Taking it for bytes
69/// shifts the underline left by one for every multi-byte character earlier on
70/// the same line -- two for an em dash -- which on a line of prose that
71/// happens to contain one lands the squiggle in the middle of the word
72/// before. The offsets on the wire are bytes, so the conversion happens here.
73#[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            // Vale's span end is inclusive, so the exclusive index is one past.
83            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        // "warning" and anything unknown
99        _ => 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        // Vale exit code 2 = runtime error; 0 or 1 = normal
150        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        // Line 1, columns 7-11 (1-based) → "world"
202        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        // Line 2, columns 8-11 (1-based) → "line"
210        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    /// The span Vale actually returned for a line out of the notes this was
215    /// found in, measured with `vale 3.13.1` rather than assumed:
216    ///
217    /// ```text
218    /// The em dash — and morphisms here.
219    /// Span [19, 27]  Match "morphisms"
220    /// ```
221    ///
222    /// The em dash is one rune and three bytes, so reading 19 as a byte
223    /// column starts the underline two bytes early -- on the `d` of "and".
224    #[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        // The multi-byte character is on the first line, so the second line's
234        // own columns are unaffected by it -- but the byte offset it starts
235        // at is not.
236        let text = "Résumé — first
237The word naïve here";
238        // "naïve" is chars 10-14 on line 2, 1-based inclusive.
239        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        // Span extends beyond line length — should clamp
254        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        // Vale sends `"Params": null` when no action params exist
349        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        // If vale is not on PATH, should return empty (not error)
367        let result = engine.check("test text", "en-US").await;
368        assert!(result.is_ok());
369        Ok(())
370    }
371
372    /// Live integration test — requires `vale` on PATH with Google style.
373    /// Run with: `cargo test vale_engine_live -- --ignored --nocapture`
374    #[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        // Verify rule_id is namespaced with "vale."
394        assert!(diagnostics[0].rule_id.starts_with("vale."));
395        Ok(())
396    }
397}