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.
372///
373/// Both are reported separately because the fixes are different -- one is an
374/// install, the other is a path in this file.
375async fn probe_vale(config: &Config) -> Vec<Probe> {
376    const ENGINE: &str = "vale";
377    if !config.engines.vale.enabled {
378        return vec![Probe::new(
379            "engines.vale",
380            ENGINE,
381            ProbeStatus::Skipped,
382            "Vale is switched off.",
383        )];
384    }
385
386    let mut probes = match binary_version("vale", &["--version"]).await {
387        Ok(version) => vec![Probe::new(
388            "engines.vale",
389            ENGINE,
390            ProbeStatus::Ok,
391            format!("Found {version} on PATH."),
392        )],
393        Err(why) => {
394            return vec![Probe::new("engines.vale", ENGINE, ProbeStatus::Down, why)];
395        }
396    };
397
398    if let Some(path) = &config.engines.vale.config {
399        probes.push(readable_file(
400            "engines.vale.config",
401            ENGINE,
402            path,
403            "Vale config",
404        ));
405    }
406    probes
407}
408
409/// Proselint, on the same two questions as Vale.
410async fn probe_proselint(config: &Config) -> Vec<Probe> {
411    const ENGINE: &str = "proselint";
412    if !config.engines.proselint.enabled {
413        return vec![Probe::new(
414            "engines.proselint",
415            ENGINE,
416            ProbeStatus::Skipped,
417            "Proselint is switched off.",
418        )];
419    }
420
421    let mut probes = match binary_version("proselint", &["--version"]).await {
422        Ok(version) => vec![Probe::new(
423            "engines.proselint",
424            ENGINE,
425            ProbeStatus::Ok,
426            format!("Found {version} on PATH."),
427        )],
428        Err(why) => {
429            return vec![Probe::new(
430                "engines.proselint",
431                ENGINE,
432                ProbeStatus::Down,
433                why,
434            )];
435        }
436    };
437
438    if let Some(path) = &config.engines.proselint.config {
439        probes.push(readable_file(
440            "engines.proselint.config",
441            ENGINE,
442            path,
443            "Proselint config",
444        ));
445    }
446    probes
447}
448
449/// Whether anything can actually spell-check the configured language.
450///
451/// Harper reads English only, so a workspace set to `de-DE` with Harper alone
452/// is configured to check nothing -- which today produces a clean document
453/// and no explanation.
454async fn probe_spell_language(config: &Config, workspace_root: &Path) -> Vec<Probe> {
455    let tag = config.engines.spell_language.clone();
456    if tag.is_empty() {
457        return vec![Probe::new(
458            "engines.spell_language",
459            "",
460            ProbeStatus::Down,
461            "engines.spell_language is empty. Set it to a BCP-47 tag such as en-US.",
462        )];
463    }
464
465    let base = tag.split('-').next().unwrap_or(&tag).to_lowercase();
466    let mut servers: Vec<String> = Vec::new();
467    if config.engines.harper.enabled && base == "en" {
468        servers.push("Harper".to_string());
469    }
470    if config.engines.languagetool.enabled {
471        servers.push("LanguageTool".to_string());
472    }
473
474    if config.engines.hunspell.enabled {
475        let search = config.engines.hunspell.search_paths.clone();
476        let overrides = config.engines.hunspell.dictionary_paths.clone();
477        let wanted = tag.clone();
478        let root = workspace_root.to_path_buf();
479        let resolved = tokio::task::spawn_blocking(move || {
480            let mut registry = crate::packs::PackRegistry::new();
481            for path in &search {
482                registry = registry.with_search_path(absolute_against(&root, path));
483            }
484            for (language, path) in &overrides {
485                registry = registry.with_override(language, absolute_against(&root, path));
486            }
487            registry.resolve(&wanted).is_ok()
488        })
489        .await
490        .unwrap_or(false);
491        if resolved {
492            servers.push("Hunspell".to_string());
493        }
494    }
495
496    if servers.is_empty() {
497        return vec![Probe::new(
498            "engines.spell_language",
499            "",
500            ProbeStatus::Down,
501            format!(
502                "Nothing enabled here checks \"{tag}\". Harper reads English only; enable \
503                 LanguageTool or install a Hunspell dictionary for it."
504            ),
505        )];
506    }
507
508    vec![Probe::new(
509        "engines.spell_language",
510        "",
511        ProbeStatus::Ok,
512        format!("\"{tag}\" is checked by {}.", servers.join(" and ")),
513    )]
514}
515
516fn absolute_against(root: &Path, value: &str) -> PathBuf {
517    let path = Path::new(value);
518    if path.is_absolute() {
519        path.to_path_buf()
520    } else {
521        root.join(path)
522    }
523}
524
525/// Whether a path in the config names a file that can be opened.
526fn readable_file(key: &str, engine: &str, path: &str, what: &str) -> Probe {
527    match std::fs::metadata(path) {
528        Ok(meta) if meta.is_dir() => Probe::new(
529            key,
530            engine,
531            ProbeStatus::Down,
532            format!("{path} is a directory, not a {what} file."),
533        ),
534        Ok(_) => match std::fs::File::open(path) {
535            Ok(_) => Probe::new(key, engine, ProbeStatus::Ok, format!("Read {path}.")),
536            Err(e) => Probe::new(
537                key,
538                engine,
539                ProbeStatus::Down,
540                format!("{path} exists but could not be opened: {e}"),
541            ),
542        },
543        Err(_) => Probe::new(
544            key,
545            engine,
546            ProbeStatus::Down,
547            format!("No {what} at {path}."),
548        ),
549    }
550}
551
552/// Run `binary --version` and return the first line it prints.
553///
554/// The version is worth having in the hover: "found vale 3.7.1 on PATH"
555/// answers a different question from "vale is installed", and the difference
556/// matters when a config uses a style only newer versions ship.
557async fn binary_version(binary: &str, args: &[&str]) -> Result<String, String> {
558    let mut command = tokio::process::Command::new(binary);
559    command
560        .args(args)
561        .stdin(std::process::Stdio::null())
562        .stdout(std::process::Stdio::piped())
563        .stderr(std::process::Stdio::piped());
564
565    let run = tokio::time::timeout(PROBE_TIMEOUT, command.output()).await;
566    match run {
567        Ok(Ok(output)) => {
568            // Some print the version to stderr, some to stdout, and which one
569            // is not worth depending on.
570            let text = if output.stdout.is_empty() {
571                String::from_utf8_lossy(&output.stderr).to_string()
572            } else {
573                String::from_utf8_lossy(&output.stdout).to_string()
574            };
575            let line = text.lines().next().unwrap_or("").trim().to_string();
576            if line.is_empty() {
577                Ok(binary.to_string())
578            } else {
579                Ok(line)
580            }
581        }
582        Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
583            "{binary} is not on PATH. Install it, or set engines.{binary}.enabled to false."
584        )),
585        Ok(Err(e)) => Err(format!("Could not run {binary}: {e}")),
586        Err(_) => Err(format!(
587            "{binary} did not answer within {}s.",
588            PROBE_TIMEOUT.as_secs()
589        )),
590    }
591}
592
593/// The part of a `reqwest` error worth putting in front of a user.
594///
595/// The `Display` of a transport error is a chain that ends in the useful
596/// sentence and begins with three that are not.
597fn terse_reqwest_error(error: &reqwest::Error) -> String {
598    let mut source: &dyn std::error::Error = error;
599    let mut last = error.to_string();
600    while let Some(next) = source.source() {
601        last = next.to_string();
602        source = next;
603    }
604    last
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use std::collections::HashMap;
611    use std::io::Write;
612
613    fn config_from(yaml: &str) -> Config {
614        Config::parse_text(yaml, Path::new("/tmp"), "yaml").expect("fixture parses")
615    }
616
617    fn find<'a>(probes: &'a [Probe], key: &str) -> Option<&'a Probe> {
618        probes.iter().find(|p| p.key == key)
619    }
620
621    /// A server that answers `/v2/languages` with `codes`, and 404 elsewhere.
622    ///
623    /// Written out rather than mocked because the probe's whole claim is that
624    /// a real request reached a real socket; a stubbed client would leave the
625    /// URL handling, the timeout and the JSON shape untested.
626    async fn fake_languagetool(codes: &[(&str, &str)]) -> (String, tokio::task::JoinHandle<()>) {
627        let body = format!(
628            "[{}]",
629            codes
630                .iter()
631                .map(|(code, long)| format!(
632                    r#"{{"name":"Test","code":"{code}","longCode":"{long}"}}"#
633                ))
634                .collect::<Vec<_>>()
635                .join(",")
636        );
637        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
638            .await
639            .expect("bind an ephemeral port");
640        let url = format!("http://{}", listener.local_addr().expect("local addr"));
641        let handle = tokio::spawn(async move {
642            while let Ok((mut socket, _)) = listener.accept().await {
643                use tokio::io::{AsyncReadExt, AsyncWriteExt};
644                let mut buffer = vec![0u8; 2048];
645                let read = socket.read(&mut buffer).await.unwrap_or(0);
646                let request = String::from_utf8_lossy(&buffer[..read]).to_string();
647                let response = if request.contains("/v2/languages") {
648                    format!(
649                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
650                         Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
651                        body.len()
652                    )
653                } else {
654                    "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
655                        .to_string()
656                };
657                let _ = socket.write_all(response.as_bytes()).await;
658                let _ = socket.shutdown().await;
659            }
660        });
661        (url, handle)
662    }
663
664    #[test]
665    fn the_worst_of_two_outcomes_is_the_one_that_should_worry_the_reader() {
666        assert_eq!(ProbeStatus::Ok.worst(ProbeStatus::Down), ProbeStatus::Down);
667        assert_eq!(ProbeStatus::Down.worst(ProbeStatus::Ok), ProbeStatus::Down);
668        assert_eq!(
669            ProbeStatus::Degraded.worst(ProbeStatus::Ok),
670            ProbeStatus::Degraded
671        );
672        assert_eq!(
673            ProbeStatus::Down.worst(ProbeStatus::Degraded),
674            ProbeStatus::Down
675        );
676        // Skipped loses to everything: an engine that is off contributes
677        // nothing to a line that has something else to say.
678        assert_eq!(ProbeStatus::Skipped.worst(ProbeStatus::Ok), ProbeStatus::Ok);
679    }
680
681    #[test]
682    fn a_server_offering_a_bare_tag_serves_the_regional_one() {
683        let served: BTreeSet<String> = ["en", "de-de"].iter().map(|s| (*s).to_string()).collect();
684        assert!(serves(&served, "en-US"));
685        assert!(serves(&served, "de-DE"));
686        assert!(!serves(&served, "fr"));
687        // Nothing asked for is nothing to complain about.
688        assert!(serves(&served, ""));
689    }
690
691    #[tokio::test]
692    async fn a_disabled_engine_is_skipped_and_not_called_broken() {
693        let config = config_from("engines:\n  vale: false\n  proselint: false\n");
694        let probes = probe_config(&config, Path::new("/tmp")).await;
695        assert_eq!(
696            find(&probes, "engines.vale").map(|p| p.status),
697            Some(ProbeStatus::Skipped)
698        );
699        assert_eq!(
700            find(&probes, "engines.proselint").map(|p| p.status),
701            Some(ProbeStatus::Skipped)
702        );
703    }
704
705    #[tokio::test]
706    async fn harper_is_always_available_because_it_is_built_in() {
707        let config = config_from("engines:\n  harper: true\n");
708        let probes = probe_harper(&config).await;
709        assert_eq!(
710            find(&probes, "engines.harper").map(|p| p.status),
711            Some(ProbeStatus::Ok)
712        );
713    }
714
715    #[tokio::test]
716    async fn a_misspelled_harper_linter_is_reported_against_its_own_key() {
717        let mut config = config_from("engines:\n  harper: true\n");
718        config
719            .engines
720            .harper
721            .linters
722            .insert("LongSentance".to_string(), false);
723        let probes = probe_harper(&config).await;
724        let probe = find(&probes, "engines.harper.linters.LongSentance")
725            .expect("the unknown linter is reported");
726        assert_eq!(probe.status, ProbeStatus::Down);
727        assert!(probe.detail.contains("LongSentance"));
728    }
729
730    #[tokio::test]
731    async fn a_real_harper_linter_is_not_reported() {
732        let mut config = config_from("engines:\n  harper: true\n");
733        let mut linters = HashMap::new();
734        linters.insert("LongSentences".to_string(), false);
735        config.engines.harper.linters = linters;
736        let probes = probe_harper(&config).await;
737        assert!(
738            !probes
739                .iter()
740                .any(|p| p.key.starts_with("engines.harper.linters.")),
741            "a linter Harper does have should produce nothing: {probes:#?}"
742        );
743    }
744
745    #[tokio::test]
746    async fn a_languagetool_url_that_is_not_a_url_is_reported_without_a_request() {
747        let config = config_from(
748            "engines:\n  languagetool:\n    enabled: true\n    url: \"localhost:8010\"\n",
749        );
750        let probes = probe_languagetool(&config).await;
751        assert_eq!(
752            find(&probes, "engines.languagetool.url").map(|p| p.status),
753            Some(ProbeStatus::Down)
754        );
755    }
756
757    #[tokio::test]
758    async fn a_languagetool_that_answers_is_reported_with_what_it_serves() {
759        let (url, server) = fake_languagetool(&[("en", "en-US")]).await;
760        let config = config_from(&format!(
761            "engines:\n  languagetool:\n    enabled: true\n    url: \"{url}\"\n  \
762             spell_language: \"en-US\"\n"
763        ));
764        let probes = probe_languagetool(&config).await;
765        server.abort();
766
767        let block = find(&probes, "engines.languagetool").expect("the block is reported");
768        assert_eq!(block.status, ProbeStatus::Ok);
769        assert!(
770            block.detail.contains("serving 1 languages"),
771            "{}",
772            block.detail
773        );
774        assert_eq!(
775            find(&probes, "engines.languagetool.url").map(|p| p.status),
776            Some(ProbeStatus::Ok)
777        );
778    }
779
780    #[tokio::test]
781    async fn a_languagetool_up_but_without_the_workspace_language_is_degraded() {
782        let (url, server) = fake_languagetool(&[("de", "de-DE")]).await;
783        let config = config_from(&format!(
784            "engines:\n  languagetool:\n    enabled: true\n    url: \"{url}\"\n  \
785             spell_language: \"en-US\"\n"
786        ));
787        let probes = probe_languagetool(&config).await;
788        server.abort();
789
790        // Reachability and usefulness are different answers, and the block
791        // carries both: the reader needs to know it connected *and* that the
792        // connection buys nothing here.
793        assert!(
794            probes
795                .iter()
796                .any(|p| p.key == "engines.languagetool" && p.status == ProbeStatus::Degraded),
797            "{probes:#?}"
798        );
799    }
800
801    #[tokio::test]
802    async fn a_languagetool_that_refuses_the_connection_says_so_against_the_url() {
803        // Bound and dropped, so the port is one nothing is listening on.
804        let port = {
805            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
806            listener.local_addr().unwrap().port()
807        };
808        let config = config_from(&format!(
809            "engines:\n  languagetool:\n    enabled: true\n    \
810             url: \"http://127.0.0.1:{port}\"\n"
811        ));
812        let probes = probe_languagetool(&config).await;
813        let probe = find(&probes, "engines.languagetool.url").expect("the url is reported");
814        assert_eq!(probe.status, ProbeStatus::Down);
815        assert!(probe.detail.contains("v2/languages"), "{}", probe.detail);
816    }
817
818    #[test]
819    fn a_config_path_that_does_not_exist_names_the_path() {
820        let probe = readable_file(
821            "engines.vale.config",
822            "vale",
823            "/definitely/not/here/.vale.ini",
824            "Vale config",
825        );
826        assert_eq!(probe.status, ProbeStatus::Down);
827        assert!(probe.detail.contains("/definitely/not/here/.vale.ini"));
828    }
829
830    #[test]
831    fn a_path_with_spaces_is_reported_whole() {
832        // The shape a Windows path takes. A message that stopped at the first
833        // space would name a file nobody wrote.
834        let probe = readable_file(
835            "engines.vale.config",
836            "vale",
837            "C:/Program Files/vale/.vale.ini",
838            "Vale config",
839        );
840        assert_eq!(probe.status, ProbeStatus::Down);
841        assert!(
842            probe.detail.contains("C:/Program Files/vale/.vale.ini"),
843            "{}",
844            probe.detail
845        );
846    }
847
848    #[test]
849    fn a_path_with_non_ascii_is_reported_whole() {
850        let probe = readable_file(
851            "engines.vale.config",
852            "vale",
853            "/tmp/café—ü/.vale.ini",
854            "Vale config",
855        );
856        assert_eq!(probe.status, ProbeStatus::Down);
857        assert!(probe.detail.contains("café—ü"), "{}", probe.detail);
858    }
859
860    #[tokio::test]
861    async fn a_config_whose_paths_are_odd_still_answers() {
862        // The probe has to report rather than fail: a path that cannot be
863        // opened is the ordinary case it exists to describe.
864        let config = config_from(
865            "engines:\n  vale:\n    enabled: true\n    config: \"C:/Program Files/x y/.vale.ini\"\n",
866        );
867        let probes = probe_config(&config, Path::new("/tmp")).await;
868        assert!(
869            probes.iter().any(|p| p.key == "engines.vale"),
870            "{probes:#?}"
871        );
872    }
873
874    #[test]
875    fn a_config_path_that_is_a_directory_says_so() {
876        let dir = tempfile::tempdir().expect("tempdir");
877        let probe = readable_file(
878            "engines.vale.config",
879            "vale",
880            &dir.path().to_string_lossy(),
881            "Vale config",
882        );
883        assert_eq!(probe.status, ProbeStatus::Down);
884        assert!(probe.detail.contains("is a directory"));
885    }
886
887    #[test]
888    fn a_readable_config_path_is_ok() {
889        let dir = tempfile::tempdir().expect("tempdir");
890        let path = dir.path().join(".vale.ini");
891        let mut file = std::fs::File::create(&path).expect("create");
892        writeln!(file, "StylesPath = styles").expect("write");
893        let probe = readable_file(
894            "engines.vale.config",
895            "vale",
896            &path.to_string_lossy(),
897            "Vale config",
898        );
899        assert_eq!(probe.status, ProbeStatus::Ok);
900    }
901
902    #[tokio::test]
903    async fn harper_alone_cannot_serve_a_language_it_does_not_read() {
904        let config = config_from("engines:\n  harper: true\n  spell_language: \"de-DE\"\n");
905        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
906        let probe = find(&probes, "engines.spell_language").expect("reported");
907        assert_eq!(probe.status, ProbeStatus::Down);
908        assert!(probe.detail.contains("de-DE"), "{}", probe.detail);
909    }
910
911    #[tokio::test]
912    async fn harper_serves_english_on_its_own() {
913        let config = config_from("engines:\n  harper: true\n  spell_language: \"en-GB\"\n");
914        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
915        let probe = find(&probes, "engines.spell_language").expect("reported");
916        assert_eq!(probe.status, ProbeStatus::Ok);
917        assert!(probe.detail.contains("Harper"), "{}", probe.detail);
918    }
919
920    #[tokio::test]
921    async fn an_empty_spell_language_is_reported_as_such() {
922        let config = config_from("engines:\n  spell_language: \"\"\n");
923        let probes = probe_spell_language(&config, Path::new("/tmp")).await;
924        assert_eq!(
925            find(&probes, "engines.spell_language").map(|p| p.status),
926            Some(ProbeStatus::Down)
927        );
928    }
929
930    #[tokio::test]
931    async fn a_missing_binary_is_reported_as_an_install_not_a_config_error() {
932        let error = binary_version("definitely-not-a-real-binary-xyz", &["--version"])
933            .await
934            .expect_err("a missing binary is an error");
935        assert!(error.contains("not on PATH"), "{error}");
936    }
937}