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#[derive(Debug, Clone, Default)]
22pub struct CheckContext {
23 pub extension: Option<String>,
25}
26
27impl CheckContext {
28 #[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 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 self.results = ResultCache::new(config.performance.result_cache_entries);
140 self.config = config;
141 self.initialize_engines();
142 }
144
145 #[must_use]
146 pub const fn get_config(&self) -> &Config {
147 &self.config
148 }
149
150 #[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 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 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 pub async fn check_units_in(
201 &mut self,
202 units: &[ProseUnit],
203 context: &CheckContext,
204 ) -> Result<Vec<Vec<Diagnostic>>> {
205 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 #[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 #[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 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 let mut engine_failures: Vec<String> = Vec::new();
273 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 if !engine_supports_language(engine.as_ref(), &spell_language) {
281 continue;
282 }
283
284 if !engine_handles_extension(engine.as_ref(), context.extension.as_deref()) {
288 continue;
289 }
290
291 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 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 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 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 language: spell_language.clone(),
425 pack_installable: installable,
426 });
427 } else if !engine_failures.is_empty() && all_diagnostics.is_empty() {
428 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 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 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
471const fn severity_rank(severity: i32) -> u8 {
476 match severity {
477 3 => 3, 2 => 2, 1 => 1, _ => 0, }
482}
483
484fn merge_duplicates(diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
498 let mut merged: Vec<Diagnostic> = Vec::with_capacity(diagnostics.len());
499 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
528fn 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
552fn 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 if let Some(severity) = crate::rules::default_severity(&d.unified_id) {
591 d.severity = severity;
592 }
593
594 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
605fn 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
1167 orchestrator.engine_health_report().is_empty(),
1168 "health should not record a language gap as a failure"
1169 );
1170 }
1171}