use crate::cache::ResultCache;
use crate::checker::{Diagnostic, EngineHealth, Severity};
use crate::config::Config;
use crate::engines::hunspell::HunspellEngine;
use crate::engines::{
Engine, ExternalEngine, HarperEngine, LanguageToolEngine, ProselintEngine, ValeEngine,
WasmEngine, engine_handles_extension, engine_supports_language, is_unsupported_language,
};
use crate::packs::PackRegistry;
use crate::prose::ProseUnit;
use crate::rules::RuleNormalizer;
use anyhow::Result;
use std::collections::HashMap;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::{debug, warn};
#[derive(Debug, Clone, Default)]
pub struct CheckContext {
pub extension: Option<String>,
}
impl CheckContext {
#[must_use]
pub fn for_path(path: Option<&std::path::Path>) -> Self {
Self {
extension: path
.and_then(std::path::Path::extension)
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase),
}
}
}
#[derive(Default)]
struct EngineHealthTracker {
consecutive_failures: u32,
last_error: Option<String>,
last_success: Option<Instant>,
last_success_epoch_ms: u64,
}
pub struct Orchestrator {
engines: Vec<Box<dyn Engine + Send>>,
normalizer: RuleNormalizer,
config: Config,
engine_health: HashMap<String, EngineHealthTracker>,
results: ResultCache,
}
impl Orchestrator {
#[must_use]
pub fn new(config: Config) -> Self {
let mut orchestrator = Self {
engines: Vec::new(),
normalizer: RuleNormalizer::new(),
results: ResultCache::new(config.performance.result_cache_entries),
config,
engine_health: HashMap::new(),
};
orchestrator.initialize_engines();
orchestrator
}
fn initialize_engines(&mut self) {
self.engines.clear();
let hpm = self.config.performance.high_performance_mode;
if self.config.engines.harper.enabled {
self.engines
.push(Box::new(HarperEngine::new(&self.config.engines.harper)));
}
if !hpm {
if self.config.engines.languagetool.enabled {
self.engines.push(Box::new(LanguageToolEngine::new(
&self.config.engines.languagetool,
)));
}
if self.config.engines.vale.enabled {
self.engines.push(Box::new(ValeEngine::new(
self.config.engines.vale.config.clone(),
)));
}
if self.config.engines.proselint.enabled {
self.engines.push(Box::new(ProselintEngine::new(
self.config.engines.proselint.config.clone(),
)));
}
if self.config.engines.hunspell.enabled {
let hunspell = &self.config.engines.hunspell;
self.engines.push(Box::new(HunspellEngine::new(
PackRegistry::for_hunspell(hunspell),
hunspell.languages.clone(),
)));
}
for provider in &self.config.engines.external {
self.engines.push(Box::new(ExternalEngine::new(
provider.name.clone(),
provider.command.clone(),
provider.args.clone(),
provider.extensions.clone(),
provider.languages.clone(),
)));
}
for wasm_plugin in &self.config.engines.wasm_plugins {
match WasmEngine::new(
wasm_plugin.name.clone(),
std::path::PathBuf::from(&wasm_plugin.path),
wasm_plugin.extensions.clone(),
wasm_plugin.languages.clone(),
) {
Ok(engine) => self.engines.push(Box::new(engine)),
Err(e) => warn!(
plugin = %wasm_plugin.name,
path = %wasm_plugin.path,
"Failed to load WASM plugin: {e}"
),
}
}
}
}
pub fn update_config(&mut self, config: Config) {
self.results = ResultCache::new(config.performance.result_cache_entries);
self.config = config;
self.initialize_engines();
}
#[must_use]
pub const fn get_config(&self) -> &Config {
&self.config
}
#[must_use]
pub fn engine_health_report(&self) -> Vec<EngineHealth> {
self.engine_health
.iter()
.map(|(name, tracker)| {
let status = if tracker.consecutive_failures == 0 {
"ok"
} else if tracker.consecutive_failures <= 2 {
"degraded"
} else {
"down"
};
EngineHealth {
name: name.clone(),
status: status.to_string(),
consecutive_failures: tracker.consecutive_failures,
last_error: tracker.last_error.clone().unwrap_or_default(),
last_success_epoch_ms: tracker.last_success_epoch_ms,
}
})
.collect()
}
pub async fn check(&mut self, text: &str, language: &str) -> Result<Vec<Diagnostic>> {
let texts = [text.to_string()];
let mut batch = self.check_batch(&texts, language).await?;
Ok(batch.pop().unwrap_or_default())
}
pub async fn check_units(&mut self, units: &[ProseUnit]) -> Result<Vec<Vec<Diagnostic>>> {
self.check_units_in(units, &CheckContext::default()).await
}
pub async fn check_units_in(
&mut self,
units: &[ProseUnit],
context: &CheckContext,
) -> Result<Vec<Vec<Diagnostic>>> {
let mut groups: Vec<(&str, Vec<usize>)> = Vec::new();
for (idx, unit) in units.iter().enumerate() {
match groups.iter_mut().find(|(lang, _)| *lang == unit.language) {
Some((_, slots)) => slots.push(idx),
None => groups.push((&unit.language, vec![idx])),
}
}
let mut out: Vec<Vec<Diagnostic>> = vec![Vec::new(); units.len()];
for (language, slots) in groups {
let texts: Vec<String> = slots.iter().map(|&i| units[i].text.clone()).collect();
let checked = self.check_batch_in(&texts, language, context).await?;
for (&slot, diagnostics) in slots.iter().zip(checked) {
out[slot] = diagnostics;
}
}
Ok(out)
}
#[allow(clippy::too_many_lines)]
pub async fn check_batch(
&mut self,
texts: &[String],
language: &str,
) -> Result<Vec<Vec<Diagnostic>>> {
self.check_batch_in(texts, language, &CheckContext::default())
.await
}
#[allow(clippy::too_many_lines)]
pub async fn check_batch_in(
&mut self,
texts: &[String],
language: &str,
context: &CheckContext,
) -> Result<Vec<Vec<Diagnostic>>> {
let max = self.config.performance.max_file_size;
let skipped: Vec<bool> = texts.iter().map(|t| max > 0 && t.len() > max).collect();
let subset: Option<Vec<String>> = skipped.iter().any(|&s| s).then(|| {
texts
.iter()
.zip(&skipped)
.filter(|&(_, &s)| !s)
.map(|(t, _)| t.clone())
.collect()
});
let batch: &[String] = subset.as_deref().unwrap_or(texts);
let spell_language = language.to_string();
let mut per_text: Vec<Vec<Diagnostic>> = vec![Vec::new(); batch.len()];
let mut engines_ran = 0u32;
let mut engine_failures: Vec<String> = Vec::new();
let installable = crate::packs::catalogue::find(&spell_language).is_some();
for engine in &mut self.engines {
let engine_name = engine.name();
if !engine_supports_language(engine.as_ref(), &spell_language) {
continue;
}
if !engine_handles_extension(engine.as_ref(), context.extension.as_deref()) {
continue;
}
let mut results: Vec<Option<Result<Vec<Diagnostic>>>> = Vec::with_capacity(batch.len());
let mut misses: Vec<String> = Vec::new();
let mut miss_slots: Vec<usize> = Vec::new();
for (slot, text) in batch.iter().enumerate() {
let cached = self.results.get(engine_name, &spell_language, text);
if cached.is_none() {
miss_slots.push(slot);
misses.push(text.clone());
}
results.push(cached.map(Ok));
}
let hits = batch.len() - misses.len();
let fresh = if misses.is_empty() {
Vec::new()
} else {
engine.check_many(&misses, &spell_language).await
};
for (&slot, result) in miss_slots.iter().zip(fresh) {
if let Ok(ref diagnostics) = result {
self.results.put(
engine_name,
&spell_language,
&batch[slot],
diagnostics.clone(),
);
}
results[slot] = Some(result);
}
let results: Vec<Result<Vec<Diagnostic>>> = results
.into_iter()
.map(|slot| slot.unwrap_or_else(|| Ok(Vec::new())))
.collect();
if !results.is_empty() && results.iter().all(is_unsupported_language) {
debug!(
engine = engine_name,
language = %spell_language,
"Engine cannot check this language"
);
continue;
}
engines_ran += 1;
debug!(
engine = engine_name,
hits,
misses = miss_slots.len(),
"Result cache"
);
if misses.is_empty() {
adopt_results(&self.normalizer, &self.config, &mut per_text, results);
continue;
}
let first_error = results.iter().find_map(|r| r.as_ref().err());
let failed = results.iter().filter(|r| r.is_err()).count();
if failed > 0 && failed < results.len() {
warn!(
engine = engine_name,
failed,
total = results.len(),
"Some texts went unchecked; their diagnostics are missing"
);
}
let tracker = self
.engine_health
.entry(engine_name.to_string())
.or_default();
match first_error {
Some(e) if failed == results.len() => {
tracker.consecutive_failures += 1;
tracker.last_error = Some(e.to_string());
warn!(engine = engine_name, "Engine error: {e}");
engine_failures.push(format!("{engine_name}: {e}"));
}
_ => {
tracker.consecutive_failures = 0;
tracker.last_error = None;
tracker.last_success = Some(Instant::now());
#[allow(clippy::cast_possible_truncation)]
{
tracker.last_success_epoch_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
as u64;
}
}
}
adopt_results(&self.normalizer, &self.config, &mut per_text, results);
}
for all_diagnostics in &mut per_text {
if engines_ran == 0 && all_diagnostics.is_empty() {
all_diagnostics.push(Diagnostic {
start_byte: 0,
end_byte: 0,
message: format!(
"No enabled engine reads \"{spell_language}\", \
so this passage went unchecked."
),
suggestions: Vec::new(),
rule_id: "languagecheck.no-provider".to_string(),
severity: Severity::Information as i32,
unified_id: "languagecheck.no-provider".to_string(),
confidence: 1.0,
language: spell_language.clone(),
pack_installable: installable,
});
} else if !engine_failures.is_empty() && all_diagnostics.is_empty() {
all_diagnostics.push(Diagnostic {
start_byte: 0,
end_byte: 0,
message: format!(
"This passage went unchecked: {}",
engine_failures.join("; ")
),
suggestions: Vec::new(),
rule_id: "languagecheck.engine-error".to_string(),
severity: Severity::Warning as i32,
unified_id: "languagecheck.engine-error".to_string(),
confidence: 1.0,
language: spell_language.clone(),
pack_installable: false,
});
}
*all_diagnostics = merge_duplicates(std::mem::take(all_diagnostics));
}
if subset.is_none() {
return Ok(per_text);
}
let mut checked = per_text.into_iter();
Ok(skipped
.into_iter()
.map(|s| {
if s {
Vec::new()
} else {
checked.next().unwrap_or_default()
}
})
.collect())
}
}
const fn severity_rank(severity: i32) -> u8 {
match severity {
3 => 3, 2 => 2, 1 => 1, _ => 0, }
}
fn merge_duplicates(diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
let mut merged: Vec<Diagnostic> = Vec::with_capacity(diagnostics.len());
let mut contributions: Vec<Vec<Vec<String>>> = Vec::new();
let mut index: HashMap<(u32, u32, String), usize> = HashMap::new();
for mut diagnostic in diagnostics {
let key = (
diagnostic.start_byte,
diagnostic.end_byte,
diagnostic.unified_id.clone(),
);
let suggestions = std::mem::take(&mut diagnostic.suggestions);
if let Some(&slot) = index.get(&key) {
if severity_rank(diagnostic.severity) > severity_rank(merged[slot].severity) {
merged[slot].severity = diagnostic.severity;
}
contributions[slot].push(suggestions);
} else {
index.insert(key, merged.len());
merged.push(diagnostic);
contributions.push(vec![suggestions]);
}
}
for (slot, from_each_engine) in merged.iter_mut().zip(contributions) {
slot.suggestions = interleave_suggestions(&from_each_engine);
}
merged
}
fn interleave_suggestions(from_each_engine: &[Vec<String>]) -> Vec<String> {
let deepest = from_each_engine.iter().map(Vec::len).max().unwrap_or(0);
let mut out = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for round in 0..deepest {
for engine in from_each_engine {
if let Some(suggestion) = engine.get(round)
&& seen.insert(suggestion.as_str())
{
out.push(suggestion.clone());
}
}
}
out
}
fn adopt_results(
normalizer: &RuleNormalizer,
config: &Config,
per_text: &mut [Vec<Diagnostic>],
results: Vec<Result<Vec<Diagnostic>>>,
) {
for (slot, result) in per_text.iter_mut().zip(results) {
let Ok(mut diagnostics) = result else {
continue;
};
for d in &mut diagnostics {
let provider = if d.rule_id.starts_with("harper") {
"harper"
} else if d.rule_id.starts_with("hunspell.") {
"hunspell"
} else if d.rule_id.starts_with("vale.") {
"vale"
} else if d.rule_id.starts_with("proselint.") {
"proselint"
} else if d.rule_id.starts_with("wasm.") {
"wasm"
} else if d.rule_id.starts_with("external.") {
"external"
} else {
"languagetool"
};
d.unified_id = normalizer.normalize(provider, &d.rule_id);
if let Some(severity) = crate::rules::default_severity(&d.unified_id) {
d.severity = severity;
}
if let Some(severity) = rule_override_severity(config, &d.rule_id, &d.unified_id) {
d.severity = severity;
}
}
diagnostics.retain(|d| d.severity != -1);
slot.extend(diagnostics);
}
}
fn rule_override_severity(config: &Config, rule_id: &str, unified_id: &str) -> Option<i32> {
let rule_config = config
.rules
.get(rule_id)
.or_else(|| config.rules.get(unified_id))?;
let severity = rule_config.severity.as_ref()?;
match severity.to_lowercase().as_str() {
"error" => Some(Severity::Error as i32),
"warning" => Some(Severity::Warning as i32),
"info" => Some(Severity::Information as i32),
"hint" => Some(Severity::Hint as i32),
"off" => Some(-1),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::config::RuleConfig;
fn config_with_rule(key: &str, severity: &str) -> Config {
let mut config = Config::default();
config.rules.insert(
key.to_string(),
RuleConfig {
severity: Some(severity.to_string()),
},
);
config
}
#[test]
fn override_matches_native_rule_id() {
let config = config_with_rule("languagetool.ARROWS", "off");
assert_eq!(
rule_override_severity(&config, "languagetool.ARROWS", "style.unknown"),
Some(-1)
);
}
#[test]
fn override_matches_unified_id() {
let config = config_with_rule("typography.capitalization", "off");
assert_eq!(
rule_override_severity(
&config,
"languagetool.UPPERCASE_SENTENCE_START",
"typography.capitalization"
),
Some(-1)
);
}
#[test]
fn override_absent_returns_none() {
let config = config_with_rule("languagetool.OTHER", "off");
assert_eq!(
rule_override_severity(&config, "languagetool.ARROWS", "style.unknown"),
None
);
}
#[test]
fn override_maps_named_severities() {
let config = config_with_rule("languagetool.ARROWS", "Error");
assert_eq!(
rule_override_severity(&config, "languagetool.ARROWS", "x"),
Some(Severity::Error as i32)
);
}
struct EchoEngine;
#[async_trait::async_trait]
impl Engine for EchoEngine {
fn name(&self) -> &'static str {
"external"
}
async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
Ok(vec![Diagnostic {
start_byte: 0,
end_byte: 0,
message: text.to_string(),
suggestions: Vec::new(),
rule_id: "external.echo".to_string(),
severity: Severity::Warning as i32,
unified_id: String::new(),
confidence: 1.0,
language: String::new(),
pack_installable: false,
}])
}
}
#[derive(Default)]
struct CountingEngine {
seen: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Engine for CountingEngine {
fn name(&self) -> &'static str {
"external"
}
async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
self.seen.fetch_add(1, Ordering::SeqCst);
Ok(vec![Diagnostic {
start_byte: 0,
end_byte: 0,
message: text.to_string(),
suggestions: Vec::new(),
rule_id: "external.echo".to_string(),
severity: Severity::Warning as i32,
unified_id: String::new(),
confidence: 1.0,
language: String::new(),
pack_installable: false,
}])
}
}
fn orchestrator_with_echo(config: Config) -> Orchestrator {
let mut orchestrator = Orchestrator::new(config);
orchestrator.engines = vec![Box::new(EchoEngine)];
orchestrator
}
fn messages(batch: &[Vec<Diagnostic>]) -> Vec<Option<&str>> {
batch
.iter()
.map(|d| d.first().map(|d| d.message.as_str()))
.collect()
}
#[tokio::test]
async fn check_batch_returns_one_result_per_text_in_order() {
let mut orchestrator = orchestrator_with_echo(Config::default());
let texts = ["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
let batch = orchestrator.check_batch(&texts, "en-US").await.unwrap();
assert_eq!(
messages(&batch),
vec![Some("alpha"), Some("beta"), Some("gamma")]
);
}
#[tokio::test]
async fn check_batch_keeps_a_slot_for_oversized_texts() {
let mut config = Config::default();
config.performance.max_file_size = 5;
let mut orchestrator = orchestrator_with_echo(config);
let texts = [
"ok".to_string(),
"far too long".to_string(),
"fine".to_string(),
];
let batch = orchestrator.check_batch(&texts, "en-US").await.unwrap();
assert_eq!(messages(&batch), vec![Some("ok"), None, Some("fine")]);
}
#[tokio::test]
async fn check_is_the_single_text_case_of_check_batch() {
let mut orchestrator = orchestrator_with_echo(Config::default());
let diagnostics = orchestrator.check("solo", "en-US").await.unwrap();
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message, "solo");
}
#[tokio::test]
async fn check_batch_marks_an_engine_healthy_when_it_answers() {
let mut orchestrator = orchestrator_with_echo(Config::default());
let texts = ["one".to_string(), "two".to_string()];
orchestrator.check_batch(&texts, "en-US").await.unwrap();
let health = orchestrator.engine_health_report();
assert_eq!(health.len(), 1);
assert_eq!(health[0].status, "ok");
}
fn orchestrator_with_counter(cache_entries: usize) -> (Orchestrator, Arc<AtomicUsize>) {
let mut config = Config::default();
config.performance.result_cache_entries = cache_entries;
let seen = Arc::new(AtomicUsize::new(0));
let mut orchestrator = Orchestrator::new(config);
orchestrator.engines = vec![Box::new(CountingEngine {
seen: Arc::clone(&seen),
})];
(orchestrator, seen)
}
#[tokio::test]
async fn only_the_changed_text_goes_back_to_the_engine() {
let (mut orchestrator, seen) = orchestrator_with_counter(64);
let first = ["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
orchestrator.check_batch(&first, "en-US").await.unwrap();
assert_eq!(seen.load(Ordering::SeqCst), 3);
let second = [
"alpha".to_string(),
"beta!".to_string(),
"gamma".to_string(),
];
let batch = orchestrator.check_batch(&second, "en-US").await.unwrap();
assert_eq!(seen.load(Ordering::SeqCst), 4);
assert_eq!(
messages(&batch),
vec![Some("alpha"), Some("beta!"), Some("gamma")]
);
}
#[tokio::test]
async fn a_disabled_cache_rechecks_everything() {
let (mut orchestrator, seen) = orchestrator_with_counter(0);
let texts = ["alpha".to_string(), "beta".to_string()];
orchestrator.check_batch(&texts, "en-US").await.unwrap();
orchestrator.check_batch(&texts, "en-US").await.unwrap();
assert_eq!(seen.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn a_config_change_drops_every_cached_answer() {
let (mut orchestrator, seen) = orchestrator_with_counter(64);
let texts = ["alpha".to_string()];
orchestrator.check_batch(&texts, "en-US").await.unwrap();
let mut config = Config::default();
config.performance.result_cache_entries = 64;
orchestrator.update_config(config);
orchestrator.engines = vec![Box::new(CountingEngine {
seen: Arc::clone(&seen),
})];
orchestrator.check_batch(&texts, "en-US").await.unwrap();
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
struct DecliningEngine;
#[async_trait::async_trait]
impl Engine for DecliningEngine {
fn name(&self) -> &'static str {
"languagetool"
}
async fn check(&mut self, _text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
Err(anyhow::Error::new(crate::engines::UnsupportedLanguage {
engine: "languagetool",
language: language_id.to_string(),
}))
}
}
struct CustomEnglishEngine;
#[async_trait::async_trait]
impl Engine for CustomEnglishEngine {
fn name(&self) -> &'static str {
"external"
}
fn supported_languages(&self) -> Vec<String> {
vec!["en".to_string()]
}
async fn check(&mut self, _text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
Ok(Vec::new())
}
}
struct FailingEngine;
#[async_trait::async_trait]
impl Engine for FailingEngine {
fn name(&self) -> &'static str {
"hunspell"
}
async fn check(&mut self, _text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
Err(anyhow::anyhow!(
"he_IL.dic is not a dictionary this checker can read"
))
}
}
fn at(span: (u32, u32), rule: &str, severity: i32, suggestions: &[&str]) -> Diagnostic {
Diagnostic {
start_byte: span.0,
end_byte: span.1,
message: format!("from {rule}"),
suggestions: suggestions.iter().map(|s| (*s).to_string()).collect(),
rule_id: rule.to_string(),
severity,
unified_id: "spelling.typo".to_string(),
confidence: 0.8,
language: String::new(),
pack_installable: false,
}
}
const ERROR: i32 = 3;
const WARNING: i32 = 2;
const HINT: i32 = 4;
#[test]
fn two_engines_reporting_the_same_thing_become_one() {
let merged = merge_duplicates(vec![
at((0, 5), "harper.Spelling", WARNING, &["definitely"]),
at((0, 5), "hunspell.spelling", WARNING, &["definitely"]),
]);
assert_eq!(merged.len(), 1);
assert_eq!(
merged[0].rule_id, "harper.Spelling",
"the first engine's report survives"
);
}
#[test]
fn the_merged_report_keeps_the_highest_severity() {
let merged = merge_duplicates(vec![
at((0, 5), "harper.Spelling", WARNING, &[]),
at((0, 5), "languagetool.MORFOLOGIK_RULE_EN_US", ERROR, &[]),
]);
assert_eq!(merged[0].severity, ERROR);
}
#[test]
fn a_hint_does_not_outrank_an_error() {
let merged = merge_duplicates(vec![
at((0, 5), "a.rule", ERROR, &[]),
at((0, 5), "b.rule", HINT, &[]),
]);
assert_eq!(merged[0].severity, ERROR);
let other_way = merge_duplicates(vec![
at((0, 5), "a.rule", HINT, &[]),
at((0, 5), "b.rule", ERROR, &[]),
]);
assert_eq!(other_way[0].severity, ERROR);
}
#[test]
fn suggestions_are_taken_one_from_each_engine_in_turn() {
let merged = merge_duplicates(vec![
at((0, 5), "harper.Spelling", WARNING, &["h1", "h2", "h3"]),
at((0, 5), "hunspell.spelling", WARNING, &["u1", "u2"]),
at((0, 5), "languagetool.X", WARNING, &["l1"]),
]);
assert_eq!(
merged[0].suggestions,
vec!["h1", "u1", "l1", "h2", "u2", "h3"],
"each engine's best pick comes before any engine's second"
);
}
#[test]
fn one_engines_long_tail_does_not_bury_the_others() {
let many: Vec<String> = (0..50).map(|i| format!("lt{i}")).collect();
let many_refs: Vec<&str> = many.iter().map(String::as_str).collect();
let merged = merge_duplicates(vec![
at((0, 5), "languagetool.X", WARNING, &many_refs),
at((0, 5), "hunspell.spelling", WARNING, &["u1"]),
at((0, 5), "harper.Spelling", WARNING, &["h1"]),
]);
assert_eq!(
&merged[0].suggestions[..3],
&["lt0", "u1", "h1"],
"the first three slots are one per engine, not three from one"
);
}
#[test]
fn the_same_suggestion_from_two_engines_is_offered_once() {
let merged = merge_duplicates(vec![
at(
(0, 5),
"harper.Spelling",
WARNING,
&["definitely", "definite"],
),
at(
(0, 5),
"hunspell.spelling",
WARNING,
&["definitely", "defiantly"],
),
]);
assert_eq!(
merged[0].suggestions,
vec!["definitely", "definite", "defiantly"]
);
}
#[test]
fn a_different_span_is_a_different_diagnostic() {
let merged = merge_duplicates(vec![
at((0, 5), "harper.Spelling", WARNING, &[]),
at((0, 6), "hunspell.spelling", WARNING, &[]),
]);
assert_eq!(merged.len(), 2);
}
#[test]
fn a_different_rule_at_the_same_span_is_a_different_diagnostic() {
let mut grammar = at((0, 5), "languagetool.X", WARNING, &[]);
grammar.unified_id = "grammar.agreement".to_string();
let merged = merge_duplicates(vec![at((0, 5), "harper.Spelling", WARNING, &[]), grammar]);
assert_eq!(merged.len(), 2);
}
#[test]
fn merging_preserves_the_order_diagnostics_arrived_in() {
let merged = merge_duplicates(vec![
at((10, 15), "a.rule", WARNING, &[]),
at((0, 5), "b.rule", WARNING, &[]),
at((10, 15), "c.rule", WARNING, &[]),
]);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].start_byte, 10, "first seen stays first");
assert_eq!(merged[1].start_byte, 0);
}
#[tokio::test]
async fn an_engine_that_fails_outright_says_so_on_the_document() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(FailingEngine)];
let batch = orchestrator
.check_batch(&["shalom".to_string()], "he")
.await
.unwrap();
assert_eq!(batch[0].len(), 1, "{:?}", batch[0]);
assert_eq!(batch[0][0].unified_id, "languagecheck.engine-error");
assert!(
batch[0][0].message.contains("he_IL.dic"),
"the report must name what broke: {}",
batch[0][0].message
);
}
#[tokio::test]
async fn a_failure_does_not_mask_another_engines_findings() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(FailingEngine), Box::new(CountingEngine::default())];
let batch = orchestrator
.check_batch(&["alpha".to_string()], "en-US")
.await
.unwrap();
assert!(
!batch[0]
.iter()
.any(|d| d.unified_id == "languagecheck.engine-error"),
"{:?}",
batch[0]
);
}
#[tokio::test]
async fn a_language_the_engine_cannot_read_is_reported_not_passed() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(DecliningEngine)];
let texts = ["\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}".to_string()];
let batch = orchestrator.check_batch(&texts, "he").await.unwrap();
assert_eq!(batch[0][0].unified_id, "languagecheck.no-provider");
assert!(batch[0][0].message.contains("he"));
}
#[tokio::test]
async fn a_custom_checker_that_speaks_the_language_stops_the_notice() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(CustomEnglishEngine)];
let batch = orchestrator
.check_batch(&["alpha beta".to_string()], "en-US")
.await
.unwrap();
assert!(
batch[0].is_empty(),
"a checker that speaks the language answered for it: {:?}",
batch[0]
);
}
#[tokio::test]
async fn a_custom_checker_only_covers_what_it_declared() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(CustomEnglishEngine)];
let batch = orchestrator
.check_batch(&["\u{5e9}\u{5dc}\u{5d5}\u{5dd}".to_string()], "he")
.await
.unwrap();
assert_eq!(batch[0][0].unified_id, "languagecheck.no-provider");
assert_eq!(batch[0][0].language, "he");
}
#[tokio::test]
async fn declining_a_language_does_not_mark_the_engine_unhealthy() {
let mut orchestrator = Orchestrator::new(Config::default());
orchestrator.engines = vec![Box::new(DecliningEngine)];
orchestrator
.check_batch(&["shalom".to_string()], "he")
.await
.unwrap();
assert!(
orchestrator.engine_health_report().is_empty(),
"health should not record a language gap as a failure"
);
}
}