Skip to main content

lang_check/
config_probe.rs

1//! Resolving the config's references against the world outside the file.
2//!
3//! A config key is one of two kinds. `dialect: "American"` is settled by
4//! reading it -- the value is in a fixed set, and the file alone says whether
5//! it is right. `url: "http://localhost:8010"` is not: the text can be
6//! perfectly well-formed and still name a server that is not running, and no
7//! amount of staring at the file will say which. Only the second kind is
8//! probed here, and only the second kind earns a mark in the editor's gutter.
9//!
10//! That split is the whole design. A mark means a probe ran and produced an
11//! outcome; anything decidable from the text is a diagnostic with no mark.
12//! Marking the first kind too would put a green tick next to an enum check,
13//! and once a few of those are on screen the column stops carrying
14//! information.
15//!
16//! Every probe is reported against the dotted path of the key it is about, so
17//! the editor can put the squiggle under the value that failed rather than
18//! over the block that contains it.
19
20use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use crate::checker;
25use crate::config::Config;
26
27/// How long any one probe may take before it is called down.
28///
29/// Short on purpose: this runs while someone is typing, and an answer that
30/// arrives after they have moved on is worse than no answer. The engines
31/// themselves are given much longer, because there the user is waiting for a
32/// result they asked for.
33const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
34
35/// What a probe found.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ProbeStatus {
38    /// Resolved: the server answered, the file was read, the name is real.
39    Ok,
40    /// Reached, but not entirely as configured.
41    Degraded,
42    /// Did not resolve.
43    Down,
44    /// Not probed, because the engine is switched off.
45    Skipped,
46}
47
48impl ProbeStatus {
49    /// Which of two outcomes an engine's line should show when it has both.
50    ///
51    /// Ordered by how much it should worry the reader, so a block with one
52    /// broken reference reads as broken however many of its other keys
53    /// resolved.
54    #[must_use]
55    const fn severity(self) -> u8 {
56        match self {
57            Self::Skipped => 0,
58            Self::Ok => 1,
59            Self::Degraded => 2,
60            Self::Down => 3,
61        }
62    }
63
64    #[must_use]
65    pub const fn worst(self, other: Self) -> Self {
66        if other.severity() > self.severity() {
67            other
68        } else {
69            self
70        }
71    }
72
73    #[must_use]
74    const fn wire(self) -> checker::ProbeStatus {
75        match self {
76            Self::Ok => checker::ProbeStatus::Ok,
77            Self::Degraded => checker::ProbeStatus::Degraded,
78            Self::Down => checker::ProbeStatus::Down,
79            Self::Skipped => checker::ProbeStatus::Skipped,
80        }
81    }
82}
83
84/// One key's outcome.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Probe {
87    /// Dotted path to the key, e.g. `engines.languagetool.url`.
88    pub key: String,
89    pub status: ProbeStatus,
90    /// One line naming what was reached and what it answered.
91    pub detail: String,
92    /// The owning engine, empty for a top-level key.
93    pub engine: String,
94    /// Whether the key is at fault rather than its value.
95    pub blames_key: bool,
96}
97
98impl Probe {
99    fn new(
100        key: impl Into<String>,
101        engine: &str,
102        status: ProbeStatus,
103        detail: impl Into<String>,
104    ) -> Self {
105        Self {
106            key: key.into(),
107            status,
108            detail: detail.into(),
109            engine: engine.to_string(),
110            blames_key: false,
111        }
112    }
113
114    /// Mark this as a finding about the key itself.
115    ///
116    /// A rule name the engine does not have is a bad key; underlining the
117    /// `false` beside it would point at the wrong token.
118    #[must_use]
119    const fn blaming_key(mut self) -> Self {
120        self.blames_key = true;
121        self
122    }
123
124    #[must_use]
125    pub fn into_wire(self) -> checker::ConfigProbe {
126        checker::ConfigProbe {
127            key: self.key,
128            status: self.status.wire() as i32,
129            detail: self.detail,
130            engine: self.engine,
131            blames_key: self.blames_key,
132        }
133    }
134}
135
136/// Probe every external reference in `config`, concurrently.
137///
138/// The engines do not depend on each other, and the slowest of them is a
139/// network round trip, so running them in sequence would add their latencies
140/// for no reason.
141pub async fn probe_config(config: &Config, workspace_root: &Path) -> Vec<Probe> {
142    let (harper, languagetool, vale, proselint, spell) = tokio::join!(
143        probe_harper(config),
144        probe_languagetool(config),
145        probe_vale(config),
146        probe_proselint(config),
147        probe_spell_language(config, workspace_root),
148    );
149
150    let mut out = Vec::new();
151    out.extend(harper);
152    out.extend(languagetool);
153    out.extend(vale);
154    out.extend(proselint);
155    out.extend(spell);
156    out
157}
158
159/// Harper runs in this process, so the engine itself cannot be unreachable.
160///
161/// Its rule names can still be wrong, and that is the failure worth catching:
162/// a misspelled linter key is accepted in silence today and simply does not
163/// apply, so the rule the user switched off stays on with nothing to say so.
164async fn probe_harper(config: &Config) -> Vec<Probe> {
165    const ENGINE: &str = "harper";
166    if !config.engines.harper.enabled {
167        return vec![Probe::new(
168            "engines.harper",
169            ENGINE,
170            ProbeStatus::Skipped,
171            "Harper is switched off.",
172        )];
173    }
174
175    let linters = config.engines.harper.linters.clone();
176    // Building the curated lint group loads the bundled FST dictionary, which
177    // is worth keeping off the thread the editor is talking to.
178    let unknown: Vec<String> = tokio::task::spawn_blocking(move || {
179        if linters.is_empty() {
180            return Vec::new();
181        }
182        let dict = harper_core::spell::FstDictionary::curated();
183        let group =
184            harper_core::linting::LintGroup::new_curated(dict, harper_core::Dialect::American);
185        let mut unknown: Vec<String> = linters
186            .keys()
187            .filter(|name| !group.contains_key(name))
188            .cloned()
189            .collect();
190        unknown.sort();
191        unknown
192    })
193    .await
194    .unwrap_or_default();
195
196    let mut probes = vec![Probe::new(
197        "engines.harper",
198        ENGINE,
199        ProbeStatus::Ok,
200        "Harper is built in and always available.",
201    )];
202    for name in unknown {
203        probes.push(
204            Probe::new(
205                format!("engines.harper.linters.{name}"),
206                ENGINE,
207                ProbeStatus::Down,
208                format!(
209                    "Harper has no linter called \"{name}\", so this setting does nothing. \
210                     Rule names are case-sensitive and spelled like LongSentences."
211                ),
212            )
213            .blaming_key(),
214        );
215    }
216    probes
217}
218
219/// Whether the configured `LanguageTool` server is up, and whether it serves
220/// the language it is being asked for.
221///
222/// `GET /v2/languages` rather than a `/v2/check` with sample text: it is the
223/// cheapest endpoint that proves the server is a `LanguageTool` and not
224/// merely something listening on that port, and its answer is exactly the
225/// list needed to tell whether `spell_language` is servable.
226async fn probe_languagetool(config: &Config) -> Vec<Probe> {
227    const ENGINE: &str = "languagetool";
228    let lt = &config.engines.languagetool;
229    if !lt.enabled {
230        return vec![Probe::new(
231            "engines.languagetool",
232            ENGINE,
233            ProbeStatus::Skipped,
234            "LanguageTool is switched off.",
235        )];
236    }
237
238    // Reported against the block and against the URL both: the block is what
239    // carries the gutter mark, and the URL is what the squiggle goes under.
240    let blame_url = |why: String| {
241        vec![
242            Probe::new(
243                "engines.languagetool",
244                ENGINE,
245                ProbeStatus::Down,
246                why.clone(),
247            ),
248            Probe::new("engines.languagetool.url", ENGINE, ProbeStatus::Down, why),
249        ]
250    };
251
252    if let Err(why) = crate::engines::usable_languagetool_url(&lt.url) {
253        return blame_url(why);
254    }
255
256    let endpoint = format!("{}/v2/languages", lt.url.trim_end_matches('/'));
257    let entries = match languagetool_languages(&endpoint).await {
258        Ok(entries) => entries,
259        Err(why) => return blame_url(why),
260    };
261
262    let served: BTreeSet<String> = entries
263        .iter()
264        .flat_map(|entry| [entry.code.to_lowercase(), entry.long_code.to_lowercase()])
265        .collect();
266
267    let reached = format!(
268        "LanguageTool answered at {}, serving {} languages.",
269        lt.url,
270        entries.len()
271    );
272    let mut probes = vec![
273        Probe::new(
274            "engines.languagetool",
275            ENGINE,
276            ProbeStatus::Ok,
277            reached.clone(),
278        ),
279        Probe::new("engines.languagetool.url", ENGINE, ProbeStatus::Ok, reached),
280    ];
281
282    // A server that is up but does not serve the language being checked is
283    // the case a bare reachability probe calls healthy and the user
284    // experiences as LanguageTool silently doing nothing.
285    if !serves(&served, &config.engines.spell_language) {
286        probes.push(Probe::new(
287            "engines.languagetool",
288            ENGINE,
289            ProbeStatus::Degraded,
290            format!(
291                "LanguageTool is up at {} but does not serve \"{}\", so it will not report \
292                 anything for this workspace.",
293                lt.url, config.engines.spell_language
294            ),
295        ));
296    }
297
298    if let Some(mother) = &lt.mother_tongue
299        && !serves(&served, mother)
300    {
301        probes.push(Probe::new(
302            "engines.languagetool.mother_tongue",
303            ENGINE,
304            ProbeStatus::Degraded,
305            format!(
306                "This server does not serve \"{mother}\", so false-friend detection will \
307                 not run."
308            ),
309        ));
310    }
311
312    probes
313}
314
315/// Whether a served-language set covers a BCP-47 tag.
316///
317/// `en-US` is covered by a server offering plain `en`, because that is what
318/// `LanguageTool` does with the tag when it receives it. An empty tag is
319/// nothing to complain about and counts as covered.
320fn serves(served: &BTreeSet<String>, tag: &str) -> bool {
321    if tag.is_empty() {
322        return true;
323    }
324    let tag = tag.to_lowercase();
325    if served.contains(&tag) {
326        return true;
327    }
328    let base = tag.split('-').next().unwrap_or(&tag);
329    served.contains(base)
330}
331
332/// `GET /v2/languages`, with the failure phrased as advice.
333async fn languagetool_languages(endpoint: &str) -> Result<Vec<LanguageEntry>, String> {
334    let client = reqwest::Client::builder()
335        .connect_timeout(PROBE_TIMEOUT)
336        .timeout(PROBE_TIMEOUT)
337        .build()
338        .map_err(|e| format!("Could not build an HTTP client to reach LanguageTool: {e}"))?;
339
340    let response = client.get(endpoint).send().await.map_err(|e| {
341        format!(
342            "Could not reach {endpoint}: {}. Start the server, or set \
343             engines.languagetool.enabled to false.",
344            terse_reqwest_error(&e)
345        )
346    })?;
347
348    if !response.status().is_success() {
349        return Err(format!(
350            "{endpoint} answered {}. Check that engines.languagetool.url points at a \
351             LanguageTool server.",
352            response.status()
353        ));
354    }
355
356    response.json::<Vec<LanguageEntry>>().await.map_err(|e| {
357        format!(
358            "{endpoint} answered, but not with a LanguageTool language list ({e}). Check \
359             that engines.languagetool.url points at a LanguageTool server."
360        )
361    })
362}
363
364#[derive(serde::Deserialize)]
365struct LanguageEntry {
366    code: String,
367    #[serde(rename = "longCode")]
368    long_code: String,
369}
370
371/// Vale: the binary has to be on PATH, and the config file has to be readable.
372async fn probe_vale(config: &Config) -> Vec<Probe> {
373    let vale = &config.engines.vale;
374    probe_cli_engine("vale", "Vale", vale.enabled, vale.config.as_deref()).await
375}
376
377/// Proselint, on the same two questions as Vale.
378async fn probe_proselint(config: &Config) -> Vec<Probe> {
379    let proselint = &config.engines.proselint;
380    probe_cli_engine(
381        "proselint",
382        "Proselint",
383        proselint.enabled,
384        proselint.config.as_deref(),
385    )
386    .await
387}
388
389/// An engine that is an external binary plus an optional config file.
390///
391/// Both are reported separately because the fixes are different -- one is an
392/// install, the other is a path in this file. `engine` is both the binary name
393/// and the key under `engines.`; `display` is how the detail text names it.
394async fn probe_cli_engine(
395    engine: &str,
396    display: &str,
397    enabled: bool,
398    config_path: Option<&str>,
399) -> Vec<Probe> {
400    let key = format!("engines.{engine}");
401    if !enabled {
402        return vec![Probe::new(
403            key,
404            engine,
405            ProbeStatus::Skipped,
406            format!("{display} is switched off."),
407        )];
408    }
409
410    let mut probes = match binary_version(engine, &["--version"]).await {
411        Ok(version) => vec![Probe::new(
412            &key,
413            engine,
414            ProbeStatus::Ok,
415            format!("Found {version} on PATH."),
416        )],
417        Err(why) => return vec![Probe::new(key, engine, ProbeStatus::Down, why)],
418    };
419
420    if let Some(path) = config_path {
421        probes.push(readable_file(
422            &format!("{key}.config"),
423            engine,
424            path,
425            &format!("{display} config"),
426        ));
427    }
428    probes
429}
430
431/// Whether anything can actually spell-check the configured language.
432///
433/// Harper reads English only, so a workspace set to `de-DE` with Harper alone
434/// is configured to check nothing -- which today produces a clean document
435/// and no explanation.
436async fn probe_spell_language(config: &Config, workspace_root: &Path) -> Vec<Probe> {
437    let tag = config.engines.spell_language.clone();
438    if tag.is_empty() {
439        return vec![Probe::new(
440            "engines.spell_language",
441            "",
442            ProbeStatus::Down,
443            "engines.spell_language is empty. Set it to a BCP-47 tag such as en-US.",
444        )];
445    }
446
447    let base = tag.split('-').next().unwrap_or(&tag).to_lowercase();
448    let mut servers: Vec<String> = Vec::new();
449    if config.engines.harper.enabled && base == "en" {
450        servers.push("Harper".to_string());
451    }
452    if config.engines.languagetool.enabled {
453        servers.push("LanguageTool".to_string());
454    }
455
456    if config.engines.hunspell.enabled {
457        let search = config.engines.hunspell.search_paths.clone();
458        let overrides = config.engines.hunspell.dictionary_paths.clone();
459        let wanted = tag.clone();
460        let root = workspace_root.to_path_buf();
461        let resolved = tokio::task::spawn_blocking(move || {
462            let mut registry = crate::packs::PackRegistry::new();
463            for path in &search {
464                registry = registry.with_search_path(absolute_against(&root, path));
465            }
466            for (language, path) in &overrides {
467                registry = registry.with_override(language, absolute_against(&root, path));
468            }
469            registry.resolve(&wanted).is_ok()
470        })
471        .await
472        .unwrap_or(false);
473        if resolved {
474            servers.push("Hunspell".to_string());
475        }
476    }
477
478    if servers.is_empty() {
479        return vec![Probe::new(
480            "engines.spell_language",
481            "",
482            ProbeStatus::Down,
483            format!(
484                "Nothing enabled here checks \"{tag}\". Harper reads English only; enable \
485                 LanguageTool or install a Hunspell dictionary for it."
486            ),
487        )];
488    }
489
490    vec![Probe::new(
491        "engines.spell_language",
492        "",
493        ProbeStatus::Ok,
494        format!("\"{tag}\" is checked by {}.", servers.join(" and ")),
495    )]
496}
497
498fn absolute_against(root: &Path, value: &str) -> PathBuf {
499    let path = Path::new(value);
500    if path.is_absolute() {
501        path.to_path_buf()
502    } else {
503        root.join(path)
504    }
505}
506
507/// Whether a path in the config names a file that can be opened.
508fn readable_file(key: &str, engine: &str, path: &str, what: &str) -> Probe {
509    match std::fs::metadata(path) {
510        Ok(meta) if meta.is_dir() => Probe::new(
511            key,
512            engine,
513            ProbeStatus::Down,
514            format!("{path} is a directory, not a {what} file."),
515        ),
516        Ok(_) => match std::fs::File::open(path) {
517            Ok(_) => Probe::new(key, engine, ProbeStatus::Ok, format!("Read {path}.")),
518            Err(e) => Probe::new(
519                key,
520                engine,
521                ProbeStatus::Down,
522                format!("{path} exists but could not be opened: {e}"),
523            ),
524        },
525        Err(_) => Probe::new(
526            key,
527            engine,
528            ProbeStatus::Down,
529            format!("No {what} at {path}."),
530        ),
531    }
532}
533
534/// Run `binary --version` and return the first line it prints.
535///
536/// The version is worth having in the hover: "found vale 3.7.1 on PATH"
537/// answers a different question from "vale is installed", and the difference
538/// matters when a config uses a style only newer versions ship.
539async fn binary_version(binary: &str, args: &[&str]) -> Result<String, String> {
540    let mut command = tokio::process::Command::new(binary);
541    command
542        .args(args)
543        .stdin(std::process::Stdio::null())
544        .stdout(std::process::Stdio::piped())
545        .stderr(std::process::Stdio::piped());
546
547    let run = tokio::time::timeout(PROBE_TIMEOUT, command.output()).await;
548    match run {
549        Ok(Ok(output)) => {
550            // Some print the version to stderr, some to stdout, and which one
551            // is not worth depending on.
552            let text = if output.stdout.is_empty() {
553                String::from_utf8_lossy(&output.stderr).to_string()
554            } else {
555                String::from_utf8_lossy(&output.stdout).to_string()
556            };
557            let line = text.lines().next().unwrap_or("").trim().to_string();
558            if line.is_empty() {
559                Ok(binary.to_string())
560            } else {
561                Ok(line)
562            }
563        }
564        Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
565            "{binary} is not on PATH. Install it, or set engines.{binary}.enabled to false."
566        )),
567        Ok(Err(e)) => Err(format!("Could not run {binary}: {e}")),
568        Err(_) => Err(format!(
569            "{binary} did not answer within {}s.",
570            PROBE_TIMEOUT.as_secs()
571        )),
572    }
573}
574
575/// The part of a `reqwest` error worth putting in front of a user.
576///
577/// The `Display` of a transport error is a chain that ends in the useful
578/// sentence and begins with three that are not.
579fn terse_reqwest_error(error: &reqwest::Error) -> String {
580    let mut source: &dyn std::error::Error = error;
581    let mut last = error.to_string();
582    while let Some(next) = source.source() {
583        last = next.to_string();
584        source = next;
585    }
586    last
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use std::collections::HashMap;
593    use std::io::Write;
594
595    fn config_from(yaml: &str) -> Config {
596        Config::parse_text(yaml, Path::new("/tmp"), "yaml").expect("fixture parses")
597    }
598
599    fn find<'a>(probes: &'a [Probe], key: &str) -> Option<&'a Probe> {
600        probes.iter().find(|p| p.key == key)
601    }
602
603    /// A server that answers `/v2/languages` with `codes`, and 404 elsewhere.
604    ///
605    /// Written out rather than mocked because the probe's whole claim is that
606    /// a real request reached a real socket; a stubbed client would leave the
607    /// URL handling, the timeout and the JSON shape untested.
608    async fn fake_languagetool(codes: &[(&str, &str)]) -> (String, tokio::task::JoinHandle<()>) {
609        let body = format!(
610            "[{}]",
611            codes
612                .iter()
613                .map(|(code, long)| format!(
614                    r#"{{"name":"Test","code":"{code}","longCode":"{long}"}}"#
615                ))
616                .collect::<Vec<_>>()
617                .join(",")
618        );
619        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
620            .await
621            .expect("bind an ephemeral port");
622        let url = format!("http://{}", listener.local_addr().expect("local addr"));
623        let handle = tokio::spawn(async move {
624            while let Ok((mut socket, _)) = listener.accept().await {
625                use tokio::io::{AsyncReadExt, AsyncWriteExt};
626                let mut buffer = vec![0u8; 2048];
627                let read = socket.read(&mut buffer).await.unwrap_or(0);
628                let request = String::from_utf8_lossy(&buffer[..read]).to_string();
629                let response = if request.contains("/v2/languages") {
630                    format!(
631                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
632                         Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
633                        body.len()
634                    )
635                } else {
636                    "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
637                        .to_string()
638                };
639                let _ = socket.write_all(response.as_bytes()).await;
640                let _ = socket.shutdown().await;
641            }
642        });
643        (url, handle)
644    }
645
646    #[test]
647    fn the_worst_of_two_outcomes_is_the_one_that_should_worry_the_reader() {
648        assert_eq!(ProbeStatus::Ok.worst(ProbeStatus::Down), ProbeStatus::Down);
649        assert_eq!(ProbeStatus::Down.worst(ProbeStatus::Ok), ProbeStatus::Down);
650        assert_eq!(
651            ProbeStatus::Degraded.worst(ProbeStatus::Ok),
652            ProbeStatus::Degraded
653        );
654        assert_eq!(
655            ProbeStatus::Down.worst(ProbeStatus::Degraded),
656            ProbeStatus::Down
657        );
658        // Skipped loses to everything: an engine that is off contributes
659        // nothing to a line that has something else to say.
660        assert_eq!(ProbeStatus::Skipped.worst(ProbeStatus::Ok), ProbeStatus::Ok);
661    }
662
663    #[test]
664    fn a_server_offering_a_bare_tag_serves_the_regional_one() {
665        let served: BTreeSet<String> = ["en", "de-de"].iter().map(|s| (*s).to_string()).collect();
666        assert!(serves(&served, "en-US"));
667        assert!(serves(&served, "de-DE"));
668        assert!(!serves(&served, "fr"));
669        // Nothing asked for is nothing to complain about.
670        assert!(serves(&served, ""));
671    }
672
673    #[tokio::test]
674    async fn a_disabled_engine_is_skipped_and_not_called_broken() {
675        let config = config_from("engines:\n  vale: false\n  proselint: false\n");
676        let probes = probe_config(&config, Path::new("/tmp")).await;
677        assert_eq!(
678            find(&probes, "engines.vale").map(|p| p.status),
679            Some(ProbeStatus::Skipped)
680        );
681        assert_eq!(
682            find(&probes, "engines.proselint").map(|p| p.status),
683            Some(ProbeStatus::Skipped)
684        );
685    }
686
687    #[tokio::test]
688    async fn harper_is_always_available_because_it_is_built_in() {
689        let config = config_from("engines:\n  harper: true\n");
690        let probes = probe_harper(&config).await;
691        assert_eq!(
692            find(&probes, "engines.harper").map(|p| p.status),
693            Some(ProbeStatus::Ok)
694        );
695    }
696
697    #[tokio::test]
698    async fn a_misspelled_harper_linter_is_reported_against_its_own_key() {
699        let mut config = config_from("engines:\n  harper: true\n");
700        config
701            .engines
702            .harper
703            .linters
704            .insert("LongSentance".to_string(), false);
705        let probes = probe_harper(&config).await;
706        let probe = find(&probes, "engines.harper.linters.LongSentance")
707            .expect("the unknown linter is reported");
708        assert_eq!(probe.status, ProbeStatus::Down);
709        assert!(probe.detail.contains("LongSentance"));
710    }
711
712    #[tokio::test]
713    async fn a_real_harper_linter_is_not_reported() {
714        let mut config = config_from("engines:\n  harper: true\n");
715        let mut linters = HashMap::new();
716        linters.insert("LongSentences".to_string(), false);
717        config.engines.harper.linters = linters;
718        let probes = probe_harper(&config).await;
719        assert!(
720            !probes
721                .iter()
722                .any(|p| p.key.starts_with("engines.harper.linters.")),
723            "a linter Harper does have should produce nothing: {probes:#?}"
724        );
725    }
726
727    #[tokio::test]
728    async fn a_languagetool_url_that_is_not_a_url_is_reported_without_a_request() {
729        let config = config_from(
730            "engines:\n  languagetool:\n    enabled: true\n    url: \"localhost:8010\"\n",
731        );
732        let probes = probe_languagetool(&config).await;
733        assert_eq!(
734            find(&probes, "engines.languagetool.url").map(|p| p.status),
735            Some(ProbeStatus::Down)
736        );
737    }
738
739    #[tokio::test]
740    async fn a_languagetool_that_answers_is_reported_with_what_it_serves() {
741        let (url, server) = fake_languagetool(&[("en", "en-US")]).await;
742        let config = config_from(&format!(
743            "engines:\n  languagetool:\n    enabled: true\n    url: \"{url}\"\n  \
744             spell_language: \"en-US\"\n"
745        ));
746        let probes = probe_languagetool(&config).await;
747        server.abort();
748
749        let block = find(&probes, "engines.languagetool").expect("the block is reported");
750        assert_eq!(block.status, ProbeStatus::Ok);
751        assert!(
752            block.detail.contains("serving 1 languages"),
753            "{}",
754            block.detail
755        );
756        assert_eq!(
757            find(&probes, "engines.languagetool.url").map(|p| p.status),
758            Some(ProbeStatus::Ok)
759        );
760    }
761
762    #[tokio::test]
763    async fn a_languagetool_up_but_without_the_workspace_language_is_degraded() {
764        let (url, server) = fake_languagetool(&[("de", "de-DE")]).await;
765        let config = config_from(&format!(
766            "engines:\n  languagetool:\n    enabled: true\n    url: \"{url}\"\n  \
767             spell_language: \"en-US\"\n"
768        ));
769        let probes = probe_languagetool(&config).await;
770        server.abort();
771
772        // Reachability and usefulness are different answers, and the block
773        // carries both: the reader needs to know it connected *and* that the
774        // connection buys nothing here.
775        assert!(
776            probes
777                .iter()
778                .any(|p| p.key == "engines.languagetool" && p.status == ProbeStatus::Degraded),
779            "{probes:#?}"
780        );
781    }
782
783    #[tokio::test]
784    async fn a_languagetool_that_refuses_the_connection_says_so_against_the_url() {
785        // Bound and dropped, so the port is one nothing is listening on.
786        let port = {
787            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
788            listener.local_addr().unwrap().port()
789        };
790        let config = config_from(&format!(
791            "engines:\n  languagetool:\n    enabled: true\n    \
792             url: \"http://127.0.0.1:{port}\"\n"
793        ));
794        let probes = probe_languagetool(&config).await;
795        let probe = find(&probes, "engines.languagetool.url").expect("the url is reported");
796        assert_eq!(probe.status, ProbeStatus::Down);
797        assert!(probe.detail.contains("v2/languages"), "{}", probe.detail);
798    }
799
800    #[test]
801    fn a_config_path_that_does_not_exist_names_the_path() {
802        let probe = readable_file(
803            "engines.vale.config",
804            "vale",
805            "/definitely/not/here/.vale.ini",
806            "Vale config",
807        );
808        assert_eq!(probe.status, ProbeStatus::Down);
809        assert!(probe.detail.contains("/definitely/not/here/.vale.ini"));
810    }
811
812    #[test]
813    fn a_path_with_spaces_is_reported_whole() {
814        // The shape a Windows path takes. A message that stopped at the first
815        // space would name a file nobody wrote.
816        let probe = readable_file(
817            "engines.vale.config",
818            "vale",
819            "C:/Program Files/vale/.vale.ini",
820            "Vale config",
821        );
822        assert_eq!(probe.status, ProbeStatus::Down);
823        assert!(
824            probe.detail.contains("C:/Program Files/vale/.vale.ini"),
825            "{}",
826            probe.detail
827        );
828    }
829
830    #[test]
831    fn a_path_with_non_ascii_is_reported_whole() {
832        let probe = readable_file(
833            "engines.vale.config",
834            "vale",
835            "/tmp/café—ü/.vale.ini",
836            "Vale config",
837        );
838        assert_eq!(probe.status, ProbeStatus::Down);
839        assert!(probe.detail.contains("café—ü"), "{}", probe.detail);
840    }
841
842    #[tokio::test]
843    async fn a_config_whose_paths_are_odd_still_answers() {
844        // The probe has to report rather than fail: a path that cannot be
845        // opened is the ordinary case it exists to describe.
846        let config = config_from(
847            "engines:\n  vale:\n    enabled: true\n    config: \"C:/Program Files/x y/.vale.ini\"\n",
848        );
849        let probes = probe_config(&config, Path::new("/tmp")).await;
850        assert!(
851            probes.iter().any(|p| p.key == "engines.vale"),
852            "{probes:#?}"
853        );
854    }
855
856    #[test]
857    fn a_config_path_that_is_a_directory_says_so() {
858        let dir = tempfile::tempdir().expect("tempdir");
859        let probe = readable_file(
860            "engines.vale.config",
861            "vale",
862            &dir.path().to_string_lossy(),
863            "Vale config",
864        );
865        assert_eq!(probe.status, ProbeStatus::Down);
866        assert!(probe.detail.contains("is a directory"));
867    }
868
869    #[test]
870    fn a_readable_config_path_is_ok() {
871        let dir = tempfile::tempdir().expect("tempdir");
872        let path = dir.path().join(".vale.ini");
873        let mut file = std::fs::File::create(&path).expect("create");
874        writeln!(file, "StylesPath = styles").expect("write");
875        let probe = readable_file(
876            "engines.vale.config",
877            "vale",
878            &path.to_string_lossy(),
879            "Vale config",
880        );
881        assert_eq!(probe.status, ProbeStatus::Ok);
882    }
883
884    #[tokio::test]
885    async fn harper_alone_cannot_serve_a_language_it_does_not_read() {
886        let config = config_from("engines:\n  harper: true\n  spell_language: \"de-DE\"\n");
887        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
888        let probe = find(&probes, "engines.spell_language").expect("reported");
889        assert_eq!(probe.status, ProbeStatus::Down);
890        assert!(probe.detail.contains("de-DE"), "{}", probe.detail);
891    }
892
893    #[tokio::test]
894    async fn harper_serves_english_on_its_own() {
895        let config = config_from("engines:\n  harper: true\n  spell_language: \"en-GB\"\n");
896        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
897        let probe = find(&probes, "engines.spell_language").expect("reported");
898        assert_eq!(probe.status, ProbeStatus::Ok);
899        assert!(probe.detail.contains("Harper"), "{}", probe.detail);
900    }
901
902    #[tokio::test]
903    async fn an_empty_spell_language_is_reported_as_such() {
904        let config = config_from("engines:\n  spell_language: \"\"\n");
905        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
906        assert_eq!(
907            find(&probes, "engines.spell_language").map(|p| p.status),
908            Some(ProbeStatus::Down)
909        );
910    }
911
912    #[tokio::test]
913    async fn a_missing_binary_is_reported_as_an_install_not_a_config_error() {
914        let error = binary_version("definitely-not-a-real-binary-xyz", &["--version"])
915            .await
916            .expect_err("a missing binary is an error");
917        assert!(error.contains("not on PATH"), "{error}");
918    }
919}