Skip to main content

lang_check/
orchestrator.rs

1use crate::cache::ResultCache;
2use crate::checker::{Diagnostic, EngineHealth, Severity};
3use crate::config::Config;
4use crate::engines::hunspell::HunspellEngine;
5use crate::engines::{
6    Engine, ExternalEngine, HarperEngine, LanguageToolEngine, ProselintEngine, ValeEngine,
7    WasmEngine, engine_handles_extension, engine_supports_language, is_unsupported_language,
8};
9use crate::packs::PackRegistry;
10use crate::prose::ProseUnit;
11use crate::rules::RuleNormalizer;
12use anyhow::Result;
13use std::collections::HashMap;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15use tracing::{debug, warn};
16
17/// What the engines are told about the document a check came from.
18///
19/// Separate from [`ProseUnit`], which describes one range: an extension is a
20/// fact about the file, and every range in it shares one.
21#[derive(Debug, Clone, Default)]
22pub struct CheckContext {
23    /// The document's file extension, without the dot.
24    pub extension: Option<String>,
25}
26
27impl CheckContext {
28    /// The context for a document at `path`.
29    #[must_use]
30    pub fn for_path(path: Option<&std::path::Path>) -> Self {
31        Self {
32            extension: path
33                .and_then(std::path::Path::extension)
34                .and_then(|e| e.to_str())
35                .map(str::to_ascii_lowercase),
36        }
37    }
38}
39
40#[derive(Default)]
41struct EngineHealthTracker {
42    consecutive_failures: u32,
43    last_error: Option<String>,
44    last_success: Option<Instant>,
45    last_success_epoch_ms: u64,
46}
47
48pub struct Orchestrator {
49    engines: Vec<Box<dyn Engine + Send>>,
50    normalizer: RuleNormalizer,
51    config: Config,
52    engine_health: HashMap<String, EngineHealthTracker>,
53    results: ResultCache,
54}
55
56impl Orchestrator {
57    #[must_use]
58    pub fn new(config: Config) -> Self {
59        let mut orchestrator = Self {
60            engines: Vec::new(),
61            normalizer: RuleNormalizer::new(),
62            results: ResultCache::new(config.performance.result_cache_entries),
63            config,
64            engine_health: HashMap::new(),
65        };
66
67        orchestrator.initialize_engines();
68        orchestrator
69    }
70
71    fn initialize_engines(&mut self) {
72        self.engines.clear();
73        let hpm = self.config.performance.high_performance_mode;
74
75        if self.config.engines.harper.enabled {
76            self.engines
77                .push(Box::new(HarperEngine::new(&self.config.engines.harper)));
78        }
79
80        // In HPM, skip LanguageTool and external providers for speed
81        if !hpm {
82            if self.config.engines.languagetool.enabled {
83                self.engines.push(Box::new(LanguageToolEngine::new(
84                    &self.config.engines.languagetool,
85                )));
86            }
87
88            if self.config.engines.vale.enabled {
89                self.engines.push(Box::new(ValeEngine::new(
90                    self.config.engines.vale.config.clone(),
91                )));
92            }
93
94            if self.config.engines.proselint.enabled {
95                self.engines.push(Box::new(ProselintEngine::new(
96                    self.config.engines.proselint.config.clone(),
97                )));
98            }
99
100            if self.config.engines.hunspell.enabled {
101                let hunspell = &self.config.engines.hunspell;
102                self.engines.push(Box::new(HunspellEngine::new(
103                    PackRegistry::for_hunspell(hunspell),
104                    hunspell.languages.clone(),
105                )));
106            }
107
108            for provider in &self.config.engines.external {
109                self.engines.push(Box::new(ExternalEngine::new(
110                    provider.name.clone(),
111                    provider.command.clone(),
112                    provider.args.clone(),
113                    provider.extensions.clone(),
114                    provider.languages.clone(),
115                )));
116            }
117
118            for wasm_plugin in &self.config.engines.wasm_plugins {
119                match WasmEngine::new(
120                    wasm_plugin.name.clone(),
121                    std::path::PathBuf::from(&wasm_plugin.path),
122                    wasm_plugin.extensions.clone(),
123                    wasm_plugin.languages.clone(),
124                ) {
125                    Ok(engine) => self.engines.push(Box::new(engine)),
126                    Err(e) => warn!(
127                        plugin = %wasm_plugin.name,
128                        path = %wasm_plugin.path,
129                        "Failed to load WASM plugin: {e}"
130                    ),
131                }
132            }
133        }
134    }
135
136    pub fn update_config(&mut self, config: Config) {
137        // Every cached answer was produced by the engines that are about to be
138        // rebuilt, under settings that may have just changed.
139        self.results = ResultCache::new(config.performance.result_cache_entries);
140        self.config = config;
141        self.initialize_engines();
142        // Preserve health state across config changes — don't clear engine_health
143    }
144
145    #[must_use]
146    pub const fn get_config(&self) -> &Config {
147        &self.config
148    }
149
150    /// Returns health status for each engine that has been tracked.
151    #[must_use]
152    pub fn engine_health_report(&self) -> Vec<EngineHealth> {
153        self.engine_health
154            .iter()
155            .map(|(name, tracker)| {
156                let status = if tracker.consecutive_failures == 0 {
157                    "ok"
158                } else if tracker.consecutive_failures <= 2 {
159                    "degraded"
160                } else {
161                    "down"
162                };
163                EngineHealth {
164                    name: name.clone(),
165                    status: status.to_string(),
166                    consecutive_failures: tracker.consecutive_failures,
167                    last_error: tracker.last_error.clone().unwrap_or_default(),
168                    last_success_epoch_ms: tracker.last_success_epoch_ms,
169                }
170            })
171            .collect()
172    }
173
174    /// Check a single text. Thin wrapper over [`Self::check_batch`].
175    pub async fn check(&mut self, text: &str, language: &str) -> Result<Vec<Diagnostic>> {
176        let texts = [text.to_string()];
177        let mut batch = self.check_batch(&texts, language).await?;
178        Ok(batch.pop().unwrap_or_default())
179    }
180
181    /// What the engines need to know about the document, as opposed to about
182    /// one range of it.
183    ///
184    /// Only the extension so far. A provider that declares which markup it
185    /// parses has to be told what it is being handed, and a prose range on its
186    /// own does not say.
187    ///
188    /// Check one document's prose, each range in the language it is written in.
189    ///
190    /// A document is not always in one language — a French thesis quoting
191    /// English, a German paper with an English abstract — and the engines have
192    /// to be told which, or `LanguageTool` reports every correctly spelled word
193    /// as a misspelling. Ranges are grouped by language and each group checked
194    /// on its own, so a group still batches.
195    pub async fn check_units(&mut self, units: &[ProseUnit]) -> Result<Vec<Vec<Diagnostic>>> {
196        self.check_units_in(units, &CheckContext::default()).await
197    }
198
199    /// [`Self::check_units`], told what document the prose came from.
200    pub async fn check_units_in(
201        &mut self,
202        units: &[ProseUnit],
203        context: &CheckContext,
204    ) -> Result<Vec<Vec<Diagnostic>>> {
205        // First-seen order, so a single-language document keeps its one batch
206        // and the common case is unchanged.
207        let mut groups: Vec<(&str, Vec<usize>)> = Vec::new();
208        for (idx, unit) in units.iter().enumerate() {
209            match groups.iter_mut().find(|(lang, _)| *lang == unit.language) {
210                Some((_, slots)) => slots.push(idx),
211                None => groups.push((&unit.language, vec![idx])),
212            }
213        }
214
215        let mut out: Vec<Vec<Diagnostic>> = vec![Vec::new(); units.len()];
216        for (language, slots) in groups {
217            let texts: Vec<String> = slots.iter().map(|&i| units[i].text.clone()).collect();
218            let checked = self.check_batch_in(&texts, language, context).await?;
219            for (&slot, diagnostics) in slots.iter().zip(checked) {
220                out[slot] = diagnostics;
221            }
222        }
223        Ok(out)
224    }
225
226    /// Check independent texts, all in `language`, returning one diagnostic
227    /// list per input, in order.
228    ///
229    /// Batching exists so latency-bound engines can overlap their work: checking
230    /// range-by-range turns a page of prose into hundreds of serial round trips.
231    /// Engines still run one after another — most hold mutable state — but each
232    /// sees the whole batch and decides its own concurrency (see
233    /// [`Engine::check_many`]).
234    #[allow(clippy::too_many_lines)]
235    pub async fn check_batch(
236        &mut self,
237        texts: &[String],
238        language: &str,
239    ) -> Result<Vec<Vec<Diagnostic>>> {
240        self.check_batch_in(texts, language, &CheckContext::default())
241            .await
242    }
243
244    /// [`Self::check_batch`], told what document the prose came from.
245    #[allow(clippy::too_many_lines)]
246    pub async fn check_batch_in(
247        &mut self,
248        texts: &[String],
249        language: &str,
250        context: &CheckContext,
251    ) -> Result<Vec<Vec<Diagnostic>>> {
252        // Texts over max_file_size are skipped, but keep their slot so the
253        // caller's results still line up one-to-one with its inputs.
254        let max = self.config.performance.max_file_size;
255        let skipped: Vec<bool> = texts.iter().map(|t| max > 0 && t.len() > max).collect();
256        let subset: Option<Vec<String>> = skipped.iter().any(|&s| s).then(|| {
257            texts
258                .iter()
259                .zip(&skipped)
260                .filter(|&(_, &s)| !s)
261                .map(|(t, _)| t.clone())
262                .collect()
263        });
264        let batch: &[String] = subset.as_deref().unwrap_or(texts);
265
266        let spell_language = language.to_string();
267        let mut per_text: Vec<Vec<Diagnostic>> = vec![Vec::new(); batch.len()];
268        let mut engines_ran = 0u32;
269        // Engines that ran and failed outright, with why. A failure that only
270        // reaches the log leaves the prose looking checked and clean, which is
271        // the worst of the three possible answers.
272        let mut engine_failures: Vec<String> = Vec::new();
273        // Decided once: whether this language is one a pack can be fetched for.
274        let installable = crate::packs::catalogue::find(&spell_language).is_some();
275
276        for engine in &mut self.engines {
277            let engine_name = engine.name();
278
279            // Skip engines that don't support the configured language
280            if !engine_supports_language(engine.as_ref(), &spell_language) {
281                continue;
282            }
283
284            // And ones that parse a markup this document is not written in.
285            // Declining on either axis is declining, not failing: the engine
286            // has nothing to say here, which is what no-provider reports.
287            if !engine_handles_extension(engine.as_ref(), context.extension.as_deref()) {
288                continue;
289            }
290
291            // Serve what this engine has already answered for and ask only for
292            // the rest. On a keystroke that is one range out of a hundred.
293            let mut results: Vec<Option<Result<Vec<Diagnostic>>>> = Vec::with_capacity(batch.len());
294            let mut misses: Vec<String> = Vec::new();
295            let mut miss_slots: Vec<usize> = Vec::new();
296            for (slot, text) in batch.iter().enumerate() {
297                let cached = self.results.get(engine_name, &spell_language, text);
298                if cached.is_none() {
299                    miss_slots.push(slot);
300                    misses.push(text.clone());
301                }
302                results.push(cached.map(Ok));
303            }
304            let hits = batch.len() - misses.len();
305
306            let fresh = if misses.is_empty() {
307                Vec::new()
308            } else {
309                engine.check_many(&misses, &spell_language).await
310            };
311            for (&slot, result) in miss_slots.iter().zip(fresh) {
312                if let Ok(ref diagnostics) = result {
313                    self.results.put(
314                        engine_name,
315                        &spell_language,
316                        &batch[slot],
317                        diagnostics.clone(),
318                    );
319                }
320                results[slot] = Some(result);
321            }
322            let results: Vec<Result<Vec<Diagnostic>>> = results
323                .into_iter()
324                .map(|slot| slot.unwrap_or_else(|| Ok(Vec::new())))
325                .collect();
326
327            // An engine that has no dictionary for this language has not run,
328            // has not failed, and must not count towards either. LanguageTool
329            // answers 400 for a language it was never built with -- Hebrew, for
330            // one -- and reading that as a failure reports a healthy server as
331            // unreachable.
332            if !results.is_empty() && results.iter().all(is_unsupported_language) {
333                debug!(
334                    engine = engine_name,
335                    language = %spell_language,
336                    "Engine cannot check this language"
337                );
338                continue;
339            }
340            engines_ran += 1;
341
342            debug!(
343                engine = engine_name,
344                hits,
345                misses = miss_slots.len(),
346                "Result cache"
347            );
348
349            // A batch is healthy if the engine answered at all: one bad text
350            // among hundreds says nothing about reachability, whereas a down
351            // server fails every one of them. A partial failure still costs the
352            // user diagnostics on those texts, so say so rather than only
353            // flipping health when everything breaks.
354            //
355            // A batch served entirely from cache says nothing either way, so it
356            // leaves health alone rather than reporting a server it never
357            // reached as reachable.
358            if misses.is_empty() {
359                adopt_results(&self.normalizer, &self.config, &mut per_text, results);
360                continue;
361            }
362
363            let first_error = results.iter().find_map(|r| r.as_ref().err());
364            let failed = results.iter().filter(|r| r.is_err()).count();
365            if failed > 0 && failed < results.len() {
366                warn!(
367                    engine = engine_name,
368                    failed,
369                    total = results.len(),
370                    "Some texts went unchecked; their diagnostics are missing"
371                );
372            }
373            let tracker = self
374                .engine_health
375                .entry(engine_name.to_string())
376                .or_default();
377            match first_error {
378                Some(e) if failed == results.len() => {
379                    tracker.consecutive_failures += 1;
380                    tracker.last_error = Some(e.to_string());
381                    warn!(engine = engine_name, "Engine error: {e}");
382                    engine_failures.push(format!("{engine_name}: {e}"));
383                }
384                _ => {
385                    tracker.consecutive_failures = 0;
386                    tracker.last_error = None;
387                    tracker.last_success = Some(Instant::now());
388                    #[allow(clippy::cast_possible_truncation)]
389                    {
390                        tracker.last_success_epoch_ms = SystemTime::now()
391                            .duration_since(UNIX_EPOCH)
392                            .unwrap_or_default()
393                            .as_millis()
394                            as u64;
395                    }
396                }
397            }
398
399            adopt_results(&self.normalizer, &self.config, &mut per_text, results);
400        }
401
402        for all_diagnostics in &mut per_text {
403            // Say so when nothing could read this language, rather than
404            // leaving prose that was never checked looking clean. Covers both
405            // an engine that declines the language up front (Harper outside
406            // English) and one that declines it on the wire (LanguageTool
407            // without the language module).
408            if engines_ran == 0 && all_diagnostics.is_empty() {
409                all_diagnostics.push(Diagnostic {
410                    start_byte: 0,
411                    end_byte: 0,
412                    message: format!(
413                        "No enabled engine reads \"{spell_language}\", \
414                         so this passage went unchecked."
415                    ),
416                    suggestions: Vec::new(),
417                    rule_id: "languagecheck.no-provider".to_string(),
418                    severity: Severity::Information as i32,
419                    unified_id: "languagecheck.no-provider".to_string(),
420                    confidence: 1.0,
421                    // Carried rather than left for the editor to recover from
422                    // the message: a tag parsed out of prose is exactly where a
423                    // spurious install prompt would come from.
424                    language: spell_language.clone(),
425                    pack_installable: installable,
426                });
427            } else if !engine_failures.is_empty() && all_diagnostics.is_empty() {
428                // Every engine that took this on failed. Without this the
429                // passage reads as clean and the reason is a line in a log the
430                // user has no reason to open -- a broken dictionary would
431                // silently stop checking a language and look like success.
432                all_diagnostics.push(Diagnostic {
433                    start_byte: 0,
434                    end_byte: 0,
435                    message: format!(
436                        "This passage went unchecked: {}",
437                        engine_failures.join("; ")
438                    ),
439                    suggestions: Vec::new(),
440                    rule_id: "languagecheck.engine-error".to_string(),
441                    severity: Severity::Warning as i32,
442                    unified_id: "languagecheck.engine-error".to_string(),
443                    confidence: 1.0,
444                    language: spell_language.clone(),
445                    // A pack that is present and broken is not fixed by
446                    // fetching it again, so nothing is offered.
447                    pack_installable: false,
448                });
449            }
450            *all_diagnostics = merge_duplicates(std::mem::take(all_diagnostics));
451        }
452
453        if subset.is_none() {
454            return Ok(per_text);
455        }
456        // Re-expand: skipped texts get an empty result in their original slot.
457        let mut checked = per_text.into_iter();
458        Ok(skipped
459            .into_iter()
460            .map(|s| {
461                if s {
462                    Vec::new()
463                } else {
464                    checked.next().unwrap_or_default()
465                }
466            })
467            .collect())
468    }
469}
470
471/// How severe a severity is, which is not the order the numbers are in.
472///
473/// `SEVERITY_HINT` is 4 and the *least* severe of the four, so comparing the
474/// raw values makes a hint outrank an error.
475const fn severity_rank(severity: i32) -> u8 {
476    match severity {
477        3 => 3, // error
478        2 => 2, // warning
479        1 => 1, // information
480        _ => 0, // hint, and anything unrecognised
481    }
482}
483
484/// Fold diagnostics that several engines reported for the same thing into one.
485///
486/// Two engines agreeing is the common case for spelling -- Harper, Hunspell
487/// and `LanguageTool` all normalise a misspelling to `spelling.typo` -- and
488/// what used to happen was that the later ones were dropped outright. Dropping
489/// lost three things: the higher severity (`LanguageTool` calls a misspelling
490/// an error, Harper a warning, and Harper registers first, so the error went),
491/// every suggestion the loser had, and any severity override keyed on the
492/// loser's native rule id.
493///
494/// Identity is still the exact `(start, end, unified_id)` triple. Engines
495/// tokenise independently, so spans that differ by a byte are left as separate
496/// diagnostics rather than guessed at.
497fn merge_duplicates(diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
498    let mut merged: Vec<Diagnostic> = Vec::with_capacity(diagnostics.len());
499    // One list of suggestions per contributing engine, per surviving slot.
500    let mut contributions: Vec<Vec<Vec<String>>> = Vec::new();
501    let mut index: HashMap<(u32, u32, String), usize> = HashMap::new();
502
503    for mut diagnostic in diagnostics {
504        let key = (
505            diagnostic.start_byte,
506            diagnostic.end_byte,
507            diagnostic.unified_id.clone(),
508        );
509        let suggestions = std::mem::take(&mut diagnostic.suggestions);
510        if let Some(&slot) = index.get(&key) {
511            if severity_rank(diagnostic.severity) > severity_rank(merged[slot].severity) {
512                merged[slot].severity = diagnostic.severity;
513            }
514            contributions[slot].push(suggestions);
515        } else {
516            index.insert(key, merged.len());
517            merged.push(diagnostic);
518            contributions.push(vec![suggestions]);
519        }
520    }
521
522    for (slot, from_each_engine) in merged.iter_mut().zip(contributions) {
523        slot.suggestions = interleave_suggestions(&from_each_engine);
524    }
525    merged
526}
527
528/// Take one suggestion from each engine in turn, best first.
529///
530/// Concatenating instead would be worse than dropping: `LanguageTool` alone
531/// answers a French misspelling with eighty replacements, which would bury
532/// every other engine's first guess under one engine's tail -- and the tail is
533/// what fills a nine-slot `SpeedFix` panel or pushes "add to dictionary" off the
534/// bottom of the lightbulb. Round-robin puts each engine's best pick at the
535/// top and keeps each engine's own ranking within its own picks.
536fn interleave_suggestions(from_each_engine: &[Vec<String>]) -> Vec<String> {
537    let deepest = from_each_engine.iter().map(Vec::len).max().unwrap_or(0);
538    let mut out = Vec::new();
539    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
540    for round in 0..deepest {
541        for engine in from_each_engine {
542            if let Some(suggestion) = engine.get(round)
543                && seen.insert(suggestion.as_str())
544            {
545                out.push(suggestion.clone());
546            }
547        }
548    }
549    out
550}
551
552/// Normalise each engine answer and fold it into the per-text results.
553///
554/// A free function because the caller holds `&mut self.engines` for the length
555/// of the engine loop, so a method on `self` would not borrow-check; it needs
556/// the normaliser and the config, which are disjoint fields.
557fn adopt_results(
558    normalizer: &RuleNormalizer,
559    config: &Config,
560    per_text: &mut [Vec<Diagnostic>],
561    results: Vec<Result<Vec<Diagnostic>>>,
562) {
563    for (slot, result) in per_text.iter_mut().zip(results) {
564        let Ok(mut diagnostics) = result else {
565            continue;
566        };
567
568        for d in &mut diagnostics {
569            let provider = if d.rule_id.starts_with("harper") {
570                "harper"
571            } else if d.rule_id.starts_with("hunspell.") {
572                "hunspell"
573            } else if d.rule_id.starts_with("vale.") {
574                "vale"
575            } else if d.rule_id.starts_with("proselint.") {
576                "proselint"
577            } else if d.rule_id.starts_with("wasm.") {
578                "wasm"
579            } else if d.rule_id.starts_with("external.") {
580                "external"
581            } else {
582                "languagetool"
583            };
584            d.unified_id = normalizer.normalize(provider, &d.rule_id);
585
586            // The category decides the severity, not the engine -- otherwise
587            // the same misspelling is an error from LanguageTool and a warning
588            // from Harper, and the colour of a squiggle says which engine
589            // noticed it instead of how serious it is.
590            if let Some(severity) = crate::rules::default_severity(&d.unified_id) {
591                d.severity = severity;
592            }
593
594            // Applied after, so a configured override beats the default.
595            if let Some(severity) = rule_override_severity(config, &d.rule_id, &d.unified_id) {
596                d.severity = severity;
597            }
598        }
599
600        diagnostics.retain(|d| d.severity != -1);
601        slot.extend(diagnostics);
602    }
603}
604
605/// Resolve a configured severity override for a diagnostic.
606///
607/// Overrides may be keyed by the **native** rule id shown on the diagnostic
608/// (e.g. `languagetool.ARROWS`, what the "Deactivate rule" action writes) or by
609/// the **unified** category id (e.g. `typography.capitalization`). The native id
610/// is matched first. Returns the new severity (`-1` marks the diagnostic for
611/// removal), or `None` when there is no applicable override.
612fn rule_override_severity(config: &Config, rule_id: &str, unified_id: &str) -> Option<i32> {
613    let rule_config = config
614        .rules
615        .get(rule_id)
616        .or_else(|| config.rules.get(unified_id))?;
617    let severity = rule_config.severity.as_ref()?;
618    match severity.to_lowercase().as_str() {
619        "error" => Some(Severity::Error as i32),
620        "warning" => Some(Severity::Warning as i32),
621        "info" => Some(Severity::Information as i32),
622        "hint" => Some(Severity::Hint as i32),
623        "off" => Some(-1),
624        _ => None,
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use std::sync::Arc;
631    use std::sync::atomic::{AtomicUsize, Ordering};
632
633    use super::*;
634    use crate::config::RuleConfig;
635
636    fn config_with_rule(key: &str, severity: &str) -> Config {
637        let mut config = Config::default();
638        config.rules.insert(
639            key.to_string(),
640            RuleConfig {
641                severity: Some(severity.to_string()),
642            },
643        );
644        config
645    }
646
647    #[test]
648    fn override_matches_native_rule_id() {
649        // The user keys the override by the native id (what diagnostics show).
650        let config = config_with_rule("languagetool.ARROWS", "off");
651        assert_eq!(
652            rule_override_severity(&config, "languagetool.ARROWS", "style.unknown"),
653            Some(-1)
654        );
655    }
656
657    #[test]
658    fn override_matches_unified_id() {
659        let config = config_with_rule("typography.capitalization", "off");
660        assert_eq!(
661            rule_override_severity(
662                &config,
663                "languagetool.UPPERCASE_SENTENCE_START",
664                "typography.capitalization"
665            ),
666            Some(-1)
667        );
668    }
669
670    #[test]
671    fn override_absent_returns_none() {
672        let config = config_with_rule("languagetool.OTHER", "off");
673        assert_eq!(
674            rule_override_severity(&config, "languagetool.ARROWS", "style.unknown"),
675            None
676        );
677    }
678
679    #[test]
680    fn override_maps_named_severities() {
681        let config = config_with_rule("languagetool.ARROWS", "Error");
682        assert_eq!(
683            rule_override_severity(&config, "languagetool.ARROWS", "x"),
684            Some(Severity::Error as i32)
685        );
686    }
687
688    /// Reports one diagnostic per text, carrying that text as its message, so a
689    /// batch's results can be matched back to the inputs that produced them.
690    struct EchoEngine;
691
692    #[async_trait::async_trait]
693    impl Engine for EchoEngine {
694        fn name(&self) -> &'static str {
695            "external"
696        }
697
698        async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
699            Ok(vec![Diagnostic {
700                start_byte: 0,
701                end_byte: 0,
702                message: text.to_string(),
703                suggestions: Vec::new(),
704                rule_id: "external.echo".to_string(),
705                severity: Severity::Warning as i32,
706                unified_id: String::new(),
707                confidence: 1.0,
708                language: String::new(),
709                pack_installable: false,
710            }])
711        }
712    }
713
714    /// Echoes each text and counts how many reached it, so a test can tell a
715    /// cache hit from a re-check.
716    #[derive(Default)]
717    struct CountingEngine {
718        seen: Arc<AtomicUsize>,
719    }
720
721    #[async_trait::async_trait]
722    impl Engine for CountingEngine {
723        fn name(&self) -> &'static str {
724            "external"
725        }
726
727        async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
728            self.seen.fetch_add(1, Ordering::SeqCst);
729            Ok(vec![Diagnostic {
730                start_byte: 0,
731                end_byte: 0,
732                message: text.to_string(),
733                suggestions: Vec::new(),
734                rule_id: "external.echo".to_string(),
735                severity: Severity::Warning as i32,
736                unified_id: String::new(),
737                confidence: 1.0,
738                language: String::new(),
739                pack_installable: false,
740            }])
741        }
742    }
743
744    fn orchestrator_with_echo(config: Config) -> Orchestrator {
745        let mut orchestrator = Orchestrator::new(config);
746        orchestrator.engines = vec![Box::new(EchoEngine)];
747        orchestrator
748    }
749
750    fn messages(batch: &[Vec<Diagnostic>]) -> Vec<Option<&str>> {
751        batch
752            .iter()
753            .map(|d| d.first().map(|d| d.message.as_str()))
754            .collect()
755    }
756
757    #[tokio::test]
758    async fn check_batch_returns_one_result_per_text_in_order() {
759        let mut orchestrator = orchestrator_with_echo(Config::default());
760        let texts = ["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
761        let batch = orchestrator.check_batch(&texts, "en-US").await.unwrap();
762
763        assert_eq!(
764            messages(&batch),
765            vec![Some("alpha"), Some("beta"), Some("gamma")]
766        );
767    }
768
769    #[tokio::test]
770    async fn check_batch_keeps_a_slot_for_oversized_texts() {
771        // Callers zip results against their ranges, so a text skipped for size
772        // must still occupy its position rather than shift everything after it.
773        let mut config = Config::default();
774        config.performance.max_file_size = 5;
775        let mut orchestrator = orchestrator_with_echo(config);
776        let texts = [
777            "ok".to_string(),
778            "far too long".to_string(),
779            "fine".to_string(),
780        ];
781        let batch = orchestrator.check_batch(&texts, "en-US").await.unwrap();
782
783        assert_eq!(messages(&batch), vec![Some("ok"), None, Some("fine")]);
784    }
785
786    #[tokio::test]
787    async fn check_is_the_single_text_case_of_check_batch() {
788        let mut orchestrator = orchestrator_with_echo(Config::default());
789        let diagnostics = orchestrator.check("solo", "en-US").await.unwrap();
790
791        assert_eq!(diagnostics.len(), 1);
792        assert_eq!(diagnostics[0].message, "solo");
793    }
794
795    #[tokio::test]
796    async fn check_batch_marks_an_engine_healthy_when_it_answers() {
797        let mut orchestrator = orchestrator_with_echo(Config::default());
798        let texts = ["one".to_string(), "two".to_string()];
799        orchestrator.check_batch(&texts, "en-US").await.unwrap();
800
801        let health = orchestrator.engine_health_report();
802        assert_eq!(health.len(), 1);
803        assert_eq!(health[0].status, "ok");
804    }
805
806    /// `(orchestrator, how many texts have reached the engine)`
807    fn orchestrator_with_counter(cache_entries: usize) -> (Orchestrator, Arc<AtomicUsize>) {
808        let mut config = Config::default();
809        config.performance.result_cache_entries = cache_entries;
810        let seen = Arc::new(AtomicUsize::new(0));
811        let mut orchestrator = Orchestrator::new(config);
812        orchestrator.engines = vec![Box::new(CountingEngine {
813            seen: Arc::clone(&seen),
814        })];
815        (orchestrator, seen)
816    }
817
818    #[tokio::test]
819    async fn only_the_changed_text_goes_back_to_the_engine() {
820        let (mut orchestrator, seen) = orchestrator_with_counter(64);
821        let first = ["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
822        orchestrator.check_batch(&first, "en-US").await.unwrap();
823        assert_eq!(seen.load(Ordering::SeqCst), 3);
824
825        // The edit a keystroke makes: one range differs, the rest are identical.
826        let second = [
827            "alpha".to_string(),
828            "beta!".to_string(),
829            "gamma".to_string(),
830        ];
831        let batch = orchestrator.check_batch(&second, "en-US").await.unwrap();
832        assert_eq!(seen.load(Ordering::SeqCst), 4);
833        assert_eq!(
834            messages(&batch),
835            vec![Some("alpha"), Some("beta!"), Some("gamma")]
836        );
837    }
838
839    #[tokio::test]
840    async fn a_disabled_cache_rechecks_everything() {
841        let (mut orchestrator, seen) = orchestrator_with_counter(0);
842        let texts = ["alpha".to_string(), "beta".to_string()];
843        orchestrator.check_batch(&texts, "en-US").await.unwrap();
844        orchestrator.check_batch(&texts, "en-US").await.unwrap();
845        assert_eq!(seen.load(Ordering::SeqCst), 4);
846    }
847
848    #[tokio::test]
849    async fn a_config_change_drops_every_cached_answer() {
850        let (mut orchestrator, seen) = orchestrator_with_counter(64);
851        let texts = ["alpha".to_string()];
852        orchestrator.check_batch(&texts, "en-US").await.unwrap();
853
854        let mut config = Config::default();
855        config.performance.result_cache_entries = 64;
856        orchestrator.update_config(config);
857        // update_config rebuilds the engines from config, so put the counting
858        // one back before asking again.
859        orchestrator.engines = vec![Box::new(CountingEngine {
860            seen: Arc::clone(&seen),
861        })];
862
863        orchestrator.check_batch(&texts, "en-US").await.unwrap();
864        assert_eq!(seen.load(Ordering::SeqCst), 2);
865    }
866
867    /// Declines every text, the way `LanguageTool` answers for a language it was
868    /// never built with.
869    struct DecliningEngine;
870
871    #[async_trait::async_trait]
872    impl Engine for DecliningEngine {
873        fn name(&self) -> &'static str {
874            "languagetool"
875        }
876
877        async fn check(&mut self, _text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
878            Err(anyhow::Error::new(crate::engines::UnsupportedLanguage {
879                engine: "languagetool",
880                language: language_id.to_string(),
881            }))
882        }
883    }
884
885    /// A custom checker that named the one language it speaks, the way an
886    /// `engines.external` entry with `languages: ["en"]` does.
887    struct CustomEnglishEngine;
888
889    #[async_trait::async_trait]
890    impl Engine for CustomEnglishEngine {
891        fn name(&self) -> &'static str {
892            "external"
893        }
894
895        fn supported_languages(&self) -> Vec<String> {
896            vec!["en".to_string()]
897        }
898
899        async fn check(&mut self, _text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
900            Ok(Vec::new())
901        }
902    }
903
904    /// Fails every text, the way an engine with a broken dictionary does.
905    struct FailingEngine;
906
907    #[async_trait::async_trait]
908    impl Engine for FailingEngine {
909        fn name(&self) -> &'static str {
910            "hunspell"
911        }
912
913        async fn check(&mut self, _text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
914            Err(anyhow::anyhow!(
915                "he_IL.dic is not a dictionary this checker can read"
916            ))
917        }
918    }
919
920    /// A diagnostic over the same span, from a named engine.
921    fn at(span: (u32, u32), rule: &str, severity: i32, suggestions: &[&str]) -> Diagnostic {
922        Diagnostic {
923            start_byte: span.0,
924            end_byte: span.1,
925            message: format!("from {rule}"),
926            suggestions: suggestions.iter().map(|s| (*s).to_string()).collect(),
927            rule_id: rule.to_string(),
928            severity,
929            unified_id: "spelling.typo".to_string(),
930            confidence: 0.8,
931            language: String::new(),
932            pack_installable: false,
933        }
934    }
935
936    const ERROR: i32 = 3;
937    const WARNING: i32 = 2;
938    const HINT: i32 = 4;
939
940    #[test]
941    fn two_engines_reporting_the_same_thing_become_one() {
942        let merged = merge_duplicates(vec![
943            at((0, 5), "harper.Spelling", WARNING, &["definitely"]),
944            at((0, 5), "hunspell.spelling", WARNING, &["definitely"]),
945        ]);
946        assert_eq!(merged.len(), 1);
947        assert_eq!(
948            merged[0].rule_id, "harper.Spelling",
949            "the first engine's report survives"
950        );
951    }
952
953    #[test]
954    fn the_merged_report_keeps_the_highest_severity() {
955        // LanguageTool calls a misspelling an error and Harper a warning, and
956        // Harper registers first. Dropping the later one dropped the error.
957        let merged = merge_duplicates(vec![
958            at((0, 5), "harper.Spelling", WARNING, &[]),
959            at((0, 5), "languagetool.MORFOLOGIK_RULE_EN_US", ERROR, &[]),
960        ]);
961        assert_eq!(merged[0].severity, ERROR);
962    }
963
964    #[test]
965    fn a_hint_does_not_outrank_an_error() {
966        // SEVERITY_HINT is 4 and the least severe of the four, so comparing
967        // the raw numbers gets this backwards.
968        let merged = merge_duplicates(vec![
969            at((0, 5), "a.rule", ERROR, &[]),
970            at((0, 5), "b.rule", HINT, &[]),
971        ]);
972        assert_eq!(merged[0].severity, ERROR);
973
974        let other_way = merge_duplicates(vec![
975            at((0, 5), "a.rule", HINT, &[]),
976            at((0, 5), "b.rule", ERROR, &[]),
977        ]);
978        assert_eq!(other_way[0].severity, ERROR);
979    }
980
981    #[test]
982    fn suggestions_are_taken_one_from_each_engine_in_turn() {
983        let merged = merge_duplicates(vec![
984            at((0, 5), "harper.Spelling", WARNING, &["h1", "h2", "h3"]),
985            at((0, 5), "hunspell.spelling", WARNING, &["u1", "u2"]),
986            at((0, 5), "languagetool.X", WARNING, &["l1"]),
987        ]);
988        assert_eq!(
989            merged[0].suggestions,
990            vec!["h1", "u1", "l1", "h2", "u2", "h3"],
991            "each engine's best pick comes before any engine's second"
992        );
993    }
994
995    #[test]
996    fn one_engines_long_tail_does_not_bury_the_others() {
997        // The case that makes a plain union worse than dropping: LanguageTool
998        // answers a French misspelling with dozens of replacements, and the
999        // SpeedFix panel only has nine slots.
1000        let many: Vec<String> = (0..50).map(|i| format!("lt{i}")).collect();
1001        let many_refs: Vec<&str> = many.iter().map(String::as_str).collect();
1002        let merged = merge_duplicates(vec![
1003            at((0, 5), "languagetool.X", WARNING, &many_refs),
1004            at((0, 5), "hunspell.spelling", WARNING, &["u1"]),
1005            at((0, 5), "harper.Spelling", WARNING, &["h1"]),
1006        ]);
1007        assert_eq!(
1008            &merged[0].suggestions[..3],
1009            &["lt0", "u1", "h1"],
1010            "the first three slots are one per engine, not three from one"
1011        );
1012    }
1013
1014    #[test]
1015    fn the_same_suggestion_from_two_engines_is_offered_once() {
1016        let merged = merge_duplicates(vec![
1017            at(
1018                (0, 5),
1019                "harper.Spelling",
1020                WARNING,
1021                &["definitely", "definite"],
1022            ),
1023            at(
1024                (0, 5),
1025                "hunspell.spelling",
1026                WARNING,
1027                &["definitely", "defiantly"],
1028            ),
1029        ]);
1030        assert_eq!(
1031            merged[0].suggestions,
1032            vec!["definitely", "definite", "defiantly"]
1033        );
1034    }
1035
1036    #[test]
1037    fn a_different_span_is_a_different_diagnostic() {
1038        // Engines tokenise independently, so a span differing by one byte is
1039        // left alone rather than guessed at.
1040        let merged = merge_duplicates(vec![
1041            at((0, 5), "harper.Spelling", WARNING, &[]),
1042            at((0, 6), "hunspell.spelling", WARNING, &[]),
1043        ]);
1044        assert_eq!(merged.len(), 2);
1045    }
1046
1047    #[test]
1048    fn a_different_rule_at_the_same_span_is_a_different_diagnostic() {
1049        let mut grammar = at((0, 5), "languagetool.X", WARNING, &[]);
1050        grammar.unified_id = "grammar.agreement".to_string();
1051        let merged = merge_duplicates(vec![at((0, 5), "harper.Spelling", WARNING, &[]), grammar]);
1052        assert_eq!(merged.len(), 2);
1053    }
1054
1055    #[test]
1056    fn merging_preserves_the_order_diagnostics_arrived_in() {
1057        let merged = merge_duplicates(vec![
1058            at((10, 15), "a.rule", WARNING, &[]),
1059            at((0, 5), "b.rule", WARNING, &[]),
1060            at((10, 15), "c.rule", WARNING, &[]),
1061        ]);
1062        assert_eq!(merged.len(), 2);
1063        assert_eq!(merged[0].start_byte, 10, "first seen stays first");
1064        assert_eq!(merged[1].start_byte, 0);
1065    }
1066
1067    #[tokio::test]
1068    async fn an_engine_that_fails_outright_says_so_on_the_document() {
1069        // Reported where the user is looking, not only in a log: prose that
1070        // went unchecked because a dictionary is broken must not come back
1071        // indistinguishable from prose that is clean.
1072        let mut orchestrator = Orchestrator::new(Config::default());
1073        orchestrator.engines = vec![Box::new(FailingEngine)];
1074
1075        let batch = orchestrator
1076            .check_batch(&["shalom".to_string()], "he")
1077            .await
1078            .unwrap();
1079        assert_eq!(batch[0].len(), 1, "{:?}", batch[0]);
1080        assert_eq!(batch[0][0].unified_id, "languagecheck.engine-error");
1081        assert!(
1082            batch[0][0].message.contains("he_IL.dic"),
1083            "the report must name what broke: {}",
1084            batch[0][0].message
1085        );
1086    }
1087
1088    #[tokio::test]
1089    async fn a_failure_does_not_mask_another_engines_findings() {
1090        // One engine down must not hide what a working one found, so the
1091        // notice only appears when nothing came back at all.
1092        let mut orchestrator = Orchestrator::new(Config::default());
1093        orchestrator.engines = vec![Box::new(FailingEngine), Box::new(CountingEngine::default())];
1094
1095        let batch = orchestrator
1096            .check_batch(&["alpha".to_string()], "en-US")
1097            .await
1098            .unwrap();
1099        assert!(
1100            !batch[0]
1101                .iter()
1102                .any(|d| d.unified_id == "languagecheck.engine-error"),
1103            "{:?}",
1104            batch[0]
1105        );
1106    }
1107
1108    #[tokio::test]
1109    async fn a_language_the_engine_cannot_read_is_reported_not_passed() {
1110        let mut orchestrator = Orchestrator::new(Config::default());
1111        orchestrator.engines = vec![Box::new(DecliningEngine)];
1112        let texts = ["\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}".to_string()];
1113
1114        let batch = orchestrator.check_batch(&texts, "he").await.unwrap();
1115        assert_eq!(batch[0][0].unified_id, "languagecheck.no-provider");
1116        assert!(batch[0][0].message.contains("he"));
1117    }
1118
1119    #[tokio::test]
1120    async fn a_custom_checker_that_speaks_the_language_stops_the_notice() {
1121        // Someone whose only engine is their own checker, declaring `en`, is
1122        // covered for English -- so the passage must not be reported as
1123        // unchecked, and the editor must not offer them a dictionary for a
1124        // language they already check. Returning nothing is a clean result,
1125        // not an absent one.
1126        let mut orchestrator = Orchestrator::new(Config::default());
1127        orchestrator.engines = vec![Box::new(CustomEnglishEngine)];
1128
1129        let batch = orchestrator
1130            .check_batch(&["alpha beta".to_string()], "en-US")
1131            .await
1132            .unwrap();
1133        assert!(
1134            batch[0].is_empty(),
1135            "a checker that speaks the language answered for it: {:?}",
1136            batch[0]
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn a_custom_checker_only_covers_what_it_declared() {
1142        // The same engine, asked for a language it did not name. It is still
1143        // installed and still useful, which is why the notice says no *enabled
1144        // engine reads this language* and not that nothing is installed.
1145        let mut orchestrator = Orchestrator::new(Config::default());
1146        orchestrator.engines = vec![Box::new(CustomEnglishEngine)];
1147
1148        let batch = orchestrator
1149            .check_batch(&["\u{5e9}\u{5dc}\u{5d5}\u{5dd}".to_string()], "he")
1150            .await
1151            .unwrap();
1152        assert_eq!(batch[0][0].unified_id, "languagecheck.no-provider");
1153        assert_eq!(batch[0][0].language, "he");
1154    }
1155
1156    #[tokio::test]
1157    async fn declining_a_language_does_not_mark_the_engine_unhealthy() {
1158        let mut orchestrator = Orchestrator::new(Config::default());
1159        orchestrator.engines = vec![Box::new(DecliningEngine)];
1160        orchestrator
1161            .check_batch(&["shalom".to_string()], "he")
1162            .await
1163            .unwrap();
1164
1165        // A server that answers "I have no Hebrew" is a server that answered.
1166        assert!(
1167            orchestrator.engine_health_report().is_empty(),
1168            "health should not record a language gap as a failure"
1169        );
1170    }
1171}