Skip to main content

lang_check/engines/
mod.rs

1mod proselint;
2mod vale;
3
4pub use proselint::ProselintEngine;
5pub use vale::ValeEngine;
6
7use crate::checker::{Diagnostic, Severity};
8use anyhow::Result;
9use extism::{Manifest, Plugin, Wasm};
10use harper_core::{
11    Dialect, Document, Lrc,
12    linting::{LintGroup, Linter},
13    parsers::Markdown,
14    spell::FstDictionary,
15};
16use serde::Deserialize;
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::sync::Semaphore;
20use tokio::task::JoinSet;
21use tracing::{debug, warn};
22
23#[async_trait::async_trait]
24pub trait Engine {
25    fn name(&self) -> &'static str;
26    async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>>;
27    /// BCP-47 primary subtags this engine supports. Empty = all languages.
28    fn supported_languages(&self) -> Vec<&'static str> {
29        vec![]
30    }
31
32    /// Check a batch of independent texts, returning one result per input in
33    /// order.
34    ///
35    /// A document is checked one prose range at a time, so a single check is
36    /// hundreds of calls. Engines whose work is latency-bound (a network round
37    /// trip, a subprocess spawn) override this to overlap them; the default is
38    /// the same sequential loop callers would write by hand.
39    async fn check_many(
40        &mut self,
41        texts: &[String],
42        language_id: &str,
43    ) -> Vec<Result<Vec<Diagnostic>>> {
44        let mut results = Vec::with_capacity(texts.len());
45        for text in texts {
46            results.push(self.check(text, language_id).await);
47        }
48        results
49    }
50}
51
52/// Returns `true` if `engine` supports the given BCP-47 `lang_tag`.
53///
54/// Matching is on the primary subtag: `"en-US"` matches an engine that
55/// advertises `"en"`. An engine with an empty list is a wildcard (supports all).
56pub fn engine_supports_language(engine: &(dyn Engine + Send), lang_tag: &str) -> bool {
57    let supported = engine.supported_languages();
58    if supported.is_empty() {
59        return true;
60    }
61    let primary = lang_tag.split('-').next().unwrap_or(lang_tag);
62    supported.iter().any(|s| s.eq_ignore_ascii_case(primary))
63}
64
65/// Build a lookup from Unicode-scalar (char) index → UTF-8 byte offset, with a
66/// final entry for the end-of-text index (char count → `text.len()`).
67///
68/// The wire protocol reports diagnostic spans as UTF-8 byte offsets, but some
69/// engines count in `char`s (e.g. Harper, which operates on a `Vec<char>`).
70/// Without this conversion, any multi-byte character (em-dash `—`, accented
71/// letters, …) before a diagnostic shifts every later underline.
72fn char_to_byte_table(text: &str) -> Vec<u32> {
73    #[allow(clippy::cast_possible_truncation)]
74    let mut table: Vec<u32> = text.char_indices().map(|(b, _)| b as u32).collect();
75    #[allow(clippy::cast_possible_truncation)]
76    table.push(text.len() as u32);
77    table
78}
79
80/// Build a lookup from UTF-16 code-unit index → UTF-8 byte offset, with a final
81/// entry for the end-of-text index.
82///
83/// Used for engines that report UTF-16 offsets (e.g. `LanguageTool`, a Java
84/// service whose char offsets are UTF-16 code units). Astral chars occupy two
85/// UTF-16 units; both map to the char's starting byte.
86fn utf16_to_byte_table(text: &str) -> Vec<u32> {
87    let mut table: Vec<u32> = Vec::with_capacity(text.len() + 1);
88    for (byte_idx, ch) in text.char_indices() {
89        #[allow(clippy::cast_possible_truncation)]
90        let b = byte_idx as u32;
91        for _ in 0..ch.len_utf16() {
92            table.push(b);
93        }
94    }
95    #[allow(clippy::cast_possible_truncation)]
96    table.push(text.len() as u32);
97    table
98}
99
100/// Clamp-safe lookup into an offset table built by [`char_to_byte_table`] or
101/// [`utf16_to_byte_table`]. Out-of-range indices map to end-of-text.
102fn lookup_offset(table: &[u32], idx: usize) -> u32 {
103    table
104        .get(idx)
105        .copied()
106        .unwrap_or_else(|| table.last().copied().unwrap_or(0))
107}
108
109pub struct HarperEngine {
110    linter: LintGroup,
111    dict: Lrc<FstDictionary>,
112}
113
114impl HarperEngine {
115    #[must_use]
116    pub fn new(config: &crate::config::HarperConfig) -> Self {
117        let dialect = match config.dialect.as_str() {
118            "British" => Dialect::British,
119            "Canadian" => Dialect::Canadian,
120            "Australian" => Dialect::Australian,
121            _ => Dialect::American,
122        };
123        let dict = FstDictionary::curated();
124        let mut linter = LintGroup::new_curated(dict.clone(), dialect);
125
126        for (rule, enabled) in &config.linters {
127            linter.config.set_rule_enabled(rule, *enabled);
128        }
129
130        Self { linter, dict }
131    }
132}
133
134#[async_trait::async_trait]
135impl Engine for HarperEngine {
136    fn name(&self) -> &'static str {
137        "harper"
138    }
139
140    fn supported_languages(&self) -> Vec<&'static str> {
141        vec!["en"]
142    }
143
144    async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
145        let document = Document::new(text, &Markdown::default(), self.dict.as_ref());
146        let lints = self.linter.lint(&document);
147
148        // Harper spans are char indices; the protocol wants UTF-8 byte offsets.
149        let char_to_byte = char_to_byte_table(text);
150
151        let diagnostics = lints
152            .into_iter()
153            .map(|lint| {
154                let suggestions = lint
155                    .suggestions
156                    .into_iter()
157                    .map(|s| match s {
158                        harper_core::linting::Suggestion::ReplaceWith(chars) => {
159                            chars.into_iter().collect::<String>()
160                        }
161                        harper_core::linting::Suggestion::InsertAfter(chars) => {
162                            let content: String = chars.into_iter().collect();
163                            format!("Insert \"{content}\"")
164                        }
165                        // Empty string replacement = delete the diagnostic range
166                        harper_core::linting::Suggestion::Remove => String::new(),
167                    })
168                    .collect();
169
170                Diagnostic {
171                    start_byte: lookup_offset(&char_to_byte, lint.span.start),
172                    end_byte: lookup_offset(&char_to_byte, lint.span.end),
173                    message: lint.message,
174                    suggestions,
175                    rule_id: format!("harper.{:?}", lint.lint_kind),
176                    severity: Severity::Warning as i32,
177                    unified_id: String::new(), // Will be filled by normalizer
178                    confidence: 0.8,
179                }
180            })
181            .collect();
182
183        Ok(diagnostics)
184    }
185}
186
187pub struct LanguageToolEngine {
188    url: String,
189    level: String,
190    mother_tongue: Option<String>,
191    disabled_rules: Vec<String>,
192    enabled_rules: Vec<String>,
193    disabled_categories: Vec<String>,
194    enabled_categories: Vec<String>,
195    max_concurrent_requests: usize,
196    client: reqwest::Client,
197}
198
199#[derive(Deserialize)]
200struct LTResponse {
201    matches: Vec<LTMatch>,
202}
203
204#[derive(Deserialize)]
205struct LTMatch {
206    message: String,
207    offset: usize,
208    length: usize,
209    replacements: Vec<LTReplacement>,
210    rule: LTRule,
211}
212
213#[derive(Deserialize)]
214struct LTReplacement {
215    value: String,
216}
217
218#[derive(Deserialize)]
219#[serde(rename_all = "camelCase")]
220struct LTRule {
221    id: String,
222    issue_type: String,
223}
224
225impl LanguageToolEngine {
226    #[must_use]
227    pub fn new(config: &crate::config::LanguageToolConfig) -> Self {
228        let client = reqwest::Client::builder()
229            .connect_timeout(std::time::Duration::from_secs(3))
230            .timeout(std::time::Duration::from_secs(10))
231            .build()
232            .unwrap_or_default();
233        Self {
234            url: config.url.clone(),
235            level: config.level.clone(),
236            mother_tongue: config.mother_tongue.clone(),
237            disabled_rules: config.disabled_rules.clone(),
238            enabled_rules: config.enabled_rules.clone(),
239            disabled_categories: config.disabled_categories.clone(),
240            enabled_categories: config.enabled_categories.clone(),
241            max_concurrent_requests: config.max_concurrent_requests.max(1),
242            client,
243        }
244    }
245
246    /// The form fields every request shares — everything except `text`.
247    fn base_form(&self, language_id: &str) -> Vec<(&'static str, String)> {
248        // language_id is a BCP-47 tag from the orchestrator (e.g. "en-US", "de-DE").
249        let mut form: Vec<(&'static str, String)> = vec![("language", language_id.to_string())];
250        if self.level != "default" {
251            form.push(("level", self.level.clone()));
252        }
253        if let Some(ref mt) = self.mother_tongue {
254            form.push(("motherTongue", mt.clone()));
255        }
256        if !self.disabled_rules.is_empty() {
257            form.push(("disabledRules", self.disabled_rules.join(",")));
258        }
259        if !self.enabled_rules.is_empty() {
260            form.push(("enabledRules", self.enabled_rules.join(",")));
261        }
262        if !self.disabled_categories.is_empty() {
263            form.push(("disabledCategories", self.disabled_categories.join(",")));
264        }
265        if !self.enabled_categories.is_empty() {
266            form.push(("enabledCategories", self.enabled_categories.join(",")));
267        }
268        form
269    }
270}
271
272/// One `POST /v2/check`.
273///
274/// Free-standing rather than a method so a batch can drive many at once from
275/// cloned handles — `reqwest::Client` is an `Arc` internally, so the clones
276/// share one connection pool.
277#[allow(clippy::cast_possible_truncation)]
278async fn languagetool_request(
279    client: &reqwest::Client,
280    url: &str,
281    base_form: &[(&'static str, String)],
282    text: &str,
283) -> Result<Vec<Diagnostic>> {
284    debug!(url = %url, text_len = text.len(), "LanguageTool request");
285
286    let mut form_params: Vec<(&str, String)> = Vec::with_capacity(base_form.len() + 1);
287    form_params.push(("text", text.to_string()));
288    form_params.extend(base_form.iter().map(|(k, v)| (*k, v.clone())));
289
290    let request_start = std::time::Instant::now();
291    let response = match client.post(url).form(&form_params).send().await {
292        Ok(r) => {
293            let status = r.status();
294            debug!(
295                status = %status,
296                elapsed_ms = request_start.elapsed().as_millis() as u64,
297                "LanguageTool HTTP response"
298            );
299            if !status.is_success() {
300                let body = r.text().await.unwrap_or_default();
301                warn!(
302                    status = %status,
303                    body = %body,
304                    "LanguageTool returned non-200"
305                );
306                return Err(anyhow::anyhow!("LanguageTool HTTP {status}: {body}"));
307            }
308            r
309        }
310        Err(e) => {
311            warn!(
312                elapsed_ms = request_start.elapsed().as_millis() as u64,
313                "LanguageTool connection error: {e}"
314            );
315            return Err(anyhow::anyhow!("LanguageTool connection error: {e}"));
316        }
317    };
318
319    let res = match response.json::<LTResponse>().await {
320        Ok(r) => r,
321        Err(e) => {
322            warn!("LanguageTool JSON parse error: {e}");
323            return Err(anyhow::anyhow!("LanguageTool JSON parse error: {e}"));
324        }
325    };
326
327    debug!(
328        matches = res.matches.len(),
329        elapsed_ms = request_start.elapsed().as_millis() as u64,
330        "LanguageTool check complete"
331    );
332
333    // LanguageTool reports offsets in UTF-16 code units; convert to bytes.
334    let utf16_to_byte = utf16_to_byte_table(text);
335
336    Ok(res
337        .matches
338        .into_iter()
339        .map(|m| {
340            let severity = match m.rule.issue_type.as_str() {
341                "misspelling" => Severity::Error,
342                "typographical" => Severity::Warning,
343                _ => Severity::Information,
344            };
345
346            Diagnostic {
347                start_byte: lookup_offset(&utf16_to_byte, m.offset),
348                end_byte: lookup_offset(&utf16_to_byte, m.offset + m.length),
349                message: m.message,
350                suggestions: m.replacements.into_iter().map(|r| r.value).collect(),
351                rule_id: format!("languagetool.{}", m.rule.id),
352                severity: severity as i32,
353                unified_id: String::new(), // Will be filled by normalizer
354                confidence: 0.8,
355            }
356        })
357        .collect())
358}
359
360#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
361#[async_trait::async_trait]
362impl Engine for LanguageToolEngine {
363    fn name(&self) -> &'static str {
364        "languagetool"
365    }
366
367    async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
368        let url = format!("{}/v2/check", self.url);
369        languagetool_request(&self.client, &url, &self.base_form(language_id), text).await
370    }
371
372    /// Overlap the per-range requests instead of paying the round trip serially.
373    ///
374    /// A page of prose is hundreds of small ranges, so wall-clock is dominated
375    /// by request latency rather than by `LanguageTool`'s own work. Requests are
376    /// capped at `max_concurrent_requests` in flight so a shared server is not
377    /// swamped, and results are re-ordered to match `texts`.
378    async fn check_many(
379        &mut self,
380        texts: &[String],
381        language_id: &str,
382    ) -> Vec<Result<Vec<Diagnostic>>> {
383        let limit = self.max_concurrent_requests.max(1);
384        if limit == 1 || texts.len() <= 1 {
385            let mut results = Vec::with_capacity(texts.len());
386            for text in texts {
387                results.push(self.check(text, language_id).await);
388            }
389            return results;
390        }
391
392        let url = Arc::new(format!("{}/v2/check", self.url));
393        let base_form = Arc::new(self.base_form(language_id));
394        let permits = Arc::new(Semaphore::new(limit));
395        let mut tasks = JoinSet::new();
396
397        for (idx, text) in texts.iter().enumerate() {
398            let client = self.client.clone();
399            let url = Arc::clone(&url);
400            let base_form = Arc::clone(&base_form);
401            let permits = Arc::clone(&permits);
402            let text = text.clone();
403            tasks.spawn(async move {
404                // The semaphore is never closed, so acquiring only fails if the
405                // runtime is shutting down — treat that as "no slot, run anyway".
406                let _permit = permits.acquire().await.ok();
407                (
408                    idx,
409                    languagetool_request(&client, &url, &base_form, &text).await,
410                )
411            });
412        }
413
414        let mut slots: Vec<Option<Result<Vec<Diagnostic>>>> = texts.iter().map(|_| None).collect();
415        while let Some(joined) = tasks.join_next().await {
416            match joined {
417                Ok((idx, result)) => slots[idx] = Some(result),
418                Err(e) => warn!("LanguageTool batch task failed to join: {e}"),
419            }
420        }
421        slots
422            .into_iter()
423            .map(|slot| slot.unwrap_or_else(|| Err(anyhow::anyhow!("LanguageTool task dropped"))))
424            .collect()
425    }
426}
427
428/// An external checker engine that communicates with a subprocess via stdin/stdout JSON.
429pub struct ExternalEngine {
430    name: String,
431    command: String,
432    args: Vec<String>,
433}
434
435impl ExternalEngine {
436    #[must_use]
437    pub const fn new(name: String, command: String, args: Vec<String>) -> Self {
438        Self {
439            name,
440            command,
441            args,
442        }
443    }
444}
445
446/// JSON request sent to the external process on stdin.
447#[derive(serde::Serialize)]
448struct ExternalRequest<'a> {
449    text: &'a str,
450    language_id: &'a str,
451}
452
453/// JSON diagnostic returned by the external process on stdout.
454#[derive(Deserialize)]
455struct ExternalDiagnostic {
456    start_byte: u32,
457    end_byte: u32,
458    message: String,
459    #[serde(default)]
460    suggestions: Vec<String>,
461    #[serde(default)]
462    rule_id: String,
463    #[serde(default = "default_severity_value")]
464    severity: i32,
465    #[serde(default)]
466    confidence: f32,
467}
468
469const fn default_severity_value() -> i32 {
470    Severity::Warning as i32
471}
472
473#[async_trait::async_trait]
474impl Engine for ExternalEngine {
475    fn name(&self) -> &'static str {
476        "external"
477    }
478
479    async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
480        use tokio::process::Command;
481
482        let request = ExternalRequest { text, language_id };
483        let input = serde_json::to_string(&request)?;
484
485        let output = match Command::new(&self.command)
486            .args(&self.args)
487            .stdin(std::process::Stdio::piped())
488            .stdout(std::process::Stdio::piped())
489            .stderr(std::process::Stdio::piped())
490            .spawn()
491        {
492            Ok(mut child) => {
493                use tokio::io::AsyncWriteExt;
494                if let Some(mut stdin) = child.stdin.take() {
495                    // Ignore write errors — the process may exit before reading stdin.
496                    let _ = stdin.write_all(input.as_bytes()).await;
497                    let _ = stdin.shutdown().await;
498                }
499                child.wait_with_output().await?
500            }
501            Err(e) => {
502                warn!(provider = %self.name, "Failed to spawn external provider: {e}");
503                return Ok(vec![]);
504            }
505        };
506
507        if !output.status.success() {
508            let stderr = String::from_utf8_lossy(&output.stderr);
509            warn!(
510                provider = %self.name,
511                status = %output.status,
512                stderr = stderr.trim(),
513                "External provider exited with error"
514            );
515            return Ok(vec![]);
516        }
517
518        let stdout = String::from_utf8_lossy(&output.stdout);
519        let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&stdout) {
520            Ok(d) => d,
521            Err(e) => {
522                warn!(provider = %self.name, "Failed to parse external provider output: {e}");
523                return Ok(vec![]);
524            }
525        };
526
527        let diagnostics = ext_diagnostics
528            .into_iter()
529            .map(|ed| {
530                let rule_id = if ed.rule_id.is_empty() {
531                    format!("external.{}", self.name)
532                } else {
533                    format!("external.{}.{}", self.name, ed.rule_id)
534                };
535                Diagnostic {
536                    start_byte: ed.start_byte,
537                    end_byte: ed.end_byte,
538                    message: ed.message,
539                    suggestions: ed.suggestions,
540                    rule_id,
541                    severity: ed.severity,
542                    unified_id: String::new(),
543                    confidence: if ed.confidence > 0.0 {
544                        ed.confidence
545                    } else {
546                        0.7
547                    },
548                }
549            })
550            .collect();
551
552        Ok(diagnostics)
553    }
554}
555
556/// A WASM checker plugin loaded via Extism.
557///
558/// The plugin must export a `check` function that accepts a JSON string
559/// `{"text": "...", "language_id": "..."}` and returns a JSON array of
560/// diagnostics matching the `ExternalDiagnostic` schema.
561pub struct WasmEngine {
562    name: String,
563    plugin: Plugin,
564}
565
566// SAFETY: Extism Plugin is not Send by default because it wraps a wasmtime Store
567// which holds raw pointers. However, we only ever access the plugin from a single
568// &mut self call at a time (the Engine trait takes &mut self), so this is safe
569// as long as we don't share across threads simultaneously.
570unsafe impl Send for WasmEngine {}
571
572impl WasmEngine {
573    /// Create a new WASM engine from a `.wasm` file path.
574    pub fn new(name: String, wasm_path: PathBuf) -> Result<Self> {
575        let wasm = Wasm::file(wasm_path);
576        let manifest = Manifest::new([wasm]);
577        let plugin = Plugin::new(&manifest, [], true)?;
578        Ok(Self { name, plugin })
579    }
580
581    /// Create a new WASM engine from raw bytes (useful for testing).
582    pub fn from_bytes(name: String, wasm_bytes: &[u8]) -> Result<Self> {
583        let wasm = Wasm::data(wasm_bytes.to_vec());
584        let manifest = Manifest::new([wasm]);
585        let plugin = Plugin::new(&manifest, [], true)?;
586        Ok(Self { name, plugin })
587    }
588}
589
590#[async_trait::async_trait]
591impl Engine for WasmEngine {
592    fn name(&self) -> &'static str {
593        "wasm"
594    }
595
596    async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
597        let request = serde_json::json!({
598            "text": text,
599            "language_id": language_id,
600        });
601        let input = request.to_string();
602
603        let output = match self.plugin.call::<&str, &str>("check", &input) {
604            Ok(result) => result.to_string(),
605            Err(e) => {
606                warn!(plugin = %self.name, "WASM plugin call failed: {e}");
607                return Ok(vec![]);
608            }
609        };
610
611        let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&output) {
612            Ok(d) => d,
613            Err(e) => {
614                warn!(plugin = %self.name, "Failed to parse WASM plugin output: {e}");
615                return Ok(vec![]);
616            }
617        };
618
619        let diagnostics = ext_diagnostics
620            .into_iter()
621            .map(|ed| {
622                let rule_id = if ed.rule_id.is_empty() {
623                    format!("wasm.{}", self.name)
624                } else {
625                    format!("wasm.{}.{}", self.name, ed.rule_id)
626                };
627                Diagnostic {
628                    start_byte: ed.start_byte,
629                    end_byte: ed.end_byte,
630                    message: ed.message,
631                    suggestions: ed.suggestions,
632                    rule_id,
633                    severity: ed.severity,
634                    unified_id: String::new(),
635                    confidence: if ed.confidence > 0.0 {
636                        ed.confidence
637                    } else {
638                        0.7
639                    },
640                }
641            })
642            .collect();
643
644        Ok(diagnostics)
645    }
646}
647
648/// Discover WASM plugins from a directory (e.g. `.languagecheck/plugins/`).
649/// Returns a list of (name, path) pairs for each `.wasm` file found.
650#[must_use]
651pub fn discover_wasm_plugins(plugin_dir: &std::path::Path) -> Vec<(String, PathBuf)> {
652    let Ok(entries) = std::fs::read_dir(plugin_dir) else {
653        return Vec::new();
654    };
655
656    entries
657        .filter_map(|entry| {
658            let entry = entry.ok()?;
659            let path = entry.path();
660            if path.extension().is_some_and(|e| e == "wasm") {
661                let name = path
662                    .file_stem()
663                    .map(|s| s.to_string_lossy().to_string())
664                    .unwrap_or_default();
665                Some((name, path))
666            } else {
667                None
668            }
669        })
670        .collect()
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn char_to_byte_handles_multibyte() {
679        // "a—b": 'a'=1 byte, '—'(U+2014)=3 bytes, 'b'=1 byte.
680        let table = char_to_byte_table("a—b");
681        assert_eq!(table, vec![0, 1, 4, 5]); // char idx 0,1,2 -> bytes; 3 -> len
682        assert_eq!(lookup_offset(&table, 2), 4); // 'b' starts at byte 4, not 2
683        assert_eq!(lookup_offset(&table, 3), 5); // end-of-text
684        assert_eq!(lookup_offset(&table, 99), 5); // clamp
685    }
686
687    #[test]
688    fn utf16_to_byte_handles_astral() {
689        // "a😀b": 'a'=1 byte/1 unit, '😀'(U+1F600)=4 bytes/2 units, 'b'=1 byte.
690        let table = utf16_to_byte_table("a😀b");
691        // units: 0->'a'@0, 1&2->'😀'@1, 3->'b'@5, 4->end@6
692        assert_eq!(table, vec![0, 1, 1, 5, 6]);
693        assert_eq!(lookup_offset(&table, 3), 5); // 'b' after surrogate pair
694    }
695
696    #[test]
697    fn em_dash_does_not_shift_byte_offsets() {
698        // A char-index span (Harper-style) for "b" in "a—b" is (2, 3); after
699        // conversion it must point at bytes (4, 5), not (2, 3).
700        let table = char_to_byte_table("a—b");
701        assert_eq!(lookup_offset(&table, 2), 4);
702        assert_eq!(lookup_offset(&table, 3), 5);
703    }
704
705    #[tokio::test]
706    async fn test_harper_engine() -> Result<()> {
707        let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
708        let text = "This is an test.";
709        let diagnostics = engine.check(text, "en-US").await?;
710
711        // Harper should find "an test" error
712        assert!(!diagnostics.is_empty());
713
714        Ok(())
715    }
716
717    #[tokio::test]
718    async fn harper_offsets_are_bytes_after_em_dash() -> Result<()> {
719        // An em-dash before the error must not shift the diagnostic's byte span.
720        let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
721        let text = "Some prose — this is an test.";
722        let diagnostics = engine.check(text, "en-US").await?;
723        assert!(!diagnostics.is_empty(), "Harper should flag 'an test'");
724
725        // Every diagnostic span must land on valid UTF-8 byte boundaries of the
726        // ORIGINAL text and slice to non-empty content (char-index spans would
727        // fall short by 2 bytes per em-dash and could split the multibyte char).
728        for d in &diagnostics {
729            let (s, e) = (d.start_byte as usize, d.end_byte as usize);
730            assert!(text.is_char_boundary(s), "start {s} not a char boundary");
731            assert!(text.is_char_boundary(e), "end {e} not a char boundary");
732            assert!(s <= e && e <= text.len(), "span ({s},{e}) out of range");
733        }
734        Ok(())
735    }
736
737    #[tokio::test]
738    async fn external_engine_with_echo() -> Result<()> {
739        // Use a simple shell command that echoes a valid JSON response
740        let mut engine = ExternalEngine::new(
741            "test-provider".to_string(),
742            "sh".to_string(),
743            vec![
744                "-c".to_string(),
745                r#"cat > /dev/null; echo '[{"start_byte":0,"end_byte":4,"message":"test issue","suggestions":["fix"],"rule_id":"test.rule","severity":2}]'"#.to_string(),
746            ],
747        );
748
749        let diagnostics = engine.check("some text", "markdown").await?;
750        assert_eq!(diagnostics.len(), 1);
751        assert_eq!(diagnostics[0].message, "test issue");
752        assert_eq!(diagnostics[0].rule_id, "external.test-provider.test.rule");
753        assert_eq!(diagnostics[0].suggestions, vec!["fix"]);
754        assert_eq!(diagnostics[0].start_byte, 0);
755        assert_eq!(diagnostics[0].end_byte, 4);
756
757        Ok(())
758    }
759
760    #[tokio::test]
761    async fn external_engine_missing_binary() -> Result<()> {
762        let mut engine = ExternalEngine::new(
763            "nonexistent".to_string(),
764            "/nonexistent/binary".to_string(),
765            vec![],
766        );
767
768        // Should not error, just return empty
769        let diagnostics = engine.check("text", "markdown").await?;
770        assert!(diagnostics.is_empty());
771
772        Ok(())
773    }
774
775    #[tokio::test]
776    async fn external_engine_bad_json_output() -> Result<()> {
777        let mut engine = ExternalEngine::new(
778            "bad-json".to_string(),
779            "echo".to_string(),
780            vec!["not json".to_string()],
781        );
782
783        // Should not error, just return empty
784        let diagnostics = engine.check("text", "markdown").await?;
785        assert!(diagnostics.is_empty());
786
787        Ok(())
788    }
789
790    #[test]
791    fn wasm_engine_invalid_bytes_returns_error() {
792        let result = WasmEngine::from_bytes("bad-plugin".to_string(), b"not a wasm file");
793        assert!(result.is_err());
794    }
795
796    #[test]
797    fn wasm_engine_missing_file_returns_error() {
798        let result = WasmEngine::new(
799            "missing".to_string(),
800            PathBuf::from("/nonexistent/plugin.wasm"),
801        );
802        assert!(result.is_err());
803    }
804
805    #[test]
806    fn discover_wasm_plugins_empty_dir() {
807        let dir = std::env::temp_dir().join("lang_check_test_wasm_empty");
808        let _ = std::fs::remove_dir_all(&dir);
809        std::fs::create_dir_all(&dir).unwrap();
810
811        let plugins = discover_wasm_plugins(&dir);
812        assert!(plugins.is_empty());
813
814        let _ = std::fs::remove_dir_all(&dir);
815    }
816
817    #[test]
818    fn discover_wasm_plugins_finds_wasm_files() {
819        let dir = std::env::temp_dir().join("lang_check_test_wasm_discover");
820        let _ = std::fs::remove_dir_all(&dir);
821        std::fs::create_dir_all(&dir).unwrap();
822
823        // Create fake .wasm files and a non-wasm file
824        std::fs::write(dir.join("checker.wasm"), b"fake").unwrap();
825        std::fs::write(dir.join("linter.wasm"), b"fake").unwrap();
826        std::fs::write(dir.join("readme.txt"), b"not a plugin").unwrap();
827
828        let mut plugins = discover_wasm_plugins(&dir);
829        plugins.sort_by(|a, b| a.0.cmp(&b.0));
830
831        assert_eq!(plugins.len(), 2);
832        assert_eq!(plugins[0].0, "checker");
833        assert_eq!(plugins[1].0, "linter");
834        assert!(plugins[0].1.ends_with("checker.wasm"));
835        assert!(plugins[1].1.ends_with("linter.wasm"));
836
837        let _ = std::fs::remove_dir_all(&dir);
838    }
839
840    #[test]
841    fn discover_wasm_plugins_nonexistent_dir() {
842        let plugins = discover_wasm_plugins(std::path::Path::new("/nonexistent/dir"));
843        assert!(plugins.is_empty());
844    }
845
846    /// Live integration test — requires LT Docker on localhost:8010.
847    /// Run with: `cargo test lt_engine_live -- --ignored --nocapture`
848    #[tokio::test]
849    #[ignore]
850    async fn lt_engine_live() -> Result<()> {
851        // Initialize tracing for visible output
852        let _ = tracing_subscriber::fmt()
853            .with_env_filter("debug")
854            .with_writer(std::io::stderr)
855            .with_target(false)
856            .try_init();
857
858        let mut engine = LanguageToolEngine::new(&crate::config::LanguageToolConfig::default());
859        let text = "This is a sentnce with erors.";
860        let diagnostics = engine.check(text, "markdown").await?;
861
862        println!("LT returned {} diagnostics:", diagnostics.len());
863        for d in &diagnostics {
864            println!(
865                "  [{}-{}] {} (rule: {}, suggestions: {:?})",
866                d.start_byte, d.end_byte, d.message, d.rule_id, d.suggestions
867            );
868        }
869
870        assert!(
871            diagnostics.len() >= 2,
872            "Expected at least 2 spelling errors, got {}",
873            diagnostics.len()
874        );
875        Ok(())
876    }
877
878    #[test]
879    fn lt_response_deserializes_camel_case() {
880        // Real LanguageTool API response (trimmed) — uses camelCase `issueType`
881        let json = r#"{
882            "matches": [{
883                "message": "Possible spelling mistake found.",
884                "offset": 10,
885                "length": 7,
886                "replacements": [{"value": "sentence"}],
887                "rule": {
888                    "id": "MORFOLOGIK_RULE_EN_US",
889                    "description": "Possible spelling mistake",
890                    "issueType": "misspelling",
891                    "category": {"id": "TYPOS", "name": "Possible Typo"}
892                }
893            }]
894        }"#;
895        let res: LTResponse = serde_json::from_str(json).unwrap();
896        assert_eq!(res.matches.len(), 1);
897        assert_eq!(res.matches[0].rule.id, "MORFOLOGIK_RULE_EN_US");
898        assert_eq!(res.matches[0].rule.issue_type, "misspelling");
899        assert_eq!(res.matches[0].offset, 10);
900        assert_eq!(res.matches[0].length, 7);
901        assert_eq!(res.matches[0].replacements[0].value, "sentence");
902    }
903}