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