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 fn supported_languages(&self) -> Vec<&'static str> {
29 vec![]
30 }
31
32 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
52pub 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
65fn 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
80fn 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
100fn 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 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 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(), 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 fn base_form(&self, language_id: &str) -> Vec<(&'static str, String)> {
248 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#[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 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(), 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 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 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
428pub 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#[derive(serde::Serialize)]
448struct ExternalRequest<'a> {
449 text: &'a str,
450 language_id: &'a str,
451}
452
453#[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 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
556pub struct WasmEngine {
562 name: String,
563 plugin: Plugin,
564}
565
566unsafe impl Send for WasmEngine {}
571
572impl WasmEngine {
573 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 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#[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 let table = char_to_byte_table("a—b");
681 assert_eq!(table, vec![0, 1, 4, 5]); assert_eq!(lookup_offset(&table, 2), 4); assert_eq!(lookup_offset(&table, 3), 5); assert_eq!(lookup_offset(&table, 99), 5); }
686
687 #[test]
688 fn utf16_to_byte_handles_astral() {
689 let table = utf16_to_byte_table("a😀b");
691 assert_eq!(table, vec![0, 1, 1, 5, 6]);
693 assert_eq!(lookup_offset(&table, 3), 5); }
695
696 #[test]
697 fn em_dash_does_not_shift_byte_offsets() {
698 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 assert!(!diagnostics.is_empty());
713
714 Ok(())
715 }
716
717 #[tokio::test]
718 async fn harper_offsets_are_bytes_after_em_dash() -> Result<()> {
719 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 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 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 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 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 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 #[tokio::test]
849 #[ignore]
850 async fn lt_engine_live() -> Result<()> {
851 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 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}